Files
teamspeak-music-bot/docs/superpowers/plans/2026-06-24-guest-mode.md
saopig1andClaude Opus 4.8 3433ccb661 docs: guest-mode implementation plan (#83)
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) <noreply@anthropic.com>
2026-06-24 23:37:08 +08:00

79 KiB
Raw Permalink Blame History

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 <file>; 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) <noreply@anthropic.com> (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:

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):

import type { BotAccess, GuestPermissions } from "./permissions.js";

Add interfaces above BotConfig:

export interface GuestModeConfig {
  enabled: boolean;
  bots: BotAccess; // "all" | string[]
  permissions: GuestPermissions;
}

Add the field to BotConfig (after trustProxy: boolean;):

  guestMode: GuestModeConfig;

Add to the object returned by getDefaultConfig() (after trustProxy: false,):

    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:

export function loadConfig(path: string): BotConfig {
  const defaults = getDefaultConfig();
  try {
    const raw = readFileSync(path, "utf-8");
    const partial = JSON.parse(raw) as Partial<BotConfig>;
    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
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:
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<string>).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:

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:

export interface PermissionContext {
  capabilities: Set<string>;
  bots: "all" | Set<string>;
  guest?: GuestPermissions;
}

Replace resolvePermissionContext with:

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<string>(),
      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
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):

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:

export type UserRole = "admin" | "member" | "guest";

Add constants under it:

