# Guest Mode (Login-less WebUI Access) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an optional, default-off "guest mode" that lets an admin allow anonymous (no-login) visitors to use a restricted, per-permission-configurable subset of the WebUI. **Architecture:** A guest is a synthetic, config-driven principal (`role: "guest"`) backed by one reserved DB user row, resolved per-request from `config.guestMode`. A single unified `authorize({capability, guestFlag})` middleware encapsulates admin/member/guest authorization, leaving the existing member/admin capability system behavior-unchanged. The append-vs-play-next queue split already exists; guest mode adds fine-grained admin toggles plus per-bot scope (enforced on REST and WebSocket). **Tech Stack:** Node 20 + TypeScript (ESM, `.js` import specifiers), Express 5, better-sqlite3, `ws`, Vue 3 + Pinia + vue-router, Vitest + supertest. ## Global Constraints - ESM project: all relative imports use the `.js` extension even from `.ts` files (e.g. `import { x } from "./permissions.js"`). Match this exactly. - Backend tests run with `npx vitest run `; the whole suite with `npm test`. Backend type-check: `npx tsc --noEmit`. Web type-check/build: `cd web && npx vue-tsc --noEmit`. - The 7 guest permission flag names are fixed and identical everywhere (backend config, `GuestPermissions`, frontend, UI): `addToQueue`, `playNext`, `playNow`, `skip`, `transport`, `removeClear`, `playMode`. - Default guest config (when first enabled): `enabled:false`, `bots:"all"`, permissions `{ addToQueue:true, playNext:false, playNow:false, skip:false, transport:false, removeClear:false, playMode:false }`. - Guests are ALWAYS denied: settings view/write, bot management, platform auth, quality, user management, audit, change-password. - Reserved guest principal: `GUEST_USER_ID = "__guest__"`, `GUEST_USERNAME = "游客"` (non-ASCII so the username can never be created via the API). - Every `git commit` in this plan ends with the trailer line: `Co-Authored-By: Claude Opus 4.8 (1M context) ` (omitted from the per-step snippets below for brevity — add it to every commit). - Branch: all work lands on `feat/guest-mode` (already created). --- ## File Structure New files: - `src/web/middleware/authorize.ts` — unified admin/member/guest gate. - `src/web/middleware/requireNotGuest.ts` — allow admin+member, deny guest (for config reads). - `src/web/middleware/authorize.test.ts`, `src/web/middleware/requireNotGuest.test.ts`. Modified (backend): `src/data/config.ts` (+test), `src/data/permissions.ts` (+test), `src/data/users.ts` (+test), `src/data/sessions.ts` (+test), `src/data/database.ts` (+test), `src/web/middleware/requireAuth.ts` (+test), `src/web/api/session.ts` (+test), `src/web/api/player.ts`, `src/web/api/bot.ts` (+test), `src/web/api/music.ts`, `src/web/server.ts`, `src/web/websocket.ts` (+ `src/web/websocket-auth.test.ts`). Modified (frontend): `web/src/composables/useSession.ts`, `web/src/router/index.ts`, `web/src/views/Login.vue`, `web/src/components/Navbar.vue`, `web/src/App.vue`, `web/src/components/SongCard.vue`, `web/src/stores/player.ts`, `web/src/components/Player.vue`, `web/src/components/Queue.vue`, `web/src/views/Settings.vue`. Docs: `README.md`. --- ## Task 1: Config schema — `guestMode` block + deep merge **Files:** - Modify: `src/data/config.ts` - Test: `src/data/config.test.ts` **Interfaces:** - Produces: `GuestModeConfig { enabled: boolean; bots: BotAccess; permissions: GuestPermissions }`; `BotConfig.guestMode: GuestModeConfig`; `getDefaultConfig()` returns the default block; `loadConfig` deep-merges `guestMode` + `guestMode.permissions`. - Consumes: `BotAccess`, `GuestPermissions` from `./permissions.js` (added in Task 2 — do Task 2 first if your toolchain type-checks on red; tests here only need the runtime shape, but the import must resolve, so **Task 2 must be committed before this compiles**). To keep each task green, implement **Task 2 first**, then this task. (Plan ordering: 2 → 1 is fine; they are presented 1 then 2 for readability but committed 2 then 1. If you prefer, do Task 2's `permissions.ts` type additions, then return here.) - [ ] **Step 1: Write the failing test** — append to `src/data/config.test.ts`: ```ts import { getDefaultConfig, loadConfig, saveConfig } from "./config.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; describe("guestMode config", () => { it("defaults to disabled, all-bots, append-only", () => { const c = getDefaultConfig(); expect(c.guestMode.enabled).toBe(false); expect(c.guestMode.bots).toBe("all"); expect(c.guestMode.permissions).toEqual({ addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, }); }); it("deep-merges a partial guestMode so missing sub-keys are back-filled", () => { const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-")); const p = join(dir, "config.json"); writeFileSync(p, JSON.stringify({ guestMode: { enabled: true, permissions: { playNext: true } } })); const c = loadConfig(p); expect(c.guestMode.enabled).toBe(true); expect(c.guestMode.bots).toBe("all"); // back-filled expect(c.guestMode.permissions.playNext).toBe(true); expect(c.guestMode.permissions.addToQueue).toBe(true); // back-filled default expect(c.guestMode.permissions.skip).toBe(false); // back-filled default rmSync(dir, { recursive: true, force: true }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/config.test.ts` Expected: FAIL (`guestMode` undefined). - [ ] **Step 3: Implement** — in `src/data/config.ts`: Add the import at the top (after the existing `node:fs`/`node:path` imports): ```ts import type { BotAccess, GuestPermissions } from "./permissions.js"; ``` Add interfaces above `BotConfig`: ```ts export interface GuestModeConfig { enabled: boolean; bots: BotAccess; // "all" | string[] permissions: GuestPermissions; } ``` Add the field to `BotConfig` (after `trustProxy: boolean;`): ```ts guestMode: GuestModeConfig; ``` Add to the object returned by `getDefaultConfig()` (after `trustProxy: false,`): ```ts guestMode: { enabled: false, bots: "all", permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, }, }, ``` Replace the body of `loadConfig` to deep-merge `guestMode`: ```ts export function loadConfig(path: string): BotConfig { const defaults = getDefaultConfig(); try { const raw = readFileSync(path, "utf-8"); const partial = JSON.parse(raw) as Partial; return { ...defaults, ...partial, guestMode: { ...defaults.guestMode, ...(partial.guestMode ?? {}), permissions: { ...defaults.guestMode.permissions, ...(partial.guestMode?.permissions ?? {}), }, }, }; } catch { return defaults; } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/config.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/data/config.ts src/data/config.test.ts git commit -m "feat(config): add default-off guestMode block with deep-merge" ``` --- ## Task 2: Permissions — guest types + `resolvePermissionContext` guest branch **Files:** - Modify: `src/data/permissions.ts` - Test: `src/data/permissions.test.ts` **Interfaces:** - Produces: `GuestPermissions` interface; `GUEST_PERMISSION_FLAGS` readonly tuple; `GuestFlag` type; `PermissionContext.guest?: GuestPermissions`; `resolvePermissionContext(role, userId, store, guest?)` where `role: "admin" | "member" | "guest"` and `guest?: { bots: BotAccess; permissions: GuestPermissions }`. - Consumes: existing `CAPABILITIES`, `BotAccess`, `PermissionStore`. > Do this task **before** Task 1 compiles (Task 1 imports `GuestPermissions`/`BotAccess` from here). - [ ] **Step 1: Write the failing test** — append to `src/data/permissions.test.ts`: ```ts import { resolvePermissionContext, GUEST_PERMISSION_FLAGS } from "./permissions.js"; describe("resolvePermissionContext guest branch", () => { const noStore = { getCapabilities: () => [], getBotAccess: () => [] as string[], setPermissions: () => {}, pruneBot: () => {}, }; it("guest has no member capabilities and exposes the guest permissions + bots", () => { const ctx = resolvePermissionContext("guest", "__guest__", noStore, { bots: ["bot1"], permissions: { addToQueue: true, playNext: false, playNow: false, skip: true, transport: false, removeClear: false, playMode: false, }, }); expect([...ctx.capabilities]).toEqual([]); expect(ctx.bots).toBeInstanceOf(Set); expect((ctx.bots as Set).has("bot1")).toBe(true); expect(ctx.guest?.addToQueue).toBe(true); expect(ctx.guest?.skip).toBe(true); }); it("guest with bots:'all' resolves to 'all'", () => { const ctx = resolvePermissionContext("guest", "__guest__", noStore, { bots: "all", permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, }, }); expect(ctx.bots).toBe("all"); }); it("exposes the 7 canonical flags", () => { expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual( ["addToQueue", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort() ); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/permissions.test.ts` Expected: FAIL (`GUEST_PERMISSION_FLAGS`/guest branch missing). - [ ] **Step 3: Implement** — in `src/data/permissions.ts`: After the `BotAccess` type declaration, add: ```ts export interface GuestPermissions { addToQueue: boolean; playNext: boolean; playNow: boolean; skip: boolean; transport: boolean; removeClear: boolean; playMode: boolean; } export const GUEST_PERMISSION_FLAGS = [ "addToQueue", "playNext", "playNow", "skip", "transport", "removeClear", "playMode", ] as const; export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number]; ``` Add `guest` to `PermissionContext`: ```ts export interface PermissionContext { capabilities: Set; bots: "all" | Set; guest?: GuestPermissions; } ``` Replace `resolvePermissionContext` with: ```ts export function resolvePermissionContext( role: "admin" | "member" | "guest", userId: string, store: PermissionStore, guest?: { bots: BotAccess; permissions: GuestPermissions } ): PermissionContext { if (role === "admin") { return { capabilities: new Set(CAPABILITIES), bots: "all" }; } if (role === "guest") { const bots = guest?.bots ?? []; return { capabilities: new Set(), bots: bots === "all" ? "all" : new Set(bots), guest: guest?.permissions, }; } const access = store.getBotAccess(userId); return { capabilities: new Set(store.getCapabilities(userId)), bots: access === "all" ? "all" : new Set(access), }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/permissions.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/data/permissions.ts src/data/permissions.test.ts git commit -m "feat(permissions): guest permission types + resolve guest branch" ``` --- ## Task 3: Users — `guest` role, reserved constants, exclude guests from count/list **Files:** - Modify: `src/data/users.ts` - Test: `src/data/users.test.ts` **Interfaces:** - Produces: `UserRole = "admin" | "member" | "guest"`; `GUEST_USER_ID = "__guest__"`; `GUEST_USERNAME = "游客"`; `countUsers()` and `listUsers()` exclude `role='guest'`. - Consumes: existing `UserStore`. - [ ] **Step 1: Write the failing test** — append to `src/data/users.test.ts` (adapt the helper that builds a DB to match the existing file; the existing tests already create a `db` + `createUserStore` — reuse that setup): ```ts import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js"; describe("guest row exclusion", () => { it("countUsers and listUsers ignore the reserved guest row", async () => { const db = makeTestDb(); // however the existing tests build an in-memory DB with the users table const users = createUserStore(db); await users.createUser("alice", "password123", "member"); // Insert the reserved guest row directly (mirrors the migration). db.prepare( "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')" ).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now()); expect(users.countUsers()).toBe(1); // alice only expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false); }); }); ``` > If `src/data/users.test.ts` has no shared `makeTestDb`, copy the DB-bootstrapping lines used by the existing `describe` blocks in that file (they create a `better-sqlite3` DB and run the `users` `CREATE TABLE`). Keep the table definition identical to `initTables`. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/users.test.ts` Expected: FAIL (`GUEST_USER_ID` undefined / guest counted). - [ ] **Step 3: Implement** — in `src/data/users.ts`: Change the role type: ```ts export type UserRole = "admin" | "member" | "guest"; ``` Add constants under it: ```ts /** Reserved synthetic principal for login-less guest sessions. The username is * non-ASCII so it can never collide with an API-created account (which is * validated against ^[A-Za-z0-9_\-.]{3,32}$). */ export const GUEST_USER_ID = "__guest__"; export const GUEST_USERNAME = "游客"; ``` Change `countStmt` and `listUsersStmt` to exclude guests: ```ts const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'"); ``` ```ts const listUsersStmt = db.prepare( "SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC" ); ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/users.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/data/users.ts src/data/users.test.ts git commit -m "feat(users): guest role + reserved guest principal, excluded from count/list" ``` --- ## Task 4: Sessions — `guest` role, per-session TTL + cap bypass **Files:** - Modify: `src/data/sessions.ts` - Test: `src/data/sessions.test.ts` **Interfaces:** - Produces: `SessionValidation.role: "admin" | "member" | "guest"`; `GUEST_SESSION_TTL_MS`; `createSession(userId, opts?: { ttlMs?: number; skipCap?: boolean })`. - Consumes: existing `sessions` schema. - [ ] **Step 1: Write the failing test** — append to `src/data/sessions.test.ts` (reuse the file's existing DB setup that creates `users` + `sessions` tables and a guest/user row): ```ts import { GUEST_SESSION_TTL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js"; describe("guest sessions", () => { it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => { const db = makeSessionsTestDb(); // existing helper / inline setup // create a user row to satisfy the FK db.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')") .run(Date.now(), Date.now()); const sessions = createSessionStore(db); const tokens = []; for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) { tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token); } // The first token must STILL validate (not evicted). expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest"); const n = (db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n; expect(n).toBe(MAX_SESSIONS_PER_USER + 3); }); it("ttlMs sets a shorter expiry than the default", () => { const db = makeSessionsTestDb(); db.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')") .run(Date.now(), Date.now()); const sessions = createSessionStore(db); const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }); expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/sessions.test.ts` Expected: FAIL (`GUEST_SESSION_TTL_MS` undefined / opts unsupported). - [ ] **Step 3: Implement** — in `src/data/sessions.ts`: Add a constant near the existing TTLs: ```ts export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived ``` Widen `SessionValidation`: ```ts export interface SessionValidation { userId: string; username: string; role: "admin" | "member" | "guest"; } ``` Update the `createSession` signature in the `SessionStore` interface: ```ts createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number }; ``` Replace the `createSession` implementation: ```ts createSession(userId, opts) { const token = randomBytes(32).toString("base64url"); const id = hashToken(token); const now = Date.now(); const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS); const tx = db.transaction(() => { if (!opts?.skipCap) { const existing = (countForUserStmt.get(userId) as { n: number }).n; if (existing >= MAX_SESSIONS_PER_USER) { deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); } } insertStmt.run(id, userId, now, expiresAt, now); }); tx(); return { token, expiresAt }; }, ``` In `validateAndTouch`, widen the returned role cast: ```ts return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/sessions.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/data/sessions.ts src/data/sessions.test.ts git commit -m "feat(sessions): guest role + per-session TTL and cap bypass" ``` --- ## Task 5: Database — create the reserved guest user row (idempotent migration) **Files:** - Modify: `src/data/database.ts` - Test: `src/data/database.test.ts` **Interfaces:** - Produces: a guest row (`id='__guest__'`, `role='guest'`) inserted idempotently during `createDatabase`. - Consumes: `GUEST_USER_ID`, `GUEST_USERNAME` from `./users.js`. - [ ] **Step 1: Write the failing test** — append to `src/data/database.test.ts`: ```ts import { GUEST_USER_ID } from "./users.js"; describe("guest principal migration", () => { it("creates exactly one reserved guest row, idempotently", () => { const dir = mkdtempSync(join(tmpdir(), "tsmb-db-")); const p = join(dir, "t.db"); const a = createDatabase(p); a.db.close(); const b = createDatabase(p); // run again — must not duplicate const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined; expect(row?.role).toBe("guest"); const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n; expect(n).toBe(1); b.db.close(); rmSync(dir, { recursive: true, force: true }); }); it("guest row does not break first-run detection (countUsers excludes it)", () => { const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-")); const p = join(dir, "t.db"); const d = createDatabase(p); const users = createUserStore(d.db); expect(users.countUsers()).toBe(0); // guest excluded → still needs setup d.db.close(); rmSync(dir, { recursive: true, force: true }); }); }); ``` > Match the existing `database.test.ts` imports (`mkdtempSync`, `tmpdir`, `join`, `rmSync`, `createDatabase`, `createUserStore`); add any that are missing. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/data/database.test.ts` Expected: FAIL (no guest row). - [ ] **Step 3: Implement** — in `src/data/database.ts`: Add to the imports at the top: ```ts import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js"; ``` Add a new function above `createDatabase`: ```ts /** * Ensure the reserved guest principal exists. Idempotent via the PK on * `users.id`. This row only backs login-less guest sessions; it is excluded * from countUsers()/listUsers() so it never interferes with first-run setup * or the user-management UI, and holds an unusable password hash. */ export function ensureGuestUser(db: Database.Database): void { const now = Date.now(); db.prepare( "INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')" ).run(GUEST_USER_ID, GUEST_USERNAME, now, now); } ``` Wire it into `createDatabase` after `backfillMemberPermissions(db);`: ```ts backfillMemberPermissions(db); ensureGuestUser(db); ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/data/database.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/data/database.ts src/data/database.test.ts git commit -m "feat(db): seed reserved guest principal idempotently" ``` --- ## Task 6: Middleware — `authorize` + `requireNotGuest` **Files:** - Create: `src/web/middleware/authorize.ts`, `src/web/middleware/requireNotGuest.ts` - Test: `src/web/middleware/authorize.test.ts`, `src/web/middleware/requireNotGuest.test.ts` **Interfaces:** - Produces: `authorize

