diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts index 1a0e76a..f327685 100644 --- a/src/web/api/session.test.ts +++ b/src/web/api/session.test.ts @@ -8,6 +8,8 @@ import { createUserStore, type UserStore } from "../../data/users.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createAuditStore } from "../../data/audit.js"; import { createPermissionStore } from "../../data/permissions.js"; +import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js"; +import type { GuestPermissions, BotAccess } from "../../data/permissions.js"; import { createSessionRouter } from "./session.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -17,7 +19,17 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { app.use(cookieParser()); const audit = createAuditStore(botDb.db); const permissions = createPermissionStore(botDb.db); - app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions)); + app.use( + "/api/session", + createSessionRouter( + users, + sessions, + audit, + pino({ level: "silent" }), + permissions, + () => getDefaultConfig().guestMode + ) + ); return app; } @@ -47,7 +59,7 @@ describe("session router", () => { it("GET /needs-setup returns true on an empty db", async () => { const res = await request(app).get("/api/session/needs-setup"); expect(res.status).toBe(200); - expect(res.body).toEqual({ needsSetup: true }); + expect(res.body).toEqual({ needsSetup: true, guestAllowed: false }); }); it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => { @@ -59,7 +71,7 @@ describe("session router", () => { extractCookie(setupRes); const needs = await request(app).get("/api/session/needs-setup"); - expect(needs.body).toEqual({ needsSetup: false }); + expect(needs.body).toEqual({ needsSetup: false, guestAllowed: false }); }); it("POST /setup returns 409 once a user already exists", async () => { @@ -157,3 +169,71 @@ describe("session router", () => { expect(u.id).toBe(meA.body.id); }); }); + +describe("session router — guest mode", () => { + let botDb: BotDatabase; + + afterEach(() => botDb.close()); + + function makeApp(opts: { + guestEnabled: boolean; + guestPermissions?: GuestPermissions; + guestBots?: BotAccess; + }) { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const audit = createAuditStore(botDb.db); + const permissions = createPermissionStore(botDb.db); + const guestCfg: GuestModeConfig = { + enabled: opts.guestEnabled, + bots: opts.guestBots ?? getDefaultConfig().guestMode.bots, + permissions: opts.guestPermissions ?? getDefaultConfig().guestMode.permissions, + }; + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use( + "/api/session", + createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions, () => guestCfg) + ); + return { app, users, sessions }; + } + + it("POST /guest is 403 when guest mode disabled", async () => { + const { app } = makeApp({ guestEnabled: false }); + const res = await request(app).post("/api/session/guest"); + expect(res.status).toBe(403); + }); + + it("POST /guest mints a guest session when enabled, and /me reports role guest + flags", async () => { + const { app } = makeApp({ + guestEnabled: true, + guestPermissions: { + addToQueue: true, + playNext: true, + playNow: false, + skip: false, + transport: false, + removeClear: false, + playMode: false, + }, + guestBots: "all", + }); + const login = await request(app).post("/api/session/guest"); + expect(login.status).toBe(200); + expect(login.body.role).toBe("guest"); + const cookie = login.headers["set-cookie"]; + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.body.role).toBe("guest"); + expect(me.body.guest.addToQueue).toBe(true); + expect(me.body.guest.playNext).toBe(true); + expect(me.body.capabilities).toEqual([]); + }); + + it("GET /needs-setup exposes guestAllowed", async () => { + const { app } = makeApp({ guestEnabled: true }); + const res = await request(app).get("/api/session/needs-setup"); + expect(res.body.guestAllowed).toBe(true); + }); +}); diff --git a/src/web/api/session.ts b/src/web/api/session.ts index 85b26b6..1d67b89 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -5,7 +5,9 @@ import type { UserStore } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; import type { AuditStore } from "../../data/audit.js"; import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js"; -import { SESSION_TTL_MS } from "../../data/sessions.js"; +import { SESSION_TTL_MS, GUEST_SESSION_TTL_MS } from "../../data/sessions.js"; +import { GUEST_USER_ID, GUEST_USERNAME } from "../../data/users.js"; +import type { GuestModeConfig } from "../../data/config.js"; import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js"; const FAILED_LOGIN_DELAY_MS = 250; @@ -51,7 +53,8 @@ export function createSessionRouter( sessions: SessionStore, audit: AuditStore, logger: Logger, - permissions: PermissionStore + permissions: PermissionStore, + getGuestConfig: () => GuestModeConfig ): Router { const router = Router(); @@ -69,7 +72,7 @@ export function createSessionRouter( }; router.get("/needs-setup", (_req, res) => { - res.json({ needsSetup: users.countUsers() === 0 }); + res.json({ needsSetup: users.countUsers() === 0, guestAllowed: getGuestConfig().enabled }); }); router.post("/setup", async (req, res) => { @@ -125,6 +128,17 @@ export function createSessionRouter( res.json({ id: user.id, username: user.username, role: user.role }); }); + router.post("/guest", (_req, res) => { + const cfg = getGuestConfig(); + if (!cfg.enabled) { + res.status(403).json({ error: "guest mode disabled" }); + return; + } + const { token } = sessions.createSession(GUEST_USER_ID, { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }); + setSessionCookie(res, token); + res.json({ id: GUEST_USER_ID, username: GUEST_USERNAME, role: "guest" }); + }); + router.post("/logout", (req, res) => { const token = parseTokenFromCookie(req.headers.cookie); if (token) { @@ -136,13 +150,20 @@ export function createSessionRouter( router.get("/me", requireAuthInline, (req, res) => { const user = req.user!; - const ctx = resolvePermissionContext(user.role, user.id, permissions); + const cfg = getGuestConfig(); + const ctx = resolvePermissionContext( + user.role, + user.id, + permissions, + user.role === "guest" ? { bots: cfg.bots, permissions: cfg.permissions } : undefined + ); res.json({ id: user.id, username: user.username, role: user.role, capabilities: [...ctx.capabilities], bots: ctx.bots === "all" ? "all" : [...ctx.bots], + guest: ctx.guest ?? null, }); }); diff --git a/src/web/server.ts b/src/web/server.ts index b1d5809..716c4b5 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -95,7 +95,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use("/api/session/login", loginLimit); app.use("/api/session/setup", setupLimit); - app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions)); + app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode)); // ─── Gates for everything else under /api ─────────────────────────────── const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);