/** 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:

  const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
  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
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):

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:

export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived

Widen SessionValidation:

export interface SessionValidation {
  userId: string;
  username: string;
  role: "admin" | "member" | "guest";
}

Update the createSession signature in the SessionStore interface:

  createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };

Replace the createSession implementation:

    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:

      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
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:

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:

import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";

Add a new function above createDatabase:

/**
 * 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);:

  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
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<P>({ capability?: string; guestFlag?: GuestFlag }): RequestHandler<P>; 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:

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:

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:
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<P = Record<string, string>>(opts: {
  capability?: string;
  guestFlag?: GuestFlag;
}): RequestHandler<P> {
  return (req: Request<P>, 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:

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
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):

// 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:
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<string>;
      bots?: "all" | Set<string>;
      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:

  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
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):

// 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:

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:

export function createSessionRouter(
  users: UserStore,
  sessions: SessionStore,
  audit: AuditStore,
  logger: Logger,
  permissions: PermissionStore,
  getGuestConfig: () => GuestModeConfig
): Router {

Update /needs-setup:

  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):

  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:

  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:

  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
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:

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<Record<string, boolean>>) => () => ({ 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):

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:

// /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: "<same token>" }) 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:

  // 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
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):

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:

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:

  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):

  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
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):

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:

import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";

Gate the quality read:

  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
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<string>); 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:

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:

    // 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<string> =
      result.role === "guest"
        ? guestBots === "all" ? "all" : new Set(guestBots)
        : "all";

Then stamp the ws in the handleUpgrade callback:

    wss.handleUpgrade(req, socket, head, (ws) => {
      const w = ws as unknown as { userId: string; isGuest: boolean; botScope: "all" | Set<string> };
      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:

  function visibleToClient(ws: WebSocket, botId: string): boolean {
    const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
    if (!w.isGuest || w.botScope === "all" || !w.botScope) return true;
    return w.botScope.has(botId);
  }

Replace the init build in the connection handler:

  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:

  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:

    // 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
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<string, boolean> | 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:

interface User {
  id: string;
  username: string;
  role: 'admin' | 'member' | 'guest';
  capabilities?: string[];
  bots?: "all" | string[];
  guest?: Record<string, boolean> | null;
}

Add a guestAllowed ref near needsSetup:

const guestAllowed = ref(false);

In refreshNeedsSetup, also capture guestAllowed:

async function refreshNeedsSetup(): Promise<void> {
  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):

async function continueAsGuest(): Promise<void> {
  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:

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():

  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
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:

  // 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
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 </form> close (still inside .auth-page), and a handler.

Template (insert after the <form> element, before </div>):

      <button
        v-if="session.guestAllowed.value"
        type="button"
        class="guest-btn"
        :disabled="loading"
        @click="enterAsGuest"
      >
        以游客身份进入
      </button>

Script (add the handler next to submit):

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 <style scoped> block):

.guest-btn {
  height: 38px; margin-top: 4px; border-radius: var(--radius-sm);
  background: transparent; color: var(--text-secondary);
  border: 1px solid var(--border-color); cursor: pointer;
}
.guest-btn:hover { color: var(--text-primary); }
.guest-btn:disabled { opacity: 0.6; cursor: progress; }

Note .auth-card is the <form>; place the guest button as a sibling after it so it sits below the card, or move it inside the form before </form> if you prefer it within the card. Either is fine; match the card width visually.

  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/views/Login.vue
git commit -m "feat(web/login): add Continue as guest entry when guest mode is on"

Task 16: Navbar — hide settings cog + guest role badge

Files:

  • Modify: web/src/components/Navbar.vue

Interfaces:

  • Consumes: session.isGuest, session.currentUser.

  • Step 1: Implement — in web/src/components/Navbar.vue:

Gate the settings cog:

      <RouterLink v-if="!session.isGuest.value" to="/settings" class="settings-btn">
        <Icon icon="mdi:cog" />
      </RouterLink>

Update the role badge to render a guest label (replace the existing badge <span>):

        <span class="nav-user-role" :class="`role-${session.currentUser.value.role}`">
          {{ session.currentUser.value.role === 'admin' ? '管理员' : session.currentUser.value.role === 'guest' ? '游客' : '成员' }}
        </span>

(Optionally add a .role-guest color in the <style> mirroring .role-member.)

  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/components/Navbar.vue
git commit -m "feat(web/navbar): hide settings cog for guests + 游客 badge"

Task 17: App shell — hide mobile settings tab for guests

Files:

  • Modify: web/src/App.vue

Interfaces:

  • Consumes: useSession().isGuest.

  • Step 1: Implement — in web/src/App.vue:

Add the import + usage in <script setup> (App.vue currently does NOT use the session):

import { useSession } from './composables/useSession.js';
const session = useSession();

Gate the mobile settings tab:

      <RouterLink v-if="!session.isGuest.value" to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
        <Icon icon="mdi:cog" class="tab-icon" />
        <span class="tab-label">设置</span>
      </RouterLink>
  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/App.vue
git commit -m "feat(web/app): hide mobile settings tab for guests"

Task 18: SongCard — gate play/playNext/add buttons

Files:

  • Modify: web/src/components/SongCard.vue

Interfaces:

  • Consumes: useSession().can, useSession().guestCan.

  • Step 1: Implement — in web/src/components/SongCard.vue:

Add to <script setup> (after the existing imports):

import { computed } from 'vue';
import { useSession } from '../composables/useSession.js';

const { can, guestCan } = useSession();
const showPlay = computed(() => can('player.control') || guestCan('playNow'));
const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));

Gate the root double-click and each button:

  <div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')">
    <div class="song-actions">
      <button v-if="showPlay" class="action-btn" @click.stop="$emit('play')" title="播放">
        <Icon icon="mdi:play" />
      </button>
      <button v-if="showPlayNext" class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
        <Icon icon="mdi:playlist-play" />
      </button>
      <button v-if="showAdd" class="action-btn" @click.stop="$emit('add')" title="添加到队列">
        <Icon icon="mdi:playlist-plus" />
      </button>
    </div>
  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/components/SongCard.vue
git commit -m "feat(web/songcard): gate play/playNext/add by member capability or guest flag"

Task 19: Player store — guest-aware non-destructive play-now

Files:

  • Modify: web/src/stores/player.ts

Interfaces:

  • Produces: playSong posts to /play-now-song for guests, /play-song otherwise.

  • Consumes: useSession().isGuest.

  • Step 1: Implement — in web/src/stores/player.ts:

Add the import at the top (with the other imports):

import { useSession } from '../composables/useSession.js';

Replace playSong:

    async playSong(song: Song) {
      if (!this.activeBotId) return;
      // Guests use the non-destructive "play now" (insert-next + skip) so they
      // can't wipe everyone else's queue; members/admins keep the normal behavior.
      const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
      const res = await axios.post(`/api/player/${this.activeBotId}/${endpoint}`, { song });
      if (res.data?.ok === false && res.data?.message) {
        this.notify(res.data.message, 'error');
      }
      this._setTiming(this.activeBotId, { serverElapsed: 0 });
      this._syncAfterAction();
    },
  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors. (No import cycle: useSession.ts imports nothing from the store.)

  • Step 3: Commit
git add web/src/stores/player.ts
git commit -m "feat(web/store): route guest play to non-destructive play-now-song"

Task 20: Player bar — per-button gating for guests

Files:

  • Modify: web/src/components/Player.vue

Interfaces:

  • Consumes: useSession().can, useSession().guestCan.

  • Step 1: Implement — in web/src/components/Player.vue:

Add computeds after const canControl = computed(() => can('player.control'));:

const { can, guestCan } = useSession();
const canControl = computed(() => can('player.control'));
const canTransport = computed(() => can('player.control') || guestCan('transport'));
const canSkip = computed(() => can('player.control') || guestCan('skip'));
const canModeCtl = computed(() => can('player.control') || guestCan('playMode'));

Replace the transport <template v-if="canControl"> block (the prev/play-pause/next/mode group) with per-button gating:

        <template v-if="canControl || canTransport || canSkip || canModeCtl">
          <button v-if="canControl" class="control-btn" @click="store.prev()">
            <Icon icon="mdi:skip-previous" />
          </button>
          <button v-if="canTransport" class="play-btn" @click="togglePlay">
            <Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
          </button>
          <button v-if="canSkip" class="control-btn" @click="store.next()">
            <Icon icon="mdi:skip-next" />
          </button>
          <button v-if="canModeCtl" class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
            <Icon :icon="modeIcon" />
            <span class="mode-label">{{ modeLabel }}</span>
          </button>
        </template>

Replace the volume <template v-if="canControl"> with canTransport:

        <template v-if="canTransport">
          <Icon icon="mdi:volume-high" class="volume-icon" />
          <input
            type="range"
            min="0"
            max="100"
            :value="activeBot?.volume ?? 75"
            @change="onVolumeChange"
            class="volume-slider"
          />
        </template>

Change the seek gating to use canTransport (the :class and the handler):

        :class="{ 'no-seek': !canTransport }"

And in the script onProgressClick:

  if (!canTransport.value) return;
  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/components/Player.vue
git commit -m "feat(web/player): per-button transport gating honoring guest flags"

Task 21: Queue panel — remove/clear gating for guests

Files:

  • Modify: web/src/components/Queue.vue

Interfaces:

  • Consumes: useSession().can, useSession().guestCan.

  • Step 1: Implement — in web/src/components/Queue.vue:

Destructure guestCan:

const { can, guestCan } = useSession();

Gate the clear/stop header button:

      <button
        v-if="botQueue.length > 0 && (can('player.control') || guestCan('removeClear'))"
        class="clear-btn"
        @click="clearAndStop"
        title="清空队列并停止播放"
      >
        <Icon icon="mdi:stop-circle-outline" />
      </button>

Gate the per-item remove button:

        <button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click="removeSong(i)" title="移除">
          <Icon icon="mdi:close" />
        </button>

Leave playAtIndex's if (!can('player.control')) return; guard as-is — there is no guest flag for jump-to-index, so guests can't double-click-to-play-at. (For a guest, the dblclick handler simply no-ops.)

  • Step 2: Type-check

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/components/Queue.vue
git commit -m "feat(web/queue): gate remove/clear by member capability or guest removeClear"

Task 22: Settings — admin-only 游客模式 section

Files:

  • Modify: web/src/views/Settings.vue

Interfaces:

  • Consumes: GET/POST /api/bot/settings (now carrying guestMode); session.isAdmin; store.bots.

  • Step 1: Implement — in web/src/views/Settings.vue:

Script — add state near the idle-timeout refs (around the loadIdleTimeout/saveAutoPause block):

// --- Guest mode (admin only) ---
const GUEST_FLAGS: { token: string; label: string }[] = [
  { token: 'addToQueue', label: '添加到队列末尾' },
  { token: 'playNext', label: '添加到下一首' },
  { token: 'playNow', label: '立即播放(不清空队列)' },
  { token: 'skip', label: '跳过当前歌曲' },
  { token: 'transport', label: '暂停/继续/进度/音量' },
  { token: 'removeClear', label: '移除/清空队列' },
  { token: 'playMode', label: '切换播放模式 / FM' },
];
const guestMode = reactive<{ enabled: boolean; botsAll: boolean; selectedBotIds: string[]; permissions: Record<string, boolean> }>({
  enabled: false,
  botsAll: true,
  selectedBotIds: [],
  permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false },
});
const guestSaving = ref(false);

function applyGuestModeFromServer(gm: any) {
  if (!gm) return;
  guestMode.enabled = Boolean(gm.enabled);
  guestMode.botsAll = gm.bots === 'all';
  guestMode.selectedBotIds = Array.isArray(gm.bots) ? [...gm.bots] : [];
  for (const f of GUEST_FLAGS) {
    guestMode.permissions[f.token] = Boolean(gm.permissions?.[f.token]);
  }
}

function toggleGuestBot(id: string, checked: boolean) {
  const has = guestMode.selectedBotIds.includes(id);
  if (checked && !has) guestMode.selectedBotIds.push(id);
  else if (!checked && has) guestMode.selectedBotIds = guestMode.selectedBotIds.filter((b) => b !== id);
}

async function saveGuestMode() {
  guestSaving.value = true;
  try {
    const res = await axios.post('/api/bot/settings', {
      guestMode: {
        enabled: guestMode.enabled,
        bots: guestMode.botsAll ? 'all' : [...guestMode.selectedBotIds],
        permissions: { ...guestMode.permissions },
      },
    });
    applyGuestModeFromServer(res.data?.guestMode);
  } catch { /* ignore */ } finally {
    guestSaving.value = false;
  }
}

