From 694ff77712acfbf04cdd2a95a5f1fdfe68ace15b Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 24 Jun 2026 23:20:49 +0800 Subject: [PATCH 01/34] docs: guest-mode (login-less WebUI access) design spec (#83) Brainstorm-approved design for an optional, default-off guest mode: - guest = anonymous, config-driven principal (no account) - per-ability admin toggles (add-to-end default on; play-next/play-now/ skip/transport/remove-clear/play-mode opt-in) + per-bot guest scope - unified authorize() gate; settings always locked for guests; non-destructive guest "play now" Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-24-guest-mode-design.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-guest-mode-design.md diff --git a/docs/superpowers/specs/2026-06-24-guest-mode-design.md b/docs/superpowers/specs/2026-06-24-guest-mode-design.md new file mode 100644 index 0000000..b0a3931 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-guest-mode-design.md @@ -0,0 +1,317 @@ +# Guest mode (login-less WebUI access) — design + +**Issue:** [#83](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/83) — "请求增加 WebUI 鉴权 guest 登录功能" +**Date:** 2026-06-24 +**Status:** Approved (brainstorm), pending implementation plan + +## Scope + +Add an optional, **default-OFF** guest mode. When an admin enables it, anyone who can +reach the WebUI can enter **without logging in** ("以游客身份进入 / Continue as guest") +and use a restricted subset of playback/queue features. The admin chooses, per +deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may +control. Guests can **never** view or change settings, manage bots, set platform +credentials, change audio quality, or see the user/audit admin panels. + +This builds directly on the existing `admin | member` role + capability system +(`src/data/permissions.ts`, `requirePermission`, `useSession().can()`) and the existing +append-vs-play-next queue split (`PlayQueue.add` vs `addNext`). It does **not** rebuild +auth. + +The original issue asked specifically that guest song requests go to "下一首" only. +That exact behavior is reproducible in this design by the admin turning the +"add to end" toggle **off** and the "play next" toggle **on** — it is one configuration +of a more general per-ability toggle model (chosen in brainstorm). + +## Problem + +Today every `/api/*` route past the session router requires a real account +(`requireAuth`). There is no anonymous/guest path: to let a friend queue a song, an +admin must create them a `member` account. The maintainer wants a low-friction, +admin-gated way to let untrusted visitors request music without an account, while +keeping all administration locked down. + +## Decisions (from brainstorm) + +1. **Guest = no-login.** A guest is an **anonymous, config-driven synthetic principal** + (`role: "guest"`), not a database user with a password. No favorites, no + change-password, short-lived session. +2. **Default OFF**, enforced **server-side** (the guest-session endpoint rejects when + the flag is off — never rely on hiding the button). +3. **Per-ability toggles**, not a single "mode". Every song action and control action is + its own admin switch. Default state when guest mode is first enabled: only + "add to end of queue" is ON; everything else OFF. +4. **Per-bot guest scope** (`"all"` or an explicit bot list), mirroring the existing + member bot allow-list. Guests cannot see or control out-of-scope bots — including + over WebSocket. +5. **Settings are always hidden AND server-blocked for guests** (view + change), closing + the two currently-ungated reads (`GET /api/bot/settings`, `GET /api/music/quality`). +6. **"Play now" for guests is non-destructive**: insert-next + skip to it, **never** the + existing clear-the-whole-queue `/play-song` behavior. +7. **One unified authorization gate** encapsulates admin/member/guest logic so the + existing member/admin capability system is left behavior-unchanged. + +## Guest ability model + +### Always allowed (baseline read-only — the point of the feature) +- Browse/search library, playlists, history, song detail, lyrics, cover art. +- See now-playing and the live queue (REST + WebSocket), **scoped to allowed bots**. + +### Always denied (hard locks — not toggles) +- View **or** change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.). +- Bot management (create/edit/delete/start/stop, bot config, avatar, profile). +- Music-platform login (`/api/auth/*`), audio quality (`/api/music/quality`). +- User management (`/api/users`), audit log (`/api/audit`), change-password. + +### Admin-configurable toggles (`guestMode.permissions.*`, all default `false` except `addToQueue`) + +| Flag | 中文 | Default | Backend route(s) gated | +|---|---|---|---| +| `addToQueue` | 添加到队列末尾 | **true** | `POST /:botId/add`, `/add-song`, `/add-by-id` | +| `playNext` | 添加到下一首 | false | `POST /:botId/play-next-song` | +| `playNow` | 立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) | +| `skip` | 跳过当前歌曲 | false | `POST /:botId/next` | +| `transport` | 暂停/继续/进度/音量 | false | `POST /:botId/pause`, `/resume`, `/seek`, `/volume` | +| `removeClear` | 移除/清空队列 | false | `DELETE /:botId/queue/:index`, `POST /:botId/clear` | +| `playMode` | 切换播放模式 / FM | false | `POST /:botId/mode`, `/fm` | + +Notes: +- Routes with **no** guest flag (e.g. `/prev`, `/stop`, `/play-song`, `/play-playlist`, + `/play-album`, `/play-at`, all of `/api/bot/*`, `/api/auth/*`, settings, users, audit) + are **never** reachable by guests — the gate denies any guest without an explicit flag. + This is the safe default: new routes are guest-denied unless deliberately opted in. +- `/play-song`, `/play-playlist`, `/play-album` call `queue.clear()` and must stay + guest-denied regardless of toggles (they would wipe everyone's queue). + +## Config schema (`src/data/config.ts`) + +```ts +export interface GuestPermissions { + addToQueue: boolean; // append to end + playNext: boolean; // 下一首 (insert after current) + playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive) + skip: boolean; // skip current track + transport: boolean; // pause/resume/seek/volume + removeClear: boolean; // remove a queue item / clear the queue + playMode: boolean; // play mode (shuffle/repeat) + FM +} + +export interface GuestModeConfig { + enabled: boolean; // master switch, default false + bots: "all" | string[]; // per-bot scope (botIds); default "all" + permissions: GuestPermissions; +} + +// added to BotConfig: +guestMode: GuestModeConfig; +``` + +`getDefaultConfig()` returns: +```ts +guestMode: { + enabled: false, + bots: "all", + permissions: { + addToQueue: true, playNext: false, playNow: false, + skip: false, transport: false, removeClear: false, playMode: false, + }, +} +``` + +**Merge hardening:** `loadConfig` currently does a shallow `{...defaults, ...partial}`, +which would drop `guestMode` sub-keys if a saved config only contains a partial +`guestMode`. `loadConfig` must **deep-merge `guestMode`** (and its `permissions`) over +the defaults so missing sub-keys are back-filled. Covered by a `config.test.ts` case. + +**Bot deletion:** when a bot is removed, prune its id from `guestMode.bots` (if it's an +array) and persist — mirrors `PermissionStore.pruneBot(botId)` for members. Done in the +same `BotManager.removeBot` path that already prunes member access. + +## Backend design + +### Synthetic guest principal & session entry +- **Role union widened** to `"admin" | "member" | "guest"` (`UserRole` in + `src/data/users.ts`, the `req.user` augmentation in `requireAuth.ts`, the frontend + `User` type, and the role badge in Navbar). +- **Reserved guest user row.** A single fixed row (e.g. id `"__guest__"`, role `"guest"`, + an unusable password hash, username e.g. `"guest"`) is created idempotently by + migration. It exists only to satisfy the `sessions.userId` FK and the + `validateAndTouch` JOIN; it is excluded from user-management listings and the + last-admin guards (those count `role = 'admin'` only, so guests don't interfere). +- **Guest login endpoint:** `POST /api/session/guest`, mounted in the **public** block + (before `csrfOriginCheck`/`requireAuth`, like `/login` and `/setup`), rate-limited. + - If `config.guestMode.enabled` is false → `403 guest mode disabled`. + - Else `sessions.createSession("__guest__")` and set the same `tsmb_session` httpOnly + cookie. **Guest sessions use a short TTL** (e.g. `GUEST_SESSION_TTL_MS`, ~24h) and + **bypass `MAX_SESSIONS_PER_USER`** for the guest principal (otherwise guest #11 + would evict guest #1). Expired guest sessions are already deleted on validation; an + optional periodic sweep can prune stale ones. +- **Disable = logout.** In `createRequireAuth`/`validateSession`, if a validated session + has `role === "guest"` but `config.guestMode.enabled` is now false, treat it as + unauthenticated (401). So flipping guest mode off immediately ends guest access. +- **Expose availability:** extend `GET /api/session/needs-setup` (or add a sibling + `GET /api/session/guest-config`) to return `guestAllowed: boolean` so the **public** + Login page can decide whether to show the guest button. This must not leak any other + config. + +### Permission resolution +`resolvePermissionContext` gains a `guest` branch. Signature extended to receive the +live guest config: +```ts +resolvePermissionContext(role, userId, store, guestConfig?) => { + admin → { capabilities: all CAPABILITIES, bots: "all" } + member → stored caps + stored bots // unchanged + guest → { + capabilities: new Set(), // holds NO member capabilities + bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots), + guest: guestConfig.permissions, // resolved per-request from live config + } +} +``` +`PermissionContext` and `req.user` gain an optional `guest?: GuestPermissions`. Because +`req.user` is rebuilt per request, toggling a permission or the bot scope takes effect on +the guest's next request (no re-login). + +### Unified authorization gate (`src/web/middleware/authorize.ts`, new) +Replaces `requirePermission('x')` on **guest-reachable** routes: +```ts +authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions }) + // 401 if no req.user + // admin → next() + // guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag + // member → (capability && req.user.capabilities.has(capability)) ? next() : 403 +``` +- Member/admin semantics are **identical** to today's `requirePermission`. +- A route with no `guestFlag` is automatically guest-denied (safe default). +- `requireBotAccess` is unchanged and already enforces the guest `bots` scope (guests + flow through `req.user.bots`). +- `requireAdmin` is unchanged (guests are non-admin → 403), so `/api/users` and + `/api/audit` stay locked. + +### Route changes (`src/web/api/player.ts`, `bot.ts`, `music.ts`) +- Re-express guest-reachable player routes via `authorize({ capability, guestFlag })`: + - `/add`, `/add-song`, `/add-by-id` → `{ capability: "player.queue", guestFlag: "addToQueue" }` + - `/play-next-song` → `{ capability: "player.control", guestFlag: "playNext" }` + (members keep `player.control`; guests pass only via `playNext`) + - new guest-safe **play-now** → `{ capability: "player.control", guestFlag: "playNow" }` + - `/next` → `{ capability: "player.control", guestFlag: "skip" }` + - `/pause`,`/resume`,`/seek`,`/volume` → `{ capability: "player.control", guestFlag: "transport" }` + - `DELETE /queue/:index`, `/clear` → `{ capability: "player.queue", guestFlag: "removeClear" }` + - `/mode`, `/fm` → `{ capability: "player.control", guestFlag: "playMode" }` + - everything else stays `authorize({ capability })` (no guest flag) → guest-denied. +- **Guest-safe play-now**: a new behavior (own route, e.g. `POST /:botId/play-now`, or a + `mode:"now"` branch) that does `queue.addNext(song)` then advances to it (skip into the + inserted track) — **no `queue.clear()`**. Members/admins may also use it; the existing + destructive `/play-song` stays for the normal ▶ in non-guest UI. Exact wiring decided + in the plan. +- **Close ungated reads against guests:** `GET /api/bot/settings` and + `GET /api/music/quality` currently have no guard, so a guest could read config. Add a + small `requireNotGuest` guard (allow `admin` + `member`, deny `guest` → 403). This + **does not change member/admin behavior** — members keep their current read access; only + guests are newly denied. (Deliberately not a new member capability, to avoid touching + member semantics.) + +### WebSocket (`src/web/websocket.ts`, `src/web/server.ts`) +- Guests authenticate over the WS upgrade unchanged (session cookie). +- **Add per-client bot-scope filtering** for guests: the upgrade handler already stamps + `ws.userId`; also resolve and stamp the client's bot scope (`"all"` or a Set). In + `setupWebSocket`, when sending `init` and broadcasting `stateChange` / + `botConnected/Disconnected/Removed`, **filter to bots the client may see**. For guests + with a scoped `bots` list, out-of-scope bots are omitted. Admin/member payloads are + unchanged (they resolve to `"all"` or their existing member scope — to avoid changing + member behavior, filtering may be applied **only when the client is a guest**; decided + in the plan). + +### Settings write (`POST /api/bot/settings`) +Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept +and persist the `guestMode` block (admin-only via `bot.manage`/`requireAdmin`), calling +`saveConfig`. Live effect: subsequent guest requests read the updated in-memory config. + +## Frontend design + +- **`useSession.ts`**: extend `User` with `role:'guest'` and a `guest?: GuestPermissions` + field (from `/api/session/me`). Add `isGuest` computed and `guestCan(flag)`; make `can` + guest-aware where it maps cleanly, but UI gating for guest-specific actions uses + `guestCan('addToQueue' | 'playNext' | ...)`. `canControlBot` already enforces the bot + scope and works for guests via the `bots` field. +- **Login page (`Login.vue`)**: when `guestAllowed`, show a prominent + "以游客身份进入 / Continue as guest" button calling a new `session.continueAsGuest()` + → `POST /api/session/guest` → refresh → redirect to `?next` or home. +- **Router (`web/src/router/index.ts`)**: in the global `beforeEach`, block guests from + `/settings` and `/setup` (redirect to home). Default-off ⇒ when not a guest, behavior + is unchanged. +- **Navbar (`Navbar.vue`)**: hide the settings cog for guests; add a `游客` role badge + branch; the bot selector already filters via `canControlBot`, so scoped guests only see + allowed bots. +- **App shell (`App.vue`)**: hide the mobile `/settings` tab for guests; the mini-player + transport reduces to the guest's allowed actions. +- **SongCard / Queue / Player**: gate each action button by the matching `guestCan(flag)` + (e.g. show ▶/下一首/添加 per `playNow`/`playNext`/`addToQueue`; show skip/transport/ + remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring how + `Queue.vue` already gates on `can('player.queue')` / `can('player.control')`. +- **Settings → Guest mode admin section (`Settings.vue`)**: new admin-only panel: a + master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope + control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing + member permission-editor bot allow-list UI. Saving calls `POST /api/bot/settings` with + the `guestMode` block. + +## Defaults, migration & backward-compat + +- `getDefaultConfig().guestMode.enabled = false` ⇒ **no behavior change** on upgrade; + existing installs see nothing until an admin opts in. +- Migration adds the reserved `__guest__` user row idempotently (guarded like the + existing `backfillMemberPermissions` `schema_meta` marker) and does **not** grant it + any `user_permissions` (guest authorization is config-driven, not row-driven). +- `loadConfig` deep-merges `guestMode` so older config files gain the new block with + defaults. +- Member/admin flows, capabilities, and the backfill are untouched. + +## Testing (TDD) + +- **Config**: `getDefaultConfig` includes `guestMode` default-off; `loadConfig` + deep-merges a partial `guestMode` (missing sub-keys back-filled); round-trips through + `saveConfig`. +- **`resolvePermissionContext` guest branch**: empty member capabilities; `bots` `"all"` + vs scoped Set; `guest` permissions object passthrough. +- **`authorize` gate**: admin bypass; member has/lacks capability → 200/403 (regression + parity with `requirePermission`); guest allowed only when the specific flag is true; + guest with no flag on a route → 403; guest on settings reads → 403. +- **Enforcement (mirror `permissions-enforcement.test.ts`)**: each toggle independently + opens exactly its route(s) for a guest and nothing else; `/play-song`/`/play-playlist`/ + `/play-album` always 403 for guests; per-bot scope: guest 403 on out-of-scope `:botId`. +- **Session entry**: `POST /api/session/guest` → 403 when disabled, mints guest session + when enabled; guest session bypasses `MAX_SESSIONS_PER_USER`; disabling guest mode + invalidates existing guest sessions (401); guest TTL shorter than member TTL. +- **WS scope**: guest receives only in-scope bots' `init`/`stateChange`; reject upgrade + unchanged for no cookie. +- **Frontend** (where covered): `guestCan` gating; router blocks `/settings` for guests. + +## Non-goals (YAGNI) + +- No guest accounts/usernames, passwords, favorites, or persistence per guest. +- No per-guest individual identity or rate-limiting beyond the existing IP rate limits + (a basic abuse guard on `/api/session/guest` is in; richer abuse controls are future). +- No change to the `admin | member` capability semantics; guest is an additive, + config-driven third principal. +- No chat-command (TeamSpeak `!add`/`!playnext`) changes — guest mode is **WebUI-only** + (the issue is explicitly about WebUI 鉴权). +- Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest + scope applies to all guests). + +## Key files touched + +Backend: `src/data/config.ts` (+test), `src/data/permissions.ts` (+test), +`src/data/users.ts` (role union, reserved guest row), `src/data/database.ts` (migration), +`src/data/sessions.ts` (guest TTL + cap bypass), `src/web/middleware/authorize.ts` (new, ++test), `src/web/middleware/requireNotGuest.ts` (new, small — for the config reads), +`src/web/api/session.ts` (guest endpoint, `/me`, `needs-setup`), +`src/web/api/player.ts` (re-gate + guest play-now), `src/web/api/bot.ts` (settings +read-lock + guestMode write), `src/web/api/music.ts` (quality read-lock), +`src/web/server.ts` + `src/web/websocket.ts` (WS scope), `src/web/auth/validateSession.ts` +(guest disable→401), enforcement tests. + +Frontend: `web/src/composables/useSession.ts`, `web/src/views/Login.vue`, +`web/src/router/index.ts`, `web/src/components/Navbar.vue`, `web/src/App.vue`, +`web/src/components/SongCard.vue`, `web/src/components/Queue.vue`, +`web/src/components/Player.vue`, `web/src/views/Settings.vue`, +`web/src/stores/player.ts`. From 3433ccb6619faa34f901c36fd27a685c0e440f13 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 24 Jun 2026 23:37:08 +0800 Subject: [PATCH 02/34] docs: guest-mode implementation plan (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 23 bite-sized TDD tasks with exact code: config + guest principal, unified authorize() gate, route re-gating + non-destructive play-now, settings/quality read-locks, WebSocket per-bot guest scoping, and the full frontend (entry, gating, admin 游客模式 section). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-24-guest-mode.md | 2199 +++++++++++++++++ 1 file changed, 2199 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-guest-mode.md 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 ` From 3042f871994015138af5427e7e7cf90f625bb0a9 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 25 Jun 2026 12:06:26 +0800 Subject: [PATCH 18/34] =?UTF-8?q?feat(web/navbar):=20hide=20settings=20cog?= =?UTF-8?q?=20for=20guests=20+=20=E6=B8=B8=E5=AE=A2=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/Navbar.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 502c3cf..67bbc85 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -98,14 +98,14 @@ - +