mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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>
2200 lines
79 KiB
Markdown
2200 lines
79 KiB
Markdown
# 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`:
|
||
|
||
```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<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**
|
||
|
||
```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<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:
|
||
|
||
```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<string>;
|
||
bots: "all" | Set<string>;
|
||
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<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**
|
||
|
||
```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<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`:
|
||
|
||
```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<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`:
|
||
|
||
```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<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:
|
||
|
||
```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<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`):
|
||
|
||
```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: "<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:
|
||
|
||
```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<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:
|
||
|
||
```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<string> =
|
||
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<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`:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```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<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`:
|
||
|
||
```ts
|
||
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`:
|
||
|
||
```ts
|
||
const guestAllowed = ref(false);
|
||
```
|
||
|
||
In `refreshNeedsSetup`, also capture `guestAllowed`:
|
||
|
||
```ts
|
||
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`):
|
||
|
||
```ts
|
||
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`:
|
||
|
||
```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 `</form>` close (still inside `.auth-page`), and a handler.
|
||
|
||
Template (insert after the `<form>` element, before `</div>`):
|
||
|
||
```vue
|
||
<button
|
||
v-if="session.guestAllowed.value"
|
||
type="button"
|
||
class="guest-btn"
|
||
:disabled="loading"
|
||
@click="enterAsGuest"
|
||
>
|
||
以游客身份进入
|
||
</button>
|
||
```
|
||
|
||
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 `<style scoped>` block):
|
||
|
||
```scss
|
||
.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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```vue
|
||
<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>`):
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```ts
|
||
import { useSession } from './composables/useSession.js';
|
||
const session = useSession();
|
||
```
|
||
|
||
Gate the mobile settings tab:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```ts
|
||
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:
|
||
|
||
```vue
|
||
<div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')">
|
||
```
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```ts
|
||
import { useSession } from '../composables/useSession.js';
|
||
```
|
||
|
||
Replace `playSong`:
|
||
|
||
```ts
|
||
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**
|
||
|
||
```bash
|
||
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'));`:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```vue
|
||
<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`:
|
||
|
||
```vue
|
||
<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):
|
||
|
||
```vue
|
||
:class="{ 'no-seek': !canTransport }"
|
||
```
|
||
And in the script `onProgressClick`:
|
||
```ts
|
||
if (!canTransport.value) return;
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
Run: `cd web && npx vue-tsc --noEmit`
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```ts
|
||
const { can, guestCan } = useSession();
|
||
```
|
||
|
||
Gate the clear/stop header button:
|
||
|
||
```vue
|
||
<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:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```ts
|
||
// --- 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`):
|
||
|
||
```ts
|
||
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:
|
||
|
||
```vue
|
||
<!-- 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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`.
|