Extend loadIdleTimeout to also hydrate guest mode (it already GETs /api/bot/settings):

async function loadIdleTimeout() {
  try {
    const res = await axios.get('/api/bot/settings');
    idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
    autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
    applyGuestModeFromServer(res.data.guestMode);
  } catch { /* ignore */ }
}

Template — insert a new admin-only section between the end of the 行为设置 section (</section> at the idle-timeout/auto-pause block) and the 机器人 Profile section:

    <!-- Guest Mode (admin only) -->
    <section v-if="session.isAdmin.value" class="settings-section">
      <h2 class="section-title">游客模式</h2>
      <p class="profile-section-hint">开启后,访客无需登录即可进入并点歌(默认关闭)。游客永远无法查看或修改设置。下面逐项决定游客可用的能力。</p>

      <label class="profile-toggle behavior-toggle">
        <div class="profile-toggle-text">
          <div class="profile-toggle-label">允许游客访问</div>
          <div class="profile-toggle-hint">登录页会出现「以游客身份进入」。关闭后所有游客会话立即失效。</div>
        </div>
        <input v-model="guestMode.enabled" type="checkbox" class="profile-toggle-switch" />
      </label>

      <div v-if="guestMode.enabled" class="perm-group">
        <div class="perm-group-title">游客权限</div>
        <div class="perm-checks">
          <label v-for="f in GUEST_FLAGS" :key="f.token" class="perm-check">
            <input type="checkbox" v-model="guestMode.permissions[f.token]" />
            {{ f.label }}
          </label>
        </div>
      </div>

      <div v-if="guestMode.enabled" class="perm-group">
        <div class="perm-group-title">可控制的机器人</div>
        <label class="perm-check">
          <input type="checkbox" v-model="guestMode.botsAll" />
          全部机器人
        </label>
        <div v-if="!guestMode.botsAll" class="perm-checks perm-bots">
          <label v-for="bot in store.bots" :key="bot.id" class="perm-check">
            <input
              type="checkbox"
              :checked="guestMode.selectedBotIds.includes(bot.id)"
              @change="toggleGuestBot(bot.id, ($event.target as HTMLInputElement).checked)"
            />
            {{ bot.name }}
          </label>
          <span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
        </div>
      </div>

      <div class="form-actions">
        <button class="btn-primary" :disabled="guestSaving" @click="saveGuestMode">
          {{ guestSaving ? '保存中…' : '保存' }}
        </button>
      </div>
    </section>

Reuses existing classes (settings-section, profile-toggle, perm-group, perm-check, perm-checks, perm-bots, form-actions, btn-primary) so no new CSS is required.

  • Step 2: Type-check + build

Run: cd web && npx vue-tsc --noEmit Expected: no errors.

  • Step 3: Commit
git add web/src/views/Settings.vue
git commit -m "feat(web/settings): admin-only 游客模式 section (toggles + bot scope)"

Task 23: README docs + full verification

Files:

  • Modify: README.md

  • Verify: whole repo

  • Step 1: Document — add a "游客模式 / Guest mode" subsection to README.md near the existing permissions/WebUI auth docs, explaining: default-off; how to enable in 设置 → 游客模式; the 7 toggles; per-bot scope; that guests can't view/change settings; and that to reproduce "下一首 only" you disable 添加到队列末尾 and enable 添加到下一首. (Match the README's existing bilingual style.)

  • Step 2: Full backend test suite

Run: npm test Expected: all tests pass (including the new config/permissions/users/sessions/database/authorize/requireNotGuest/requireAuth/session/enforcement/bot/websocket tests).

  • Step 3: Full build (backend + web type-check + bundle)

Run: npm run build Expected: tsc clean, vue-tsc --noEmit clean, vite build succeeds.

  • Step 4: Manual smoke (optional but recommended — use the /run or /verify skill)

