diff --git a/docs/superpowers/plans/2026-06-24-guest-mode.md b/docs/superpowers/plans/2026-06-24-guest-mode.md new file mode 100644 index 0000000..6dff4bb --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-guest-mode.md @@ -0,0 +1,2199 @@ +# 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 `