({ capability?: string; guestFlag?: GuestFlag }): RequestHandler

`; `requireNotGuest: RequestHandler`. - Consumes: `req.user` shape `{ role, capabilities?, bots?, guest? }` (the `guest?` field is added to the augmentation in Task 7; for this task's tests, cast a fake `req`). - [ ] **Step 1: Write the failing tests** — `src/web/middleware/authorize.test.ts`: ```ts import { describe, it, expect, vi } from "vitest"; import { authorize } from "./authorize.js"; function run(user: any, opts: any) { const req: any = { user }; const res: any = { statusCode: 0, body: null, status(c: number) { this.statusCode = c; return this; }, json(b: any) { this.body = b; return this; } }; const next = vi.fn(); authorize(opts)(req, res, next); return { res, next }; } describe("authorize", () => { it("401 when unauthenticated", () => { const { res, next } = run(undefined, { capability: "player.queue" }); expect(res.statusCode).toBe(401); expect(next).not.toHaveBeenCalled(); }); it("admin always passes", () => { const { next } = run({ role: "admin" }, { capability: "bot.manage" }); expect(next).toHaveBeenCalled(); }); it("member passes only with the capability", () => { expect(run({ role: "member", capabilities: new Set(["player.queue"]) }, { capability: "player.queue" }).next).toHaveBeenCalled(); expect(run({ role: "member", capabilities: new Set() }, { capability: "player.queue" }).res.statusCode).toBe(403); }); it("guest passes only when its flag is enabled", () => { expect(run({ role: "guest", guest: { playNext: true } }, { capability: "player.control", guestFlag: "playNext" }).next).toHaveBeenCalled(); expect(run({ role: "guest", guest: { playNext: false } }, { capability: "player.control", guestFlag: "playNext" }).res.statusCode).toBe(403); }); it("guest is denied on routes with no guestFlag (e.g. play-song)", () => { expect(run({ role: "guest", guest: { addToQueue: true } }, { capability: "player.control" }).res.statusCode).toBe(403); }); }); ``` `src/web/middleware/requireNotGuest.test.ts`: ```ts import { describe, it, expect, vi } from "vitest"; import { requireNotGuest } from "./requireNotGuest.js"; function run(user: any) { const req: any = { user }; const res: any = { statusCode: 0, status(c: number) { this.statusCode = c; return this; }, json() { return this; } }; const next = vi.fn(); requireNotGuest(req, res, next); return { res, next }; } describe("requireNotGuest", () => { it("401 when no user", () => { expect(run(undefined).res.statusCode).toBe(401); }); it("403 for guests", () => { expect(run({ role: "guest" }).res.statusCode).toBe(403); }); it("passes admins and members", () => { expect(run({ role: "admin" }).next).toHaveBeenCalled(); expect(run({ role: "member" }).next).toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run src/web/middleware/authorize.test.ts src/web/middleware/requireNotGuest.test.ts` Expected: FAIL (modules not found). - [ ] **Step 3: Implement** — `src/web/middleware/authorize.ts`: ```ts import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { GuestFlag } from "../../data/permissions.js"; /** * Unified authorization gate. * - admin → always allowed (unchanged from requirePermission) * - member → allowed iff it holds `capability` (unchanged from requirePermission) * - guest → allowed iff `guestFlag` is set AND that flag is enabled in the * guest's resolved permissions; a route with no `guestFlag` is * denied to guests by default. * Generic over the route-param shape `P` for the same reason requirePermission is. */ export function authorize

>(opts: { capability?: string; guestFlag?: GuestFlag; }): RequestHandler

{ return (req: Request

, res: Response, next: NextFunction) => { const user = req.user; if (!user) { res.status(401).json({ error: "unauthenticated" }); return; } if (user.role === "admin") { next(); return; } if (user.role === "guest") { if (opts.guestFlag && user.guest?.[opts.guestFlag]) { next(); return; } res.status(403).json({ error: "forbidden" }); return; } if (opts.capability && user.capabilities?.has(opts.capability)) { next(); return; } res.status(403).json({ error: "forbidden" }); }; } ``` `src/web/middleware/requireNotGuest.ts`: ```ts import type { Request, Response, NextFunction } from "express"; /** Allow admins and members; deny login-less guests (used for config reads * that must never leak to guests, e.g. GET /api/bot/settings, GET /api/music/quality). */ export function requireNotGuest(req: Request, res: Response, next: NextFunction): void { if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } if (req.user.role === "guest") { res.status(403).json({ error: "forbidden" }); return; } next(); } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run src/web/middleware/authorize.test.ts src/web/middleware/requireNotGuest.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/web/middleware/authorize.ts src/web/middleware/requireNotGuest.ts src/web/middleware/authorize.test.ts src/web/middleware/requireNotGuest.test.ts git commit -m "feat(mw): add unified authorize() gate and requireNotGuest" ``` --- ## Task 7: `requireAuth` — guest-aware `req.user`, disable→401, server wiring **Files:** - Modify: `src/web/middleware/requireAuth.ts`, `src/web/server.ts` - Test: `src/web/middleware/requireAuth.test.ts` **Interfaces:** - Produces: `req.user` augmentation widened to `role: "admin" | "member" | "guest"` + `guest?: GuestPermissions`; `createRequireAuth(sessions, permissions, getGuestConfig: () => GuestModeConfig)`. - Consumes: `resolvePermissionContext` (Task 2), `GuestModeConfig` (Task 1), `GuestPermissions` (Task 2). - [ ] **Step 1: Write the failing test** — add cases to `src/web/middleware/requireAuth.test.ts` (reuse its existing harness that builds a fake `sessions`/`permissions`; if it stubs `validateSessionFromHeaders` via a fake `sessions.validateAndTouch`, follow that): ```ts // A guest session is rejected (401) when guest mode is disabled. it("rejects a guest session when guest mode is disabled", () => { const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) }; const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] }; const getGuestConfig = () => ({ enabled: false, bots: "all", permissions: {} as any }); const mw = createRequireAuth(sessions, permissions, getGuestConfig); const req: any = { headers: { cookie: "tsmb_session=x" } }; const res: any = { statusCode: 0, cleared: false, clearCookie() { this.cleared = true; }, status(c: number) { this.statusCode = c; return this; }, json() { return this; }, cookie() {} }; const next = vi.fn(); mw(req, res, next); expect(res.statusCode).toBe(401); expect(next).not.toHaveBeenCalled(); }); it("attaches guest permissions when guest mode is enabled", () => { const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) }; const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] }; const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false }; const getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms }); const mw = createRequireAuth(sessions, permissions, getGuestConfig); const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false }; const res: any = { status() { return this; }, json() { return this; }, cookie() {}, clearCookie() {} }; const next = vi.fn(); mw(req, res, next); expect(next).toHaveBeenCalled(); expect(req.user.role).toBe("guest"); expect(req.user.guest.addToQueue).toBe(true); expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true); }); ``` > If `requireAuth.test.ts` currently constructs `createRequireAuth(sessions, permissions)` with two args, those existing calls must gain a third `getGuestConfig` arg — update them in this step. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/web/middleware/requireAuth.test.ts` Expected: FAIL (3rd arg / guest handling missing). - [ ] **Step 3: Implement** — replace `src/web/middleware/requireAuth.ts` with: ```ts import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { SessionStore } from "../../data/sessions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js"; import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js"; import type { GuestModeConfig } from "../../data/config.js"; import { validateSessionFromHeaders, extractSessionToken, SESSION_COOKIE_NAME, } from "../auth/validateSession.js"; declare module "express-serve-static-core" { interface Request { user?: { id: string; username: string; role: "admin" | "member" | "guest"; capabilities?: Set; bots?: "all" | Set; guest?: GuestPermissions; }; } } export function createRequireAuth( sessions: SessionStore, permissions: PermissionStore, getGuestConfig: () => GuestModeConfig ): RequestHandler { return function requireAuth(req: Request, res: Response, next: NextFunction) { const result = validateSessionFromHeaders(req.headers.cookie, sessions); if (!result) { res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); res.status(401).json({ error: "unauthenticated" }); return; } // A guest session is only valid while guest mode is enabled. Disabling it // immediately invalidates any in-flight guest sessions. const guestCfg = getGuestConfig(); if (result.role === "guest" && !guestCfg.enabled) { res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); res.status(401).json({ error: "unauthenticated" }); return; } const ctx = resolvePermissionContext( result.role, result.userId, permissions, result.role === "guest" ? { bots: guestCfg.bots, permissions: guestCfg.permissions } : undefined ); req.user = { id: result.userId, username: result.username, role: result.role, capabilities: ctx.capabilities, bots: ctx.bots, guest: ctx.guest, }; const token = extractSessionToken(req.headers.cookie); if (token) { res.cookie(SESSION_COOKIE_NAME, token, { httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS, }); } next(); }; } ``` In `src/web/server.ts`, update the `createRequireAuth` call: ```ts const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode); ``` - [ ] **Step 4: Run test + type-check** Run: `npx vitest run src/web/middleware/requireAuth.test.ts && npx tsc --noEmit` Expected: PASS, no type errors. (If `tsc` flags the `createSessionRouter` call in server.ts, that's fixed in Task 8 — you may temporarily expect that one error until Task 8; prefer doing Task 8 immediately after.) - [ ] **Step 5: Commit** ```bash git add src/web/middleware/requireAuth.ts src/web/middleware/requireAuth.test.ts src/web/server.ts git commit -m "feat(auth): guest-aware requireAuth + disable invalidates guest sessions" ``` --- ## Task 8: Session router — guest endpoint, `guestAllowed`, guest `/me` **Files:** - Modify: `src/web/api/session.ts`, `src/web/server.ts` - Test: `src/web/api/session.test.ts` **Interfaces:** - Produces: `createSessionRouter(users, sessions, audit, logger, permissions, getGuestConfig)`; `POST /api/session/guest`; `GET /api/session/needs-setup` now returns `{ needsSetup, guestAllowed }`; `GET /api/session/me` returns `{ ..., role, capabilities, bots, guest }`. - Consumes: `GUEST_USER_ID`, `GUEST_USERNAME` (Task 3), `GUEST_SESSION_TTL_MS` (Task 4), `GuestModeConfig` (Task 1). - [ ] **Step 1: Write the failing test** — add to `src/web/api/session.test.ts` (this file already uses supertest with a mounted session router; mirror its setup, passing the new `getGuestConfig` arg): ```ts // helper in this file builds: app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, getGuestConfig)) 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); }); ``` > Implement `makeApp({...})` in the test using the existing helpers: a real DB via `createDatabase`, the stores, and a `getGuestConfig` returning `{ enabled, bots, permissions }` from the options. The guest row exists because `createDatabase` calls `ensureGuestUser`. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/web/api/session.test.ts` Expected: FAIL. - [ ] **Step 3: Implement** — in `src/web/api/session.ts`: Update imports: ```ts 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"; ``` Add the `getGuestConfig` parameter to the factory: ```ts export function createSessionRouter( users: UserStore, sessions: SessionStore, audit: AuditStore, logger: Logger, permissions: PermissionStore, getGuestConfig: () => GuestModeConfig ): Router { ``` Update `/needs-setup`: ```ts router.get("/needs-setup", (_req, res) => { res.json({ needsSetup: users.countUsers() === 0, guestAllowed: getGuestConfig().enabled }); }); ``` Add the guest login route (place it next to `/login`, in the public part of the router — the whole session router is mounted before `requireAuth`/`csrf`): ```ts 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" }); }); ``` Update `/me` to resolve guest permissions and include them: ```ts router.get("/me", requireAuthInline, (req, res) => { const user = req.user!; 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, }); }); ``` In `src/web/server.ts`, pass the getter to the session router: ```ts app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode)); ``` - [ ] **Step 4: Run test + type-check** Run: `npx vitest run src/web/api/session.test.ts && npx tsc --noEmit` Expected: PASS, no type errors. - [ ] **Step 5: Commit** ```bash git add src/web/api/session.ts src/web/server.ts src/web/api/session.test.ts git commit -m "feat(session): guest login endpoint, guestAllowed, guest /me payload" ``` --- ## Task 9: Player routes — unified gate + non-destructive guest play-now **Files:** - Modify: `src/web/api/player.ts` - Test: `src/web/api/permissions-enforcement.test.ts` (add a guest describe block) **Interfaces:** - Produces: guest-reachable player routes gated by `authorize`; new `POST /:botId/play-now-song` (guestFlag `playNow`, non-destructive). - Consumes: `authorize` (Task 6). - [ ] **Step 1: Write the failing test** — append a guest block to `src/web/api/permissions-enforcement.test.ts` (it already mounts the real player router with an injected `req.user`; add a helper to inject a guest user and assert per-flag allow/deny). Example shape: ```ts describe("guest enforcement on player routes", () => { // mountPlayer(injectUser) builds an express app: app.use((req,_res,n)=>{req.user=injectUser();n();}); app.use("/api/player", createPlayerRouter(...mockBotManager...)) const guest = (perms: Partial>) => () => ({ id: "__guest__", role: "guest", bots: "all", guest: { addToQueue: false, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, ...perms } }); it("addToQueue flag gates POST /add, /add-song, /add-by-id", async () => { const allow = mountPlayer(guest({ addToQueue: true })); const deny = mountPlayer(guest({ addToQueue: false })); expect((await request(allow).post("/api/player/bot1/add-song").send({ song: SONG })).status).not.toBe(403); expect((await request(deny).post("/api/player/bot1/add-song").send({ song: SONG })).status).toBe(403); }); it("playNext flag gates /play-next-song; playNow gates /play-now-song; skip gates /next", async () => { expect((await request(mountPlayer(guest({ playNext: true }))).post("/api/player/bot1/play-next-song").send({ song: SONG })).status).not.toBe(403); expect((await request(mountPlayer(guest({}))).post("/api/player/bot1/play-next-song").send({ song: SONG })).status).toBe(403); expect((await request(mountPlayer(guest({ playNow: true }))).post("/api/player/bot1/play-now-song").send({ song: SONG })).status).not.toBe(403); expect((await request(mountPlayer(guest({ skip: true }))).post("/api/player/bot1/next")).status).not.toBe(403); }); it("guests are always denied /play-song and /play-at regardless of flags", async () => { const all = mountPlayer(guest({ addToQueue: true, playNext: true, playNow: true, skip: true, transport: true, removeClear: true, playMode: true })); expect((await request(all).post("/api/player/bot1/play-song").send({ song: SONG })).status).toBe(403); expect((await request(all).post("/api/player/bot1/play-at").send({ index: 0 })).status).toBe(403); }); it("members are unaffected (player.queue still gates /add-song)", async () => { const m = mountPlayer(() => ({ id: "u1", role: "member", capabilities: new Set(["player.queue"]), bots: "all" })); expect((await request(m).post("/api/player/bot1/add-song").send({ song: SONG })).status).not.toBe(403); }); }); ``` > Use the file's existing bot-manager mock so handlers resolve a fake bot/queue. `SONG = { id: "1", platform: "netease", name: "x", artist: "y" }`. Assert on **403 vs not-403** (a 200/500 from the mock both prove the gate passed). - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/web/api/permissions-enforcement.test.ts` Expected: FAIL. - [ ] **Step 3: Implement** — in `src/web/api/player.ts`: Update the import (replace the `requirePermission` import line — keep `requireBotAccess`): ```ts import { requireBotAccess } from "../middleware/requirePermission.js"; import { authorize } from "../middleware/authorize.js"; ``` Replace the gate on each guest-reachable route (leave the handler bodies untouched). Map: ```ts // /add, /add-song, /add-by-id → addToQueue router.post("/:botId/add", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), /* ...existing handler... */); router.post("/:botId/add-song", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), /* ... */); router.post("/:botId/add-by-id", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), /* ... */); // transport (pause/resume/seek/volume) → transport ; skip (next) → skip ; clear → removeClear router.post("/:botId/pause", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!pause")); router.post("/:botId/resume", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!resume")); router.post("/:botId/next", authorize({ capability: "player.control", guestFlag: "skip" }), simpleCommand("!next")); router.post("/:botId/prev", authorize({ capability: "player.control" }), simpleCommand("!prev")); // no guest prev router.post("/:botId/stop", authorize({ capability: "player.control" }), simpleCommand("!stop")); // no guest stop router.post("/:botId/clear", authorize({ capability: "player.queue", guestFlag: "removeClear" }), simpleCommand("!clear")); // fm/mode → playMode ; volume/seek → transport router.post("/:botId/fm", authorize({ capability: "player.control", guestFlag: "playMode" }), /* ...existing fm handler... */); router.post("/:botId/volume", authorize({ capability: "player.control", guestFlag: "transport" }), /* ...existing volume handler... */); router.post("/:botId/mode", authorize({ capability: "player.control", guestFlag: "playMode" }), /* ...existing mode handler... */); router.post("/:botId/seek", authorize({ capability: "player.control", guestFlag: "transport" }), /* ...existing seek handler... */); // remove a queue item → removeClear router.delete("/:botId/queue/:index", authorize({ capability: "player.queue", guestFlag: "removeClear" }), /* ...existing handler... */); // play-next-song → playNext router.post("/:botId/play-next-song", authorize({ capability: "player.control", guestFlag: "playNext" }), /* ...existing handler... */); // play-at and play-song keep NO guest flag (guests denied) router.post("/:botId/play-at", authorize({ capability: "player.control" }), /* ...existing handler... */); router.post("/:botId/play-song", authorize({ capability: "player.control" }), /* ...existing handler... */); ``` > Do these as careful in-place edits: change ONLY the middleware argument (`requirePermission("X")` → `authorize({ capability: "X"[, guestFlag: "..."] })`). Leave every handler body exactly as-is. There may be additional `requirePermission(...)` routes in this file not listed here (e.g. play-playlist/play-album) — convert each to `authorize({ capability: "" })` with **no** guestFlag so members/admins are unchanged and guests stay denied. Add the new non-destructive guest play-now route immediately after the `play-next-song` route: ```ts // Play a song "now" without clearing the queue: insert after current, then // promote to current and start it. Non-destructive (unlike /play-song which // clears the whole queue) — this is the guest-safe "play now". router.post("/:botId/play-now-song", authorize({ capability: "player.control", guestFlag: "playNow" }), async (req, res) => { try { const bot = (req as any).bot; const { song } = req.body; if (!song || !song.id || !song.platform) { res.status(400).json({ error: "song object with id and platform is required" }); return; } const queue = bot.getQueueManager(); const insertedAt = queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; queue.addNext(song); queue.playAt(insertedAt); bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); return; } res.json({ ok: true, message: `正在播放:${song.name || "Unknown"} - ${song.artist || "Unknown"}` }); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); ``` - [ ] **Step 4: Run test + type-check** Run: `npx vitest run src/web/api/permissions-enforcement.test.ts && npx tsc --noEmit` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/web/api/player.ts src/web/api/permissions-enforcement.test.ts git commit -m "feat(player): unified authorize() gating + non-destructive guest play-now" ``` --- ## Task 10: Bot settings — lock reads from guests + persist `guestMode` **Files:** - Modify: `src/web/api/bot.ts` - Test: `src/web/api/bot.test.ts` **Interfaces:** - Produces: `GET /api/bot/settings` gated by `requireNotGuest` and now returns `guestMode`; `POST /api/bot/settings` (admin via `bot.manage`) accepts and validates a `guestMode` block. - Consumes: `requireNotGuest` (Task 6), `GUEST_PERMISSION_FLAGS` (Task 2). - [ ] **Step 1: Write the failing test** — add to `src/web/api/bot.test.ts` (mirror its existing settings tests; inject `req.user` as guest/admin): ```ts it("GET /settings is 403 for guests and includes guestMode for admins", async () => { const guestApp = mountBot(() => ({ role: "guest", guest: {} })); expect((await request(guestApp).get("/api/bot/settings")).status).toBe(403); const adminApp = mountBot(() => ({ role: "admin" })); const res = await request(adminApp).get("/api/bot/settings"); expect(res.status).toBe(200); expect(res.body.guestMode).toBeDefined(); expect(res.body.guestMode.enabled).toBe(false); }); it("POST /settings persists a guestMode block", async () => { const adminApp = mountBot(() => ({ role: "admin" })); const res = await request(adminApp).post("/api/bot/settings").send({ guestMode: { enabled: true, bots: ["bot1"], permissions: { playNext: true } }, }); expect(res.status).toBe(200); expect(res.body.guestMode.enabled).toBe(true); expect(res.body.guestMode.bots).toEqual(["bot1"]); expect(res.body.guestMode.permissions.playNext).toBe(true); expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default }); ``` > `mountBot(injectUser)` builds an app injecting `req.user`, with a `config` object from `getDefaultConfig()` and a temp `configPath`; it mounts `createBotRouter(...)`. Reuse the existing helper if present. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/web/api/bot.test.ts` Expected: FAIL. - [ ] **Step 3: Implement** — in `src/web/api/bot.ts`: Update imports: ```ts import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { GUEST_PERMISSION_FLAGS } from "../../data/permissions.js"; ``` Gate the GET and extend its response: ```ts router.get("/settings", requireNotGuest, (_req, res) => { res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, guestMode: config.guestMode, }); }); ``` Extend the POST handler to accept `guestMode` (keep the existing idle/autoPause logic; add the guestMode parsing before `saveConfig`): ```ts router.post("/settings", requirePermission("bot.manage"), (req, res) => { const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode } = req.body; const hasIdle = idleTimeoutMinutes !== undefined; if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) { res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" }); return; } const hasAutoPause = typeof autoPauseOnEmpty === "boolean"; if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes; if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; if (guestMode !== undefined && guestMode !== null && typeof guestMode === "object") { const gm = config.guestMode; if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled; if (guestMode.bots === "all") { gm.bots = "all"; } else if (Array.isArray(guestMode.bots)) { gm.bots = guestMode.bots.filter((id: unknown): id is string => typeof id === "string"); } if (guestMode.permissions && typeof guestMode.permissions === "object") { for (const f of GUEST_PERMISSION_FLAGS) { if (typeof guestMode.permissions[f] === "boolean") { gm.permissions[f] = guestMode.permissions[f]; } } } } saveConfig(configPath, config); for (const bot of botManager.getAllBots()) { if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes); if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty); } res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, autoPauseOnEmpty: config.autoPauseOnEmpty, guestMode: config.guestMode, }); }); ``` - [ ] **Step 4: Run test + type-check** Run: `npx vitest run src/web/api/bot.test.ts && npx tsc --noEmit` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/web/api/bot.ts src/web/api/bot.test.ts git commit -m "feat(bot): lock settings reads from guests + persist guestMode" ``` --- ## Task 11: Music quality — lock reads from guests **Files:** - Modify: `src/web/api/music.ts` - Test: `src/web/api/permissions-enforcement.test.ts` (or `music`-specific test if one exists) **Interfaces:** - Produces: `GET /api/music/quality` gated by `requireNotGuest`. - Consumes: `requireNotGuest` (Task 6). - [ ] **Step 1: Write the failing test** — add to the enforcement test (mount the music router with injected user): ```ts it("GET /api/music/quality is 403 for guests, allowed for members", async () => { const guestApp = mountMusic(() => ({ role: "guest", guest: {} })); expect((await request(guestApp).get("/api/music/quality")).status).toBe(403); const memberApp = mountMusic(() => ({ role: "member", capabilities: new Set() })); expect((await request(memberApp).get("/api/music/quality")).status).toBe(200); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/web/api/permissions-enforcement.test.ts` Expected: FAIL. - [ ] **Step 3: Implement** — in `src/web/api/music.ts`: Update import: ```ts import { requirePermission } from "../middleware/requirePermission.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js"; ``` Gate the quality read: ```ts router.get("/quality", requireNotGuest, (_req, res) => { res.json({ netease: neteaseProvider.getQuality(), qq: qqProvider.getQuality(), bilibili: bilibiliProvider.getQuality(), }); }); ``` > Search/browse GET routes in this file stay open (guests need them to find songs). - [ ] **Step 4: Run test + type-check** Run: `npx vitest run src/web/api/permissions-enforcement.test.ts && npx tsc --noEmit` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/web/api/music.ts src/web/api/permissions-enforcement.test.ts git commit -m "feat(music): lock quality read from guests" ``` --- ## Task 12: WebSocket — per-bot scope for guests **Files:** - Modify: `src/web/server.ts`, `src/web/websocket.ts` - Test: `src/web/websocket-auth.test.ts` **Interfaces:** - Produces: the upgrade handler stamps `(ws).isGuest` and `(ws).botScope` (`"all" | Set`); `setupWebSocket` filters `init` and per-bot broadcasts so guests only see in-scope bots. - Consumes: `options.config.guestMode` for the guest scope; member/admin behavior unchanged. - [ ] **Step 1: Write the failing test** — add to `src/web/websocket-auth.test.ts` a test that a guest WS connection's `init` only includes in-scope bots. If the existing harness only tests the upgrade accept/reject, add a focused `setupWebSocket` unit test in the same file: ```ts import { setupWebSocket } from "./websocket.js"; it("guest init is filtered to the guest bot scope", () => { const sent: any[] = []; const fakeWs: any = { readyState: 1, isGuest: true, botScope: new Set(["bot1"]), send: (m: string) => sent.push(JSON.parse(m)), on: () => {} }; const fakeWss: any = { on: (ev: string, cb: any) => { if (ev === "connection") fakeWss._conn = cb; } }; const botManager: any = { getAllBots: () => [{ id: "bot1", getStatus: () => ({ id: "bot1" }) }, { id: "bot2", getStatus: () => ({ id: "bot2" }) }], on: () => {}, off: () => {}, }; const cleanup = setupWebSocket(fakeWss, botManager, { debug() {}, error() {}, info() {}, warn() {} } as any); fakeWss._conn(fakeWs); const init = sent.find((m) => m.type === "init"); expect(init.bots.map((b: any) => b.id)).toEqual(["bot1"]); cleanup(); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/web/websocket-auth.test.ts` Expected: FAIL (no filtering). - [ ] **Step 3: Implement** In `src/web/server.ts`, inside the `server.on("upgrade", ...)` handler, after `const result = validateSessionFromHeaders(...)` and its null-check, and before `wss.handleUpgrade`, add a guest-disabled guard and compute the scope: ```ts // Guest sessions are only valid while guest mode is enabled. if (result.role === "guest" && !options.config.guestMode.enabled) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); socket.destroy(); return; } const guestBots = options.config.guestMode.bots; const botScope: "all" | Set = result.role === "guest" ? guestBots === "all" ? "all" : new Set(guestBots) : "all"; ``` Then stamp the ws in the `handleUpgrade` callback: ```ts wss.handleUpgrade(req, socket, head, (ws) => { const w = ws as unknown as { userId: string; isGuest: boolean; botScope: "all" | Set }; w.userId = result.userId; w.isGuest = result.role === "guest"; w.botScope = botScope; wss.emit("connection", ws, req); }); ``` In `src/web/websocket.ts`: Add a scope helper and use it in the connection handler's `init`: ```ts function visibleToClient(ws: WebSocket, botId: string): boolean { const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set }; if (!w.isGuest || w.botScope === "all" || !w.botScope) return true; return w.botScope.has(botId); } ``` Replace the `init` build in the connection handler: ```ts wss.on("connection", (ws) => { clients.add(ws); logger.debug("WebSocket client connected"); const bots = botManager .getAllBots() .filter((b) => visibleToClient(ws, b.id)) .map((b) => b.getStatus()); ws.send(JSON.stringify({ type: "init", bots })); // ... keep the existing close/error handlers ... }); ``` Change `broadcast` to take an optional `botId` and filter per client: ```ts const broadcast = (data: object, botId?: string) => { const message = JSON.stringify(data); for (const client of clients) { if (client.readyState !== WebSocket.OPEN) continue; if (botId !== undefined && !visibleToClient(client, botId)) continue; try { client.send(message); } catch { clients.delete(client); } } }; ``` Pass the botId at each call site: ```ts // onStateChange: broadcast({ type: "stateChange", botId: bot.id, status: bot.getStatus(), queue: bot.getQueue() }, bot.id); // onConnected: broadcast({ type: "botConnected", botId: bot.id, status: bot.getStatus() }, bot.id); // onDisconnected: broadcast({ type: "botDisconnected", botId: bot.id, status: bot.getStatus() }, bot.id); // onBotInstanceRemoved: broadcast({ type: "botRemoved", botId: id }, id); ``` - [ ] **Step 4: Run test + type-check** Run: `npx vitest run src/web/websocket-auth.test.ts && npx tsc --noEmit` Expected: PASS. Member/admin clients (`isGuest=false`) receive everything as before. - [ ] **Step 5: Commit** ```bash git add src/web/server.ts src/web/websocket.ts src/web/websocket-auth.test.ts git commit -m "feat(ws): scope guest WebSocket feed to allowed bots" ``` --- ## Task 13: Frontend session composable — guest surface **Files:** - Modify: `web/src/composables/useSession.ts` **Interfaces:** - Produces: `User.role: 'admin'|'member'|'guest'`; `User.guest?: Record | null`; `guestAllowed` ref; `isGuest` computed; `guestCan(flag)`; `continueAsGuest()`. - Consumes: `/api/session/needs-setup` (`guestAllowed`), `/api/session/guest`, `/api/session/me` (`guest`). - [ ] **Step 1: Implement** (frontend has no unit harness for this composable; verify by type-check/build). Edit `web/src/composables/useSession.ts`: Widen `User`: ```ts interface User { id: string; username: string; role: 'admin' | 'member' | 'guest'; capabilities?: string[]; bots?: "all" | string[]; guest?: Record | null; } ``` Add a `guestAllowed` ref near `needsSetup`: ```ts const guestAllowed = ref(false); ``` In `refreshNeedsSetup`, also capture `guestAllowed`: ```ts async function refreshNeedsSetup(): Promise { const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" }); if (res.ok) { const body = await res.json(); needsSetup.value = Boolean(body.needsSetup); guestAllowed.value = Boolean(body.guestAllowed); } } ``` Add a `continueAsGuest` action (after `login`): ```ts async function continueAsGuest(): Promise { const res = await fetch("/api/session/guest", { method: "POST", credentials: "same-origin" }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `guest entry failed (${res.status})`); } currentUser.value = (await res.json()) as User; await refreshMe(); // authoritative role + guest flags + bots } ``` Add helpers near `can`: ```ts function guestCan(flag: string): boolean { const u = currentUser.value; return !!u && u.role === "guest" && !!u.guest && u.guest[flag] === true; } ``` Export the new surface from `useSession()`: ```ts return { currentUser: readonly(currentUser), needsSetup: readonly(needsSetup), guestAllowed: readonly(guestAllowed), isAuthenticated: computed(() => currentUser.value !== null), isAdmin: computed(() => currentUser.value?.role === 'admin'), isGuest: computed(() => currentUser.value?.role === 'guest'), ready: readonly(ready), refresh, login, logout, setup, continueAsGuest, can, guestCan, canControlBot, }; ``` - [ ] **Step 2: Type-check** Run: `cd web && npx vue-tsc --noEmit` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add web/src/composables/useSession.ts git commit -m "feat(web/session): expose isGuest, guestCan, continueAsGuest, guestAllowed" ``` --- ## Task 14: Router — block guests from settings/setup **Files:** - Modify: `web/src/router/index.ts` **Interfaces:** - Consumes: `session.isGuest`. - [ ] **Step 1: Implement** — in `web/src/router/index.ts`, inside `beforeEach`, after the `!session.isAuthenticated.value` redirect block, add: ```ts // Guests may never reach settings/setup, even by typing the URL. const GUEST_BLOCKED = new Set(['settings', 'setup']); if (session.isGuest.value && GUEST_BLOCKED.has(to.name as string)) { return { name: 'home' }; } ``` - [ ] **Step 2: Type-check** Run: `cd web && npx vue-tsc --noEmit` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add web/src/router/index.ts git commit -m "feat(web/router): block guests from settings and setup routes" ``` --- ## Task 15: Login — "Continue as guest" button **Files:** - Modify: `web/src/views/Login.vue` **Interfaces:** - Consumes: `session.guestAllowed`, `session.continueAsGuest`. - [ ] **Step 1: Implement** — in `web/src/views/Login.vue`, add the button after the `` close (still inside `.auth-page`), and a handler. Template (insert after the `

` element, before ``): ```vue ``` Script (add the handler next to `submit`): ```ts async function enterAsGuest() { error.value = ''; loading.value = true; try { await session.continueAsGuest(); const rawNext = typeof route.query.next === 'string' ? route.query.next : '/'; const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/'; router.replace(next); } catch (e) { error.value = (e as Error).message; } finally { loading.value = false; } } ``` Style (append inside the `