Start the app, create/login admin, enable guest mode with only 添加到下一首 on and scope to one bot; open an incognito window, click 以游客身份进入, confirm: no settings cog/tab, only the 下一首 button appears on songs, other bots are not visible, and POST /api/session/guest returns 403 after toggling guest mode off.

  • Step 5: Commit
git add README.md
git commit -m "docs: document guest mode"

Self-Review notes (author)

  • Spec coverage: default-off (T1), guest principal/session entry (T3–T5, T8), unified gate (T6, T9), per-flag toggles incl. play-next/play-now/skip/transport/remove-clear/play-mode (T9, T18, T20–T22), non-destructive play-now (T9, T19), settings always-locked incl. ungated reads (T10–T11), per-bot scope on REST (T7 via requireBotAccess + resolvePermissionContext) and WS (T12), disable→logout (T7, T8, T12), frontend gating + entry + admin UI (T13–T22), tests throughout, README (T23). Reproducing issue #83's "下一首 only" = admin config (T22) — documented.
  • Ordering caveat: Task 2 must be committed before Task 1 type-checks (Task 1 imports from permissions.js); Tasks 7 and 8 both edit server.ts and should be done back-to-back so tsc is green.
  • Type consistency: flag names (addToQueue/playNext/playNow/skip/transport/removeClear/playMode) identical across GuestPermissions, GUEST_PERMISSION_FLAGS, config defaults, authorize guestFlag args, and the Settings UI. role union widened identically in users.ts, sessions.ts, requireAuth.ts, and the frontend User.