diff --git a/docs/superpowers/plans/2026-05-30-account-permissions.md b/docs/superpowers/plans/2026-05-30-account-permissions.md new file mode 100644 index 0000000..16bc02a --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-account-permissions.md @@ -0,0 +1,811 @@ +# Account Permissions 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:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend. + +**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor. + +**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia. + +**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md` + +**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run `. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task. + +--- + +## File Structure + +**Create:** +- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here) +- `src/data/permissions.test.ts` — store + constants tests +- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)` +- `src/web/middleware/requirePermission.test.ts` — middleware tests + +**Modify:** +- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members +- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction` +- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access +- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged +- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them +- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware +- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission` +- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")` +- `src/web/api/auth.ts` — `requirePermission("platform.auth")` +- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts +- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members +- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions` +- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)` +- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts` + +--- + +## Task 1: Capability constants + PermissionStore + tables + +**Files:** +- Create: `src/data/permissions.ts` +- Create: `src/data/permissions.test.ts` +- Modify: `src/data/database.ts` (initTables) + +- [ ] **Step 1: Write the failing test** + +`src/data/permissions.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createPermissionStore } from "./permissions.js"; +import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js"; + +describe("PermissionStore", () => { + let dbFile: string; + let db: BotDatabase; + + beforeEach(() => { + dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + db = createDatabase(dbFile); + // a user row is required for FK; insert directly + db.db.prepare( + "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)" + ).run("u1", "alice", "x", Date.now(), Date.now(), "member"); + }); + + afterEach(() => { + db.close(); + try { fs.rmSync(dbFile, { force: true }); } catch {} + try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {} + try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {} + }); + + it("exposes the five capability tokens and a basic tier", () => { + expect(CAPABILITIES).toEqual([ + "player.control", "player.queue", "bot.manage", "platform.auth", "quality", + ]); + expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]); + }); + + it("defaults to no capabilities and no bots", () => { + const store = createPermissionStore(db.db); + expect(store.getCapabilities("u1")).toEqual([]); + expect(store.getBotAccess("u1")).toEqual([]); + }); + + it("round-trips capabilities and a specific bot list", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] }); + expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]); + expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]); + }); + + it("stores the all-bots flag as 'all'", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" }); + expect(store.getBotAccess("u1")).toBe("all"); + }); + + it("setPermissions replaces prior capabilities and bots", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] }); + store.setPermissions("u1", { capabilities: ["quality"], bots: "all" }); + expect(store.getCapabilities("u1")).toEqual(["quality"]); + expect(store.getBotAccess("u1")).toBe("all"); + }); + + it("ignores unknown capability tokens", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] }); + expect(store.getCapabilities("u1")).toEqual(["player.control"]); + }); + + it("pruneBot removes a bot from every user's allow-list", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] }); + store.pruneBot("botA"); + expect(store.getBotAccess("u1")).toEqual(["botB"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/data/permissions.test.ts` +Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing). + +- [ ] **Step 3: Create `src/data/permissions.ts`** + +```typescript +import type Database from "better-sqlite3"; + +export const CAPABILITIES = [ + "player.control", + "player.queue", + "bot.manage", + "platform.auth", + "quality", +] as const; +export type Capability = (typeof CAPABILITIES)[number]; + +/** Marker token stored in user_permissions meaning "all bots, incl. future". */ +export const BOTS_ALL = "bots.all"; + +/** Capabilities granted to a newly-created member by default. */ +export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"]; + +export function isCapability(x: string): x is Capability { + return (CAPABILITIES as readonly string[]).includes(x); +} + +export type BotAccess = "all" | string[]; + +export interface PermissionStore { + getCapabilities(userId: string): Capability[]; + getBotAccess(userId: string): BotAccess; + setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void; + pruneBot(botId: string): void; +} + +export function createPermissionStore(db: Database.Database): PermissionStore { + const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?"); + const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?"); + const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)"); + const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?"); + const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?"); + const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)"); + const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?"); + + return { + getCapabilities(userId) { + return (selCaps.all(userId) as { permission: string }[]) + .map((r) => r.permission) + .filter((p): p is Capability => isCapability(p)); + }, + getBotAccess(userId) { + const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL); + if (all) return "all"; + return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId); + }, + setPermissions(userId, input) { + const caps = input.capabilities.filter(isCapability); + const tx = db.transaction(() => { + delCaps.run(userId); + delBots.run(userId); + for (const c of caps) insCap.run(userId, c); + if (input.bots === "all") { + insCap.run(userId, BOTS_ALL); + } else { + for (const b of input.bots) insBot.run(userId, b); + } + }); + tx(); + }, + pruneBot(botId) { + pruneBotStmt.run(botId); + }, + }; +} +``` + +- [ ] **Step 4: Add tables in `src/data/database.ts` initTables** + +Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE: + +```typescript + db.exec(` + CREATE TABLE IF NOT EXISTS user_permissions ( + userId TEXT NOT NULL, + permission TEXT NOT NULL, + PRIMARY KEY (userId, permission), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_bot_access ( + userId TEXT NOT NULL, + botId TEXT NOT NULL, + PRIMARY KEY (userId, botId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId); + `); +``` + +(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run src/data/permissions.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts +git commit -m "feat(perm): permission store + capability tokens + tables" +``` + +--- + +## Task 2: requirePermission + requireBotAccess middleware + +**Files:** +- Create: `src/web/middleware/requirePermission.ts` +- Create: `src/web/middleware/requirePermission.test.ts` +- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`) + +- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`** + +Change the `declare module` block so `req.user` carries capabilities + bot access: + +```typescript +declare module "express-serve-static-core" { + interface Request { + user?: { + id: string; + username: string; + role: "admin" | "member"; + capabilities: Set; + bots: "all" | Set; + }; + } +} +``` + +(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.) + +- [ ] **Step 2: Write the failing middleware test** + +`src/web/middleware/requirePermission.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import { requirePermission, requireBotAccess } from "./requirePermission.js"; + +function appWith(user: any) { + const app = express(); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true })); + app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true })); + return app; +} + +const member = (caps: string[], bots: "all" | string[]) => ({ + id: "u1", username: "a", role: "member", + capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots), +}); +const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" }; + +describe("requirePermission", () => { + it("401 when unauthenticated", async () => { + const app = express(); + app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true })); + expect((await request(app).post("/cap")).status).toBe(401); + }); + it("403 when member lacks the capability", async () => { + expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403); + }); + it("200 when member has the capability", async () => { + expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200); + }); + it("200 for admin regardless of capabilities", async () => { + expect((await request(appWith(admin)).post("/cap")).status).toBe(200); + }); +}); + +describe("requireBotAccess", () => { + it("200 when bots = all", async () => { + expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200); + }); + it("200 when botId in allow-list", async () => { + expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200); + }); + it("403 when botId not in allow-list", async () => { + expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403); + }); + it("200 for admin", async () => { + expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/web/middleware/requirePermission.test.ts` +Expected: FAIL — module `./requirePermission.js` not found. + +- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`** + +```typescript +import type { Request, Response, NextFunction, RequestHandler } from "express"; + +export function requirePermission(capability: string): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +} + +export function requireBotAccess(paramName = "botId"): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "admin" || req.user.bots === "all") { next(); return; } + const botId = req.params[paramName]; + if (botId && req.user.bots.has(botId)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/web/middleware/requirePermission.test.ts` +Expected: PASS (8 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts +git commit -m "feat(perm): requirePermission + requireBotAccess middleware" +``` + +--- + +## Task 3: Effective-permissions resolver (admin = all) + +**Files:** +- Modify: `src/data/permissions.ts` (add `resolveContext` helper) +- Modify: `src/data/permissions.test.ts` (add tests) + +- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`: + +```typescript +import { resolvePermissionContext } from "./permissions.js"; + +describe("resolvePermissionContext", () => { + it("admin gets all capabilities and all bots regardless of stored rows", () => { + const store = createPermissionStore(db.db); + const ctx = resolvePermissionContext("admin", "u1", store); + expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort()); + expect(ctx.bots).toBe("all"); + }); + it("member reflects stored capabilities + bot access", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] }); + const ctx = resolvePermissionContext("member", "u1", store); + expect([...ctx.capabilities]).toEqual(["player.control"]); + expect(ctx.bots).toEqual(new Set(["b1"])); + }); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `npx vitest run src/data/permissions.test.ts` +Expected: FAIL — `resolvePermissionContext` not exported. + +- [ ] **Step 3: Add to `src/data/permissions.ts`** + +```typescript +export interface PermissionContext { + capabilities: Set; + bots: "all" | Set; +} + +export function resolvePermissionContext( + role: "admin" | "member", + userId: string, + store: PermissionStore +): PermissionContext { + if (role === "admin") { + return { capabilities: new Set(CAPABILITIES), bots: "all" }; + } + const access = store.getBotAccess(userId); + return { + capabilities: new Set(store.getCapabilities(userId)), + bots: access === "all" ? "all" : new Set(access), + }; +} +``` + +- [ ] **Step 4: Run to verify pass** + +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(perm): resolvePermissionContext (admin = super-user)" +``` + +--- + +## Task 4: Load permissions onto req.user (requireAuth + session inline + /me) + +**Files:** +- Modify: `src/web/middleware/requireAuth.ts` +- Modify: `src/web/api/session.ts` +- Modify: `src/web/server.ts` + +- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`** + +`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields: + +```typescript +import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js"; + +export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler { + return function requireAuth(req, res, next) { + const result = validateSessionFromHeaders(req.headers.cookie, sessions); + if (!result) { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + res.status(401).json({ error: "unauthenticated" }); + return; + } + const ctx = resolvePermissionContext(result.role, result.userId, permissions); + req.user = { + id: result.userId, username: result.username, role: result.role, + capabilities: ctx.capabilities, bots: ctx.bots, + }; + 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(); + }; +} +``` + +- [ ] **Step 2: Update `src/web/server.ts`** + +Construct the store next to the others and pass it in: + +```typescript +import { createPermissionStore } from "../data/permissions.js"; +// ... +const permissions = createPermissionStore(options.database.db); +// ... +const requireAuth = createRequireAuth(sessions, permissions); +``` + +Keep `permissions` in scope — it's passed to routers in Tasks 5–7. + +- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`** + +`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return: + +```typescript +const ctx = resolvePermissionContext(validation.role, validation.userId, permissions); +res.json({ + id: validation.userId, username: validation.username, role: validation.role, + capabilities: [...ctx.capabilities], + bots: ctx.bots === "all" ? "all" : [...ctx.bots], +}); +``` + +(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.) + +- [ ] **Step 4: Verify build + existing tests** + +Run: `npx tsc --noEmit` +Expected: exit 0 (the widened `req.user` is now populated everywhere it's read). + +Run: `npx vitest run src/web` +Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`). + +- [ ] **Step 5: Commit** + +```bash +git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts +git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me" +``` + +--- + +## Task 5: Enforce capabilities on the action routes + +**Files:** +- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts` +- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`) + +- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts: + - member without `player.control` → `POST /api/player/:botId/pause` → 403 + - member with `player.control` + bot in allow-list → 200 (bot resolves) + - member with `player.control` but bot NOT in allow-list → 403 + - member without `player.queue` → `POST /api/player/:botId/clear` → 403 + - member without `bot.manage` → `POST /api/bot` → 403 + - member without `platform.auth` → `POST /api/auth/cookie` → 403 + - member without `quality` → `POST /api/music/quality` → 403 + - admin → all 200/allowed + + Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness). + +- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone). + +- [ ] **Step 3: Apply gates.** + +`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes: + +```typescript +import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js"; + +// after the existing router.use("/:botId", resolveBot): +router.use("/:botId", requireBotAccess("botId")); + +const control = requirePermission("player.control"); +const queue = requirePermission("player.queue"); +// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song +// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE) +// Apply per route, e.g.: +router.post("/:botId/pause", control, async (req, res) => { /* existing */ }); +router.post("/:botId/add", queue, async (req, res) => { /* existing */ }); +router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ }); +``` + +(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.) + +`src/web/api/bot.ts` — gate management + per-bot: + +```typescript +const manage = requirePermission("bot.manage"); +router.post("/", manage, ...); // create (no botId) +router.put("/:id", manage, requireBotAccess("id"), ...); +router.delete("/:id", manage, requireBotAccess("id"), ...); +router.post("/:id/start", manage, requireBotAccess("id"), ...); +router.post("/:id/stop", manage, requireBotAccess("id"), ...); +router.put("/:id/avatar", manage, requireBotAccess("id"), ...); +router.delete("/:id/avatar", manage, requireBotAccess("id"), ...); +router.post("/settings", manage, ...); // global idle timeout +``` + +`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`: +`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.) + +`src/web/api/music.ts` — gate the one mutating route: +`router.post("/quality", requirePermission("quality"), ...)`. + +- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts +git commit -m "feat(perm): enforce capabilities + bot access on action routes" +``` + +--- + +## Task 6: Filter the bot list for members + +**Files:** +- Modify: `src/web/api/bot.ts` (`GET /`) +- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`) +- Modify: test from Task 5 + +- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all. + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`: + +```typescript +const all = getAllBots().map((b) => b.getStatus()); +const u = req.user!; +const bots = u.role === "admin" || u.bots === "all" + ? all + : all.filter((b) => (u.bots as Set).has(b.id)); +res.json({ bots }); +``` + +In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`. + +- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.** + +- [ ] **Step 5: Commit** + +```bash +git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts +git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete" +``` + +--- + +## Task 7: Management API (GET/PUT permissions) + audit + +**Files:** +- Modify: `src/data/audit.ts` (add action) +- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default) +- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`) +- Create/extend: `src/web/api/users.test.ts` + +- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`. + +- [ ] **Step 2: Write failing tests** for the users router (admin-only): + - `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member. + - `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists. + - `PUT` with an unknown capability token → it is dropped (not stored). + - New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`). + +- [ ] **Step 3: Run → fail.** + +- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add: + +```typescript +import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js"; + +router.get("/:id/permissions", (req, res) => { + const user = users.findById(req.params.id); + if (!user) { res.status(404).json({ error: "not_found" }); return; } + res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) }); +}); + +router.put("/:id/permissions", (req, res) => { + const user = users.findById(req.params.id); + if (!user) { res.status(404).json({ error: "not_found" }); return; } + const body = req.body ?? {}; + const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : []; + const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []); + permissions.setPermissions(user.id, { capabilities: caps, bots }); + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: user.id, targetUsername: user.username, + action: "user.permissions_changed", + }); + res.json({ success: true }); +}); +``` + +In the existing `POST /api/users` handler, after creating a member, seed the basic tier: + +```typescript +if (created.role === "member") { + permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" }); +} +``` + +- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.** + +- [ ] **Step 6: Commit** + +```bash +git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts +git commit -m "feat(perm): admin permissions API + audit + new-member basic tier" +``` + +--- + +## Task 8: One-time migration backfill (existing members → full) + +**Files:** +- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill) +- Create: `src/data/permissions-migration.test.ts` + +- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty). + + Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip. + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`: + +```typescript +db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`); +const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get(); +if (!done) { + const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[]; + const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)"); + const tx = db.transaction(() => { + for (const m of members) { + for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) { + insCap.run(m.id, c); + } + } + db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now())); + }); + tx(); +} +``` + +- [ ] **Step 4: Run → pass.** + +- [ ] **Step 5: Commit** + +```bash +git add src/data/database.ts src/data/permissions-migration.test.ts +git commit -m "feat(perm): one-time backfill of existing members to full access" +``` + +--- + +## Task 9: Frontend — session capabilities + helpers + +**Files:** +- Modify: `web/src/composables/useSession.ts` + +- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them). +- [ ] **Step 2:** Add computed helpers: + +```typescript +function can(cap: string): boolean { + const u = currentUser.value; + return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap)); +} +function canControlBot(botId: string): boolean { + const u = currentUser.value; + if (!u) return false; + if (u.role === 'admin' || u.bots === 'all') return true; + return Array.isArray(u.bots) && u.bots.includes(botId); +} +``` + +Export `can` and `canControlBot` from the composable. + +- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`. +- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"` + +--- + +## Task 10: Frontend — gate UI by capability + filter bots + +**Files:** +- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts` + +- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control. +- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`. +- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`. +- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`. +- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"` + +--- + +## Task 11: Frontend — admin permission editor + +**Files:** +- Modify: `web/src/views/Settings.vue` (User Management section) + +- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`. +- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor. +- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`. +- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"` + +--- + +## Final verification + +- [ ] `npx tsc --noEmit` → exit 0 +- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note) +- [ ] `cd web && npx vue-tsc --noEmit` → exit 0 +- [ ] `npm run build` → succeeds +- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie). + +> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.) + +## Out of scope (separate PRs, per spec) + +#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug. diff --git a/docs/superpowers/specs/2026-05-30-account-permissions-design.md b/docs/superpowers/specs/2026-05-30-account-permissions-design.md new file mode 100644 index 0000000..5265801 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-account-permissions-design.md @@ -0,0 +1,167 @@ +# Fine-grained account permissions — design + +**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea) +**Date:** 2026-05-30 +**Status:** Approved (brainstorm), pending implementation plan + +## Scope + +Issue #79 bundles five things. This spec covers **only item E**: allow an admin to +grant each non-admin (member) account a set of capabilities and a list of bots they +may control. The other items are handled in separate PRs and are **out of scope** +here: + +- #1 Guest mode (login-less playback) +- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate) +- #3 Auto-pause when channel empty +- #4 Dedicated link loses bot binding on refresh (a bug) + +## Problem + +Today the bot has a coarse two-role system: `admin | member` (single `role` column, +read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`. +**Every other action — create/edit/delete bots, start/stop, all playback & queue +control, set platform login cookies, set audio quality — is open to any logged-in +member, on every bot.** Admins want to delegate limited control to members without +handing them full power. + +## Decisions (from brainstorm) + +1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action + matrix, not role templates). +2. **Defaults:** on upgrade, existing members are backfilled with full capabilities + + all bots (no behavior change); newly-created members get a **basic tier**. +3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list; + empty list = no bots controllable. Members **cannot see** bots outside their + allow-list (hidden, not merely disabled). +4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots. +5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted + (existing invariant preserved). Permission grants/revokes are written to the + existing audit log. + +## Capability taxonomy + +| Capability token | Covers | Scope | +|---|---|---| +| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) | +| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) | +| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) | +| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) | +| `quality` | set audio quality per platform | **global** | + +Bot scope is independent of capabilities: a member with `player.control` can only +exercise it on bots in their allow-list (or all, if the "all bots" flag is set). +`platform.auth` and `quality` are global capabilities with no bot scope. + +**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`. +A new member can play/queue on every bot but cannot manage bots, change credentials, +or change quality. + +## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern) + +```sql +-- capability tokens + the "all bots" flag (stored as token 'bots.all') +CREATE TABLE IF NOT EXISTS user_permissions ( + userId TEXT NOT NULL, + permission TEXT NOT NULL, + PRIMARY KEY (userId, permission), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +-- specific bot allow-list (only consulted when 'bots.all' is NOT present) +CREATE TABLE IF NOT EXISTS user_bot_access ( + userId TEXT NOT NULL, + botId TEXT NOT NULL, + PRIMARY KEY (userId, botId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId); +``` + +- Admins have no rows (they bypass). Only members are constrained. +- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access` + rows; empty ⇒ none. +- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works. +- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its + access rows should be cleaned up (either an FK to the bot table if one exists, or an + explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`). + +New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` / +`createSessionStore`: prepared statements + an interface). Methods: +`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`, +`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`. + +## Backend enforcement (real 403 — not just hidden UI) + +- **`req.user` widened** to carry `capabilities: Set` and bot access. Loaded in + `requireAuth` (one extra lookup, or a JOIN in the session query). The same + `{id,username,role,capabilities,bots}` shape must be kept in sync in the three places + it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS + upgrade handler in `server.ts` (WS only needs it if a push action becomes gated). + Because it's read live, permission changes take effect immediately (no re-login). +- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no + user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403. +- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list; + else 403. Mounted on the player router's existing `/:botId` choke-point + (`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts` + (start/stop/edit/delete/avatar/profile). +- **Route → capability mapping:** + - `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`) + - `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`) + - `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones) + - `/api/auth/*` (cookie/QR/SMS) → `platform.auth` + - `/api/music/quality` POST → `quality` +- **`GET /api/bot`** filters its result to the caller's allowed bots for members + (admins see all). This is what "hides" disallowed bots in the UI. + +## Management API (admin-only, added to the existing users router) + +- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }` +- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens + against the known set and botIds against existing bots; writes audit + `user.permissions_changed`. +- `GET /api/session/me` is extended to include the **current** user's + `{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.) + +## Frontend + +- `useSession` extends `User` with `capabilities` + bot scope and exposes + `can(cap)` and `canControlBot(botId)` helpers. +- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden); + `activeBot` fallback and `fetchBots` default only ever land on an allowed bot. +- **Player / Settings** hide controls and whole sections a member lacks: platform + login, audio quality, and bot create/edit/delete are hidden without the matching + capability; playback/queue buttons hidden without `player.control` / `player.queue`. +- **Admin permission editor:** in the Settings → User Management list, each member row + gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人" + toggle. Saving calls `PUT /api/users/:id/permissions`. + +## Defaults & migration + +- New tables created idempotently in `initTables`. +- **One-time backfill** (guarded so it runs once): every existing `member` gets all + five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves + current behavior for existing members on upgrade. +- **New member default** (`POST /api/users` with role member): capabilities + `{ player.control, player.queue }` + `bots.all` (basic tier). +- Pre-existing accounts default to `role = 'admin'` per the current schema — those are + super-users and unaffected. + +## Testing (TDD) + +- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs + empty; `pruneBot`). +- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks + cap → 200/403; bot in/out of allow-list; `bots.all`). +- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden; + `GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates + + audits. +- Migration test: existing members backfilled to full + `bots.all`; new member gets + basic tier. + +## Non-goals + +- No per-bot×per-capability matrix, no custom role templates (YAGNI). +- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate. +- No change to the admin/member role concept itself; this layers capabilities under + the existing `member` role. diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 54b7b22..5748da1 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -12,6 +12,7 @@ import type { Logger } from "../logger.js"; import type { ServerProtocol } from "../ts-protocol/client.js"; import type { AvatarStore } from "../data/avatars.js"; +import type { PermissionStore } from "../data/permissions.js"; /** * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the @@ -76,6 +77,7 @@ export class BotManager extends EventEmitter { private config: BotConfig; private logger: Logger; private avatarStore: AvatarStore; + private permissions: PermissionStore; constructor( neteaseProvider: MusicProvider, @@ -84,7 +86,8 @@ export class BotManager extends EventEmitter { database: BotDatabase, config: BotConfig, logger: Logger, - avatarStore: AvatarStore + avatarStore: AvatarStore, + permissions: PermissionStore ) { super(); this.neteaseProvider = neteaseProvider; @@ -95,6 +98,7 @@ export class BotManager extends EventEmitter { this.config = config; this.logger = logger; this.avatarStore = avatarStore; + this.permissions = permissions; } async createBot(params: CreateBotParams): Promise { @@ -152,6 +156,7 @@ export class BotManager extends EventEmitter { this.bots.delete(id); } this.database.deleteBotInstance(id); + this.permissions.pruneBot(id); this.emit("botInstanceRemoved", id); this.logger.info({ botId: id }, "Bot instance removed"); } diff --git a/src/data/audit.ts b/src/data/audit.ts index 6f48171..c3d8004 100644 --- a/src/data/audit.ts +++ b/src/data/audit.ts @@ -6,7 +6,8 @@ export type AuditAction = | "user.deleted" | "user.password_reset" | "user.password_changed" - | "user.role_changed"; + | "user.role_changed" + | "user.permissions_changed"; export interface AuditEntry { id: number; diff --git a/src/data/database.ts b/src/data/database.ts index 51e7c0e..d2d9a43 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -1,4 +1,5 @@ import Database from "better-sqlite3"; +import { CAPABILITIES, BOTS_ALL } from "./permissions.js"; export interface PlayHistoryEntry { botId: string; @@ -194,15 +195,54 @@ function initTables(db: Database.Database): void { UNIQUE(userId, platform, playlistId) ); CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId); + + CREATE TABLE IF NOT EXISTS user_permissions ( + userId TEXT NOT NULL, + permission TEXT NOT NULL, + PRIMARY KEY (userId, permission), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS user_bot_access ( + userId TEXT NOT NULL, + botId TEXT NOT NULL, + PRIMARY KEY (userId, botId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId); `); } +/** + * One-time backfill: existing `member` users created before the + * account-permissions feature are granted full access (all 5 capabilities + + * the `bots.all` marker), exactly once per database. Admins are skipped (they + * bypass permission checks). New members created after this runs are not + * affected — they get the basic tier via POST /api/users. A marker row in + * `schema_meta` makes this idempotent. + */ +export function backfillMemberPermissions(db: Database.Database): void { + db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`); + const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get(); + if (done) return; + const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[]; + const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)"); + const tokens = [...CAPABILITIES, BOTS_ALL]; + const tx = db.transaction(() => { + for (const m of members) { + for (const t of tokens) insCap.run(m.id, t); + } + db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length)); + }); + tx(); +} + export function createDatabase(dbPath: string): BotDatabase { const db = new Database(dbPath); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); initTables(db); migrateSchema(db); + backfillMemberPermissions(db); const insertHistory = db.prepare(` INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) diff --git a/src/data/permissions-migration.test.ts b/src/data/permissions-migration.test.ts new file mode 100644 index 0000000..d290ce1 --- /dev/null +++ b/src/data/permissions-migration.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js"; +import { createPermissionStore, CAPABILITIES } from "./permissions.js"; + +describe("backfillMemberPermissions", () => { + let dbFile: string; + let db: BotDatabase; + function fresh() { + dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + db = createDatabase(dbFile); + } + afterEach(() => { + db.close(); + for (const s of ["", "-wal", "-shm"]) { + try { + fs.rmSync(dbFile + s, { force: true }); + } catch {} + } + }); + + it("grants existing members full access + bots.all, skips admins, once", () => { + fresh(); + // simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows + db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run(); + const now = Date.now(); + const ins = db.db.prepare( + "INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)" + ); + ins.run("m1", "mem", "x", now, now, "member"); + ins.run("a1", "adm", "x", now, now, "admin"); + + backfillMemberPermissions(db.db); + + const store = createPermissionStore(db.db); + expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort()); + expect(store.getBotAccess("m1")).toBe("all"); + expect(store.getCapabilities("a1")).toEqual([]); + expect(store.getBotAccess("a1")).toEqual([]); + }); + + it("is idempotent — running again does not change or re-grant", () => { + fresh(); + db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run(); + const now = Date.now(); + db.db + .prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)") + .run("m1", "mem", "x", now, now, "member"); + backfillMemberPermissions(db.db); + // member restricted afterwards + createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] }); + // second run must NOT re-grant (marker present) + backfillMemberPermissions(db.db); + expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]); + }); +}); diff --git a/src/data/permissions.test.ts b/src/data/permissions.test.ts new file mode 100644 index 0000000..61e3e16 --- /dev/null +++ b/src/data/permissions.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createPermissionStore } from "./permissions.js"; +import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js"; + +describe("PermissionStore", () => { + let dbFile: string; + let db: BotDatabase; + + beforeEach(() => { + dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + db = createDatabase(dbFile); + db.db.prepare( + "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)" + ).run("u1", "alice", "x", Date.now(), Date.now(), "member"); + }); + + afterEach(() => { + db.close(); + try { fs.rmSync(dbFile, { force: true }); } catch {} + try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {} + try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {} + }); + + it("exposes the five capability tokens and a basic tier", () => { + expect(CAPABILITIES).toEqual([ + "player.control", "player.queue", "bot.manage", "platform.auth", "quality", + ]); + expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]); + }); + + it("defaults to no capabilities and no bots", () => { + const store = createPermissionStore(db.db); + expect(store.getCapabilities("u1")).toEqual([]); + expect(store.getBotAccess("u1")).toEqual([]); + }); + + it("round-trips capabilities and a specific bot list", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] }); + expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]); + expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]); + }); + + it("stores the all-bots flag as 'all'", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" }); + expect(store.getBotAccess("u1")).toBe("all"); + }); + + it("setPermissions replaces prior capabilities and bots", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] }); + store.setPermissions("u1", { capabilities: ["quality"], bots: "all" }); + expect(store.getCapabilities("u1")).toEqual(["quality"]); + expect(store.getBotAccess("u1")).toBe("all"); + }); + + it("ignores unknown capability tokens", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] }); + expect(store.getCapabilities("u1")).toEqual(["player.control"]); + }); + + it("pruneBot removes a bot from every user's allow-list", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] }); + store.pruneBot("botA"); + expect(store.getBotAccess("u1")).toEqual(["botB"]); + }); + + describe("resolvePermissionContext", () => { + it("admin gets all capabilities and all bots regardless of stored rows", () => { + const store = createPermissionStore(db.db); + const ctx = resolvePermissionContext("admin", "u1", store); + expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort()); + expect(ctx.bots).toBe("all"); + }); + it("member reflects stored capabilities + bot access", () => { + const store = createPermissionStore(db.db); + store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] }); + const ctx = resolvePermissionContext("member", "u1", store); + expect([...ctx.capabilities]).toEqual(["player.control"]); + expect(ctx.bots).toEqual(new Set(["b1"])); + }); + }); +}); diff --git a/src/data/permissions.ts b/src/data/permissions.ts new file mode 100644 index 0000000..556bc79 --- /dev/null +++ b/src/data/permissions.ts @@ -0,0 +1,89 @@ +import type Database from "better-sqlite3"; + +export const CAPABILITIES = [ + "player.control", + "player.queue", + "bot.manage", + "platform.auth", + "quality", +] as const; +export type Capability = (typeof CAPABILITIES)[number]; + +/** Marker token stored in user_permissions meaning "all bots, incl. future". */ +export const BOTS_ALL = "bots.all"; + +/** Capabilities granted to a newly-created member by default. */ +export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"]; + +export function isCapability(x: string): x is Capability { + return (CAPABILITIES as readonly string[]).includes(x); +} + +export type BotAccess = "all" | string[]; + +export interface PermissionStore { + getCapabilities(userId: string): Capability[]; + getBotAccess(userId: string): BotAccess; + setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void; + pruneBot(botId: string): void; +} + +export function createPermissionStore(db: Database.Database): PermissionStore { + const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?"); + const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?"); + const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)"); + const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?"); + const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?"); + const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)"); + const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?"); + + return { + getCapabilities(userId) { + return (selCaps.all(userId) as { permission: string }[]) + .map((r) => r.permission) + .filter((p): p is Capability => isCapability(p)); + }, + getBotAccess(userId) { + const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL); + if (all) return "all"; + return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId); + }, + setPermissions(userId, input) { + const caps = input.capabilities.filter(isCapability); + const tx = db.transaction(() => { + delCaps.run(userId); + delBots.run(userId); + for (const c of caps) insCap.run(userId, c); + if (input.bots === "all") { + insCap.run(userId, BOTS_ALL); + } else { + for (const b of input.bots) insBot.run(userId, b); + } + }); + tx(); + }, + pruneBot(botId) { + pruneBotStmt.run(botId); + }, + }; +} + +export interface PermissionContext { + capabilities: Set; + bots: "all" | Set; +} + +export function resolvePermissionContext( + role: "admin" | "member", + userId: string, + store: PermissionStore +): PermissionContext { + if (role === "admin") { + return { capabilities: new Set(CAPABILITIES), bots: "all" }; + } + const access = store.getBotAccess(userId); + return { + capabilities: new Set(store.getCapabilities(userId)), + bots: access === "all" ? "all" : new Set(access), + }; +} diff --git a/src/index.ts b/src/index.ts index e107dcf..f48a557 100755 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js"; import { BiliBiliProvider } from "./music/bilibili.js"; import { createCookieStore } from "./music/auth.js"; import { createAvatarStore } from "./data/avatars.js"; +import { createPermissionStore } from "./data/permissions.js"; import { BotManager } from "./bot/manager.js"; import { createWebServer } from "./web/server.js"; @@ -56,6 +57,8 @@ async function main() { const bilibiliCookie = cookieStore.load("bilibili"); if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie); + const permissions = createPermissionStore(db.db); + const botManager = new BotManager( neteaseProvider, qqProvider, @@ -63,7 +66,8 @@ async function main() { db, config, logger, - avatarStore + avatarStore, + permissions ); await botManager.loadSavedBots(); diff --git a/src/web/api/audit.test.ts b/src/web/api/audit.test.ts index 5e65d48..b65a658 100644 --- a/src/web/api/audit.test.ts +++ b/src/web/api/audit.test.ts @@ -6,6 +6,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore } from "../../data/users.js"; import { createSessionStore } from "../../data/sessions.js"; import { createAuditStore } from "../../data/audit.js"; +import { createPermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { createAuditRouter } from "./audit.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -20,6 +21,7 @@ describe("audit router", () => { const users = createUserStore(botDb.db); const sessions = createSessionStore(botDb.db); const audit = createAuditStore(botDb.db); + const permissions = createPermissionStore(botDb.db); const alice = await users.createUser("alice", "pw-alice", "admin"); cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; for (let i = 0; i < 3; i++) { @@ -32,7 +34,7 @@ describe("audit router", () => { app = express(); app.use(express.json()); app.use(cookieParser()); - app.use("/api", createRequireAuth(sessions)); + app.use("/api", createRequireAuth(sessions, permissions)); app.use("/api/audit", createAuditRouter(audit)); }); diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts index 68fc323..16e3d1f 100644 --- a/src/web/api/auth.ts +++ b/src/web/api/auth.ts @@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js"; import { YouTubeProvider } from "../../music/youtube.js"; import type { CookieStore } from "../../music/auth.js"; import type { Logger } from "../../logger.js"; +import { requirePermission } from "../middleware/requirePermission.js"; export function createAuthRouter( neteaseProvider: MusicProvider, @@ -35,7 +36,7 @@ export function createAuthRouter( } }); - router.post("/qrcode", async (req, res) => { + router.post("/qrcode", requirePermission("platform.auth"), async (req, res) => { try { const { platform } = req.body; const provider = getProvider(platform); @@ -77,7 +78,7 @@ export function createAuthRouter( } }); - router.post("/sms/send", async (req, res) => { + router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => { try { const { phone } = req.body; if (!phone) { @@ -97,7 +98,7 @@ export function createAuthRouter( } }); - router.post("/sms/verify", async (req, res) => { + router.post("/sms/verify", requirePermission("platform.auth"), async (req, res) => { try { const { phone, code } = req.body; if (!phone || !code) { @@ -118,7 +119,7 @@ export function createAuthRouter( } }); - router.post("/cookie", (req, res) => { + router.post("/cookie", requirePermission("platform.auth"), (req, res) => { const { platform, cookie } = req.body; if (!cookie) { res.status(400).json({ error: "cookie is required" }); diff --git a/src/web/api/bot-list-filter.test.ts b/src/web/api/bot-list-filter.test.ts new file mode 100644 index 0000000..84aeaab --- /dev/null +++ b/src/web/api/bot-list-filter.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createBotRouter } from "./bot.js"; + +const logger = pino({ level: "silent" }); + +// Fake bot whose getStatus() exposes its id, matching the real status shape. +function makeFakeBot(id: string) { + return { + id, + getStatus: () => ({ id }), + }; +} + +function makeBotManager() { + const b1 = makeFakeBot("b1"); + const b2 = makeFakeBot("b2"); + return { + getBot: (id: string) => (id === "b1" ? b1 : id === "b2" ? b2 : undefined), + getAllBots: () => [b1, b2], + getBotConfig: () => undefined, + createBot: async () => b1, + updateBot: () => {}, + removeBot: async () => {}, + startBot: async () => {}, + stopBot: () => {}, + } as any; +} + +function makeApp(user: any) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + app.use( + "/api/bot", + createBotRouter( + makeBotManager(), + { idleTimeoutMinutes: 0 } as any, + "/tmp/config.json", + logger, + { getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any, + { read: () => null, write: () => "x", remove: () => {} } as any, + ), + ); + return app; +} + +const member = (bots: "all" | string[]) => ({ + id: "u1", + username: "alice", + role: "member" as const, + capabilities: new Set(), + bots: bots === "all" ? ("all" as const) : new Set(bots), +}); + +const admin = { + id: "a", + username: "admin", + role: "admin" as const, + capabilities: new Set(), + bots: "all" as const, +}; + +describe("GET /api/bot bot-list filtering", () => { + it("member with bots:Set([b1]) sees only b1", async () => { + const app = makeApp(member(["b1"])); + const res = await request(app).get("/api/bot"); + expect(res.status).toBe(200); + const ids = (res.body.bots as { id: string }[]).map((b) => b.id); + expect(ids).toEqual(["b1"]); + }); + + it("admin sees both b1 and b2", async () => { + const app = makeApp(admin); + const res = await request(app).get("/api/bot"); + expect(res.status).toBe(200); + const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort(); + expect(ids).toEqual(["b1", "b2"]); + }); + + it("member with bots:'all' sees both b1 and b2", async () => { + const app = makeApp(member("all")); + const res = await request(app).get("/api/bot"); + expect(res.status).toBe(200); + const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort(); + expect(ids).toEqual(["b1", "b2"]); + }); +}); diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index 0e3d60a..3e0832f 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -11,6 +11,7 @@ import { createUserStore } from "../../data/users.js"; import { createSessionStore } from "../../data/sessions.js"; import { createAvatarStore } from "../../data/avatars.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; +import { createPermissionStore } from "../../data/permissions.js"; import { createBotRouter } from "./bot.js"; import { getDefaultConfig, type BotConfig } from "../../data/config.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -59,7 +60,7 @@ describe("bot router /settings", () => { app = express(); app.use(express.json()); app.use(cookieParser()); - app.use("/api", createRequireAuth(sessions)); + app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); app.use( "/api/bot", createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore), diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index d08e3df..85172de 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -5,6 +5,7 @@ import { saveConfig } from "../../data/config.js"; import type { Logger } from "../../logger.js"; import type { BotDatabase } from "../../data/database.js"; import type { AvatarStore } from "../../data/avatars.js"; +import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js"; export function createBotRouter( botManager: BotManager, @@ -16,8 +17,13 @@ export function createBotRouter( ): Router { const router = Router(); - router.get("/", (_req, res) => { - const bots = botManager.getAllBots().map((b) => b.getStatus()); + router.get("/", (req, res) => { + const all = botManager.getAllBots().map((b) => b.getStatus()); + const u = req.user!; + const bots = + u.role === "admin" || u.bots === "all" + ? all + : all.filter((b) => u.bots instanceof Set && u.bots.has(b.id)); res.json({ bots }); }); @@ -30,8 +36,9 @@ export function createBotRouter( }); }); - // POST /api/bot/settings — 保存全局 bot 行为设置 - router.post("/settings", (req, res) => { + // POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot + // behavior is a bot.manage operation, consistent with PR #80's permission model) + router.post("/settings", requirePermission("bot.manage"), (req, res) => { const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body; const hasIdle = idleTimeoutMinutes !== undefined; @@ -58,7 +65,7 @@ export function createBotRouter( }); }); - router.get("/:id", (req, res) => { + router.get("/:id", requireBotAccess("id"), (req, res) => { const bot = botManager.getBot(req.params.id); if (!bot) { res.status(404).json({ error: "Bot not found" }); @@ -68,16 +75,19 @@ export function createBotRouter( }); // Get saved config for a bot - router.get("/:id/config", (req, res) => { + router.get("/:id/config", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => { const saved = botManager.getBotConfig(req.params.id); if (!saved) { res.status(404).json({ error: "Bot config not found" }); return; } - res.json(saved); + // Never expose the TS identity / API key to the client; the edit form only + // consumes channel/server passwords. + const { ts6ApiKey: _ts6ApiKey, identity: _identity, ...safe } = saved as unknown as Record; + res.json(safe); }); - router.get("/:id/avatar", (req, res) => { + router.get("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => { const path = botDb.getCustomAvatarPath(req.params.id); if (!path) { res.status(404).end(); @@ -99,7 +109,7 @@ export function createBotRouter( res.send(buf); }); - router.put("/:id/avatar", (req, res) => { + router.put("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => { const exists = botManager.getBot(req.params.id) || botDb.getBotInstances().some((b) => b.id === req.params.id); @@ -133,7 +143,7 @@ export function createBotRouter( res.json({ path: rel }); }); - router.delete("/:id/avatar", (req, res) => { + router.delete("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => { const path = botDb.getCustomAvatarPath(req.params.id); if (path) avatarStore.remove(path); botDb.setCustomAvatarPath(req.params.id, null); @@ -141,7 +151,7 @@ export function createBotRouter( res.status(204).end(); }); - router.post("/", async (req, res) => { + router.post("/", requirePermission("bot.manage"), async (req, res) => { try { const { name, @@ -177,7 +187,7 @@ export function createBotRouter( }); // Update bot config (must be stopped first to apply connection changes) - router.put("/:id", async (req, res) => { + router.put("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => { try { const bot = botManager.getBot(req.params.id); if (!bot) { @@ -196,7 +206,7 @@ export function createBotRouter( } }); - router.delete("/:id", async (req, res) => { + router.delete("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => { try { await botManager.removeBot(req.params.id); res.json({ success: true }); @@ -205,7 +215,7 @@ export function createBotRouter( } }); - router.post("/:id/start", async (req, res) => { + router.post("/:id/start", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => { try { await botManager.startBot(req.params.id); res.json({ success: true }); @@ -214,7 +224,7 @@ export function createBotRouter( } }); - router.post("/:id/stop", (req, res) => { + router.post("/:id/stop", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => { try { botManager.stopBot(req.params.id); res.json({ success: true }); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index edf9c04..1e8a1a5 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import type { MusicProvider } from "../../music/provider.js"; import { YouTubeProvider } from "../../music/youtube.js"; import type { Logger } from "../../logger.js"; +import { requirePermission } from "../middleware/requirePermission.js"; export function createMusicRouter( neteaseProvider: MusicProvider, @@ -217,7 +218,7 @@ export function createMusicRouter( }); // Set quality - router.post("/quality", (req, res) => { + router.post("/quality", requirePermission("quality"), (req, res) => { const { quality, platform } = req.body; if (!quality) { res.status(400).json({ error: "quality is required" }); diff --git a/src/web/api/permissions-enforcement.test.ts b/src/web/api/permissions-enforcement.test.ts new file mode 100644 index 0000000..42c1174 --- /dev/null +++ b/src/web/api/permissions-enforcement.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createPlayerRouter } from "./player.js"; +import { createBotRouter } from "./bot.js"; +import { createAuthRouter } from "./auth.js"; +import { createMusicRouter } from "./music.js"; + +const logger = pino({ level: "silent" }); + +// --- minimal stubs -------------------------------------------------------- + +const ALLOWED_BOT = "bot-allowed"; + +// A fake bot whose methods all no-op / return benign values so the real +// handlers run to completion without 500ing. We only assert that the +// permission/bot-access gate let the request THROUGH (status !== 403). +function makeFakeBot(id: string) { + return { + id, + executeCommand: async () => "ok", + getStatus: () => ({ id }), + getQueue: () => [], + getProfileManager: () => ({ getConfig: () => ({}), updateConfig: () => {}, setCustomAvatar: () => {} }), + }; +} + +function makeBotManager() { + const bot = makeFakeBot(ALLOWED_BOT); + return { + getBot: (id: string) => (id === ALLOWED_BOT ? bot : undefined), + getAllBots: () => [bot], + getBotConfig: () => undefined, + createBot: async () => bot, + updateBot: () => {}, + removeBot: async () => {}, + startBot: async () => {}, + stopBot: () => {}, + } as any; +} + +function makeProvider() { + return { + platform: "netease", + getQuality: () => "high", + setQuality: () => {}, + getAuthStatus: async () => ({ loggedIn: false }), + getQrCode: async () => ({ key: "k", url: "u" }), + getCookie: () => "c", + setCookie: () => {}, + search: async () => ({ songs: [], albums: [], playlists: [] }), + } as any; +} + +// Build one app mounting all four real routers, with req.user injected by a +// middleware placed BEFORE the routers (mimicking what requireAuth does). +function makeApp(user: any) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + + const botManager = makeBotManager(); + const provider = makeProvider(); + + app.use("/api/player", createPlayerRouter(botManager, logger)); + app.use( + "/api/bot", + createBotRouter( + botManager, + { idleTimeoutMinutes: 0 } as any, + "/tmp/config.json", + logger, + { getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any, + { read: () => null, write: () => "x", remove: () => {} } as any, + ), + ); + app.use("/api/auth", createAuthRouter(provider, provider, provider, logger)); + app.use("/api/music", createMusicRouter(provider, provider, provider, logger)); + + return app; +} + +const member = (caps: string[], bots: "all" | string[]) => ({ + id: "u1", + username: "alice", + role: "member" as const, + capabilities: new Set(caps), + bots: bots === "all" ? ("all" as const) : new Set(bots), +}); + +const admin = { + id: "a", + username: "admin", + role: "admin" as const, + capabilities: new Set(), + bots: "all" as const, +}; + +describe("permission enforcement on action routes", () => { + describe("player.control", () => { + it("403 for member WITHOUT player.control", async () => { + const app = makeApp(member([], [ALLOWED_BOT])); + const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`); + expect(res.status).toBe(403); + }); + + it("NOT 403 for member WITH player.control + bot in allow-list", async () => { + const app = makeApp(member(["player.control"], [ALLOWED_BOT])); + const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`); + expect(res.status).not.toBe(403); + }); + + it("403 for member WITH player.control but bot NOT in allow-list", async () => { + const app = makeApp(member(["player.control"], ["other-bot"])); + const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`); + expect(res.status).toBe(403); + }); + }); + + describe("player.queue", () => { + it("403 for member WITHOUT player.queue", async () => { + const app = makeApp(member(["player.control"], [ALLOWED_BOT])); + const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`); + expect(res.status).toBe(403); + }); + + it("NOT 403 for member WITH player.queue", async () => { + const app = makeApp(member(["player.queue"], [ALLOWED_BOT])); + const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`); + expect(res.status).not.toBe(403); + }); + }); + + describe("bot.manage", () => { + it("403 for member WITHOUT bot.manage on POST /api/bot", async () => { + const app = makeApp(member([], "all")); + const res = await request(app) + .post("/api/bot") + .send({ name: "n", serverAddress: "s", nickname: "nick" }); + expect(res.status).toBe(403); + }); + + it("NOT 403 for member WITH bot.manage on POST /api/bot", async () => { + const app = makeApp(member(["bot.manage"], "all")); + const res = await request(app) + .post("/api/bot") + .send({ name: "n", serverAddress: "s", nickname: "nick" }); + expect(res.status).not.toBe(403); + }); + + it("403 for member WITH bot.manage but bot NOT in allow-list on POST /api/bot/:id/start", async () => { + const app = makeApp(member(["bot.manage"], ["other-bot"])); + const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`); + expect(res.status).toBe(403); + }); + + it("NOT 403 for member WITH bot.manage + bot in allow-list on POST /api/bot/:id/start", async () => { + const app = makeApp(member(["bot.manage"], [ALLOWED_BOT])); + const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`); + expect(res.status).not.toBe(403); + }); + }); + + describe("platform.auth", () => { + it("403 for member WITHOUT platform.auth on POST /api/auth/cookie", async () => { + const app = makeApp(member([], "all")); + const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" }); + expect(res.status).toBe(403); + }); + + it("NOT 403 for member WITH platform.auth on POST /api/auth/cookie", async () => { + const app = makeApp(member(["platform.auth"], "all")); + const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" }); + expect(res.status).not.toBe(403); + }); + }); + + describe("quality", () => { + it("403 for member WITHOUT quality on POST /api/music/quality", async () => { + const app = makeApp(member([], "all")); + const res = await request(app).post("/api/music/quality").send({ quality: "high" }); + expect(res.status).toBe(403); + }); + + it("NOT 403 for member WITH quality on POST /api/music/quality", async () => { + const app = makeApp(member(["quality"], "all")); + const res = await request(app).post("/api/music/quality").send({ quality: "high" }); + expect(res.status).not.toBe(403); + }); + }); + + describe("read-only routes stay open", () => { + it("GET /api/auth/status not gated", async () => { + const app = makeApp(member([], "all")); + const res = await request(app).get("/api/auth/status"); + expect(res.status).not.toBe(403); + }); + + it("GET /api/music/quality not gated", async () => { + const app = makeApp(member([], "all")); + const res = await request(app).get("/api/music/quality"); + expect(res.status).not.toBe(403); + }); + + it("GET /api/bot not gated", async () => { + const app = makeApp(member([], "all")); + const res = await request(app).get("/api/bot"); + expect(res.status).not.toBe(403); + }); + }); + + describe("admin bypasses every gate", () => { + let app: express.Express; + beforeEach(() => { app = makeApp(admin); }); + + it("player.control", async () => { + expect((await request(app).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403); + }); + it("player.queue", async () => { + expect((await request(app).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403); + }); + it("bot.manage POST /api/bot", async () => { + const res = await request(app).post("/api/bot").send({ name: "n", serverAddress: "s", nickname: "nick" }); + expect(res.status).not.toBe(403); + }); + it("bot.manage POST /api/bot/:id/start", async () => { + expect((await request(app).post(`/api/bot/${ALLOWED_BOT}/start`)).status).not.toBe(403); + }); + it("platform.auth POST /api/auth/cookie", async () => { + expect((await request(app).post("/api/auth/cookie").send({ cookie: "c" })).status).not.toBe(403); + }); + it("quality POST /api/music/quality", async () => { + expect((await request(app).post("/api/music/quality").send({ quality: "high" })).status).not.toBe(403); + }); + }); +}); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index b1711fe..607e69b 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -4,6 +4,7 @@ import type { BotDatabase } from "../../data/database.js"; import type { MusicProvider } from "../../music/provider.js"; import type { Logger } from "../../logger.js"; import { parseCommand } from "../../bot/commands.js"; +import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js"; export function createPlayerRouter( botManager: BotManager, @@ -15,6 +16,13 @@ export function createPlayerRouter( ): Router { const router = Router(); + // Access check runs BEFORE the existence/resolver check so a member who is + // not allowed a bot always gets a uniform 403 — whether or not the bot + // exists — instead of a 404 that would leak which bot IDs are real. + // requireBotAccess only needs req.params.botId and req.user (set by the + // global requireAuth mounted earlier), so it works before the resolver. + router.use("/:botId", requireBotAccess("botId")); + router.use("/:botId", (req, res, next) => { const bot = botManager.getBot(req.params.botId); if (!bot) { @@ -33,7 +41,7 @@ export function createPlayerRouter( return ""; }; - router.post("/:botId/play", async (req, res) => { + router.post("/:botId/play", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { query, platform } = req.body; @@ -53,7 +61,7 @@ export function createPlayerRouter( } }); - router.post("/:botId/add", async (req, res) => { + router.post("/:botId/add", requirePermission("player.queue"), async (req, res) => { try { const bot = (req as any).bot; const { query, platform } = req.body; @@ -80,14 +88,14 @@ export function createPlayerRouter( } }; - router.post("/:botId/pause", simpleCommand("!pause")); - router.post("/:botId/resume", simpleCommand("!resume")); - router.post("/:botId/next", simpleCommand("!next")); - router.post("/:botId/prev", simpleCommand("!prev")); - router.post("/:botId/stop", simpleCommand("!stop")); - router.post("/:botId/clear", simpleCommand("!clear")); + router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause")); + router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume")); + router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next")); + router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev")); + router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop")); + router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear")); - router.post("/:botId/fm", async (req, res) => { + router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { platform } = req.body; @@ -109,7 +117,7 @@ export function createPlayerRouter( } }); - router.post("/:botId/volume", async (req, res) => { + router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { volume } = req.body; @@ -137,7 +145,7 @@ export function createPlayerRouter( const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]); - router.post("/:botId/mode", async (req, res) => { + router.post("/:botId/mode", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { mode } = req.body; @@ -162,7 +170,7 @@ export function createPlayerRouter( }); // Seek to position - router.post("/:botId/seek", async (req, res) => { + router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { position } = req.body; // seconds @@ -186,7 +194,7 @@ export function createPlayerRouter( res.json({ queue: bot.getQueue(), status: bot.getStatus() }); }); - router.delete("/:botId/queue/:index", async (req, res) => { + router.delete("/:botId/queue/:index", requirePermission("player.queue"), async (req, res) => { try { const bot = (req as any).bot; const cmd = parseCommand(`!remove ${req.params.index}`, "!")!; @@ -198,7 +206,7 @@ export function createPlayerRouter( }); // Jump to a specific index in the queue (without clearing it) - router.post("/:botId/play-at", async (req, res) => { + router.post("/:botId/play-at", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { index } = req.body; @@ -232,7 +240,7 @@ export function createPlayerRouter( } }); - router.post("/:botId/playlist", async (req, res) => { + router.post("/:botId/playlist", requirePermission("player.queue"), async (req, res) => { try { const bot = (req as any).bot; const { playlistId, platform } = req.body; @@ -249,7 +257,7 @@ export function createPlayerRouter( // Play a playlist by ID — stores metadata only, resolves URL for first song // Respects current play mode (random = pick random first song) - router.post("/:botId/play-playlist", async (req, res) => { + router.post("/:botId/play-playlist", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { playlistId, platform } = req.body; @@ -336,7 +344,7 @@ export function createPlayerRouter( }); // Play an album by ID — mirrors play-playlist but calls getAlbumSongs - router.post("/:botId/play-album", async (req, res) => { + router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { albumId, platform } = req.body; @@ -408,7 +416,7 @@ export function createPlayerRouter( }); // Play a single song by ID — resolves URL on demand - router.post("/:botId/play-song", async (req, res) => { + router.post("/:botId/play-song", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { song } = req.body; @@ -436,7 +444,7 @@ export function createPlayerRouter( // Insert a single song to play right after the current one. // If nothing is playing, behaves like /play-song (start immediately). - router.post("/:botId/play-next-song", async (req, res) => { + router.post("/:botId/play-next-song", requirePermission("player.control"), async (req, res) => { try { const bot = (req as any).bot; const { song } = req.body; @@ -474,7 +482,7 @@ export function createPlayerRouter( } }); - router.post("/:botId/add-song", async (req, res) => { + router.post("/:botId/add-song", requirePermission("player.queue"), async (req, res) => { try { const bot = (req as any).bot; const { song } = req.body; @@ -502,7 +510,7 @@ export function createPlayerRouter( }); // Add a song to queue by ID — metadata only - router.post("/:botId/add-by-id", async (req, res) => { + router.post("/:botId/add-by-id", requirePermission("player.queue"), async (req, res) => { try { const bot = (req as any).bot; const { songId, platform } = req.body; @@ -540,7 +548,7 @@ export function createPlayerRouter( res.json(bot.getProfileManager().getConfig()); }); - router.put("/:botId/profile", (req, res) => { + router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => { try { const bot = (req as any).bot; const pm = bot.getProfileManager(); diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts index a46f081..1a0e76a 100644 --- a/src/web/api/session.test.ts +++ b/src/web/api/session.test.ts @@ -7,6 +7,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore, type UserStore } from "../../data/users.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createAuditStore } from "../../data/audit.js"; +import { createPermissionStore } from "../../data/permissions.js"; import { createSessionRouter } from "./session.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -15,7 +16,8 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { app.use(express.json()); app.use(cookieParser()); const audit = createAuditStore(botDb.db); - app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }))); + const permissions = createPermissionStore(botDb.db); + app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions)); return app; } @@ -100,6 +102,12 @@ describe("session router", () => { const me = await request(app).get("/api/session/me").set("Cookie", cookie); expect(me.status).toBe(200); expect(me.body.username).toBe("alice"); + // alice is the first user (an admin), so /me exposes all capabilities and full bot access. + expect(Array.isArray(me.body.capabilities)).toBe(true); + expect(me.body.capabilities).toEqual( + expect.arrayContaining(["player.control", "player.queue", "bot.manage", "platform.auth", "quality"]) + ); + expect(me.body.bots).toBe("all"); const anon = await request(app).get("/api/session/me"); expect(anon.status).toBe(401); diff --git a/src/web/api/session.ts b/src/web/api/session.ts index 21e8721..85b26b6 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -4,6 +4,7 @@ import type { Logger } from "../../logger.js"; import type { UserStore } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; import type { AuditStore } from "../../data/audit.js"; +import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js"; import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js"; @@ -49,7 +50,8 @@ export function createSessionRouter( users: UserStore, sessions: SessionStore, audit: AuditStore, - logger: Logger + logger: Logger, + permissions: PermissionStore ): Router { const router = Router(); @@ -133,7 +135,15 @@ export function createSessionRouter( }); router.get("/me", requireAuthInline, (req, res) => { - res.json(req.user); + const user = req.user!; + const ctx = resolvePermissionContext(user.role, user.id, permissions); + res.json({ + id: user.id, + username: user.username, + role: user.role, + capabilities: [...ctx.capabilities], + bots: ctx.bots === "all" ? "all" : [...ctx.bots], + }); }); router.post("/change-password", requireAuthInline, async (req, res) => { diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts index 67efd4c..35e7c43 100644 --- a/src/web/api/users.test.ts +++ b/src/web/api/users.test.ts @@ -6,7 +6,8 @@ import pino from "pino"; import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore, type UserStore } from "../../data/users.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js"; -import { createAuditStore } from "../../data/audit.js"; +import { createAuditStore, type AuditStore } from "../../data/audit.js"; +import { createPermissionStore, type PermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { createUsersRouter } from "./users.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -15,11 +16,12 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) { const app = express(); app.use(express.json()); app.use(cookieParser()); - const requireAuth = createRequireAuth(sessions); + const permissions = createPermissionStore(botDb.db); + const requireAuth = createRequireAuth(sessions, permissions); const audit = createAuditStore(botDb.db); app.use("/api", requireAuth); - app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }))); - return app; + app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions)); + return { app, permissions, audit }; } describe("users router", () => { @@ -27,6 +29,8 @@ describe("users router", () => { let users: UserStore; let sessions: SessionStore; let app: express.Express; + let permissions: PermissionStore; + let audit: AuditStore; let aliceId: string; let aliceCookie: string; let bobId: string; @@ -35,7 +39,7 @@ describe("users router", () => { botDb = createDatabase(":memory:"); users = createUserStore(botDb.db); sessions = createSessionStore(botDb.db); - app = makeApp(botDb, users, sessions); + ({ app, permissions, audit } = makeApp(botDb, users, sessions)); const alice = await users.createUser("alice", "pw-alice", "admin"); aliceId = alice.id; aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; @@ -148,10 +152,10 @@ describe("users router", () => { const localApp = express(); localApp.use(express.json()); localApp.use(cookieParser()); - localApp.use("/api", createRequireAuth(sessions)); + localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); localApp.use( "/api/users", - createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" })) + createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db)) ); const res = await request(localApp) .post("/api/users") @@ -254,4 +258,87 @@ describe("users router", () => { .send({ role: "admin" }); expect(res.status).toBe(404); }); + + it("GET /:id/permissions returns empty arrays for a fresh member", async () => { + const res = await request(app) + .get(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual({ capabilities: [], bots: [] }); + }); + + it("GET /:id/permissions 404 on unknown user", async () => { + const res = await request(app) + .get(`/api/users/not-a-real-id/permissions`) + .set("Cookie", aliceCookie); + expect(res.status).toBe(404); + }); + + it("PUT /:id/permissions sets permissions, persists, and audits", async () => { + const before = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed"); + expect(before).toHaveLength(0); + + const put = await request(app) + .put(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie) + .send({ capabilities: ["player.control"], bots: "all" }); + expect(put.status).toBe(200); + + const get = await request(app) + .get(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie); + expect(get.status).toBe(200); + expect(get.body).toEqual({ capabilities: ["player.control"], bots: "all" }); + + const rows = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed"); + expect(rows).toHaveLength(1); + expect(rows[0].actorId).toBe(aliceId); + expect(rows[0].targetUserId).toBe(bobId); + }); + + it("PUT /:id/permissions drops unknown capability tokens", async () => { + const put = await request(app) + .put(`/api/users/${bobId}/permissions`) + .set("Cookie", aliceCookie) + .send({ capabilities: ["player.control", "bogus"], bots: [] }); + expect(put.status).toBe(200); + expect(permissions.getCapabilities(bobId)).toEqual(["player.control"]); + }); + + it("PUT /:id/permissions 404 on unknown user", async () => { + const res = await request(app) + .put(`/api/users/not-a-real-id/permissions`) + .set("Cookie", aliceCookie) + .send({ capabilities: ["player.control"], bots: "all" }); + expect(res.status).toBe(404); + }); + + it("POST / seeds the basic tier for a new member", async () => { + const res = await request(app) + .post("/api/users") + .set("Cookie", aliceCookie) + .send({ username: "dave", password: "dave-pw-pw" }); + expect(res.status).toBe(201); + const perms = await request(app) + .get(`/api/users/${res.body.id}/permissions`) + .set("Cookie", aliceCookie); + expect(perms.status).toBe(200); + expect(perms.body).toEqual({ + capabilities: ["player.control", "player.queue"], + bots: "all", + }); + }); + + it("POST / does NOT seed permissions for a new admin", async () => { + const res = await request(app) + .post("/api/users") + .set("Cookie", aliceCookie) + .send({ username: "erin", password: "erin-pw-pw", role: "admin" }); + expect(res.status).toBe(201); + const perms = await request(app) + .get(`/api/users/${res.body.id}/permissions`) + .set("Cookie", aliceCookie); + expect(perms.status).toBe(200); + expect(perms.body).toEqual({ capabilities: [], bots: [] }); + }); }); diff --git a/src/web/api/users.ts b/src/web/api/users.ts index 0517654..7d38236 100644 --- a/src/web/api/users.ts +++ b/src/web/api/users.ts @@ -4,6 +4,7 @@ import type { UserStore } from "../../data/users.js"; import { UsernameTakenError } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; import type { AuditStore } from "../../data/audit.js"; +import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js"; import { extractSessionToken } from "../auth/validateSession.js"; function isValidUsername(v: unknown): v is string { @@ -18,7 +19,8 @@ export function createUsersRouter( users: UserStore, sessions: SessionStore, audit: AuditStore, - logger: Logger + logger: Logger, + permissions: PermissionStore ): Router { const router = Router(); @@ -35,6 +37,9 @@ export function createUsersRouter( const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member"; try { const u = await users.createUser(username, password, role); + if (u.role === "member") { + permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" }); + } try { audit.record({ actorId: req.user!.id, actorUsername: req.user!.username, @@ -162,5 +167,43 @@ export function createUsersRouter( res.status(204).end(); }); + router.get("/:id/permissions", (req, res) => { + const user = users.findById(req.params.id); + if (!user) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json({ + capabilities: permissions.getCapabilities(user.id), + bots: permissions.getBotAccess(user.id), + }); + }); + + router.put("/:id/permissions", (req, res) => { + const user = users.findById(req.params.id); + if (!user) { + res.status(404).json({ error: "not_found" }); + return; + } + const body = req.body ?? {}; + const caps: string[] = Array.isArray(body.capabilities) + ? body.capabilities.filter(isCapability) + : []; + const bots: "all" | string[] = + body.bots === "all" ? "all" : Array.isArray(body.bots) ? body.bots.map(String) : []; + permissions.setPermissions(user.id, { capabilities: caps, bots }); + try { + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: user.id, targetUsername: user.username, + action: "user.permissions_changed", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "user.permissions_changed" }, "audit insert failed"); + } + logger.info({ actorId: req.user!.id, targetUserId: user.id }, "User permissions changed"); + res.json({ success: true }); + }); + return router; } diff --git a/src/web/middleware/requireAdmin.test.ts b/src/web/middleware/requireAdmin.test.ts index 40ccd02..df020d3 100644 --- a/src/web/middleware/requireAdmin.test.ts +++ b/src/web/middleware/requireAdmin.test.ts @@ -5,6 +5,7 @@ import request from "supertest"; import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore } from "../../data/users.js"; import { createSessionStore } from "../../data/sessions.js"; +import { createPermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "./requireAuth.js"; import { requireAdmin } from "./requireAdmin.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -19,13 +20,14 @@ describe("requireAdmin middleware", () => { botDb = createDatabase(":memory:"); const users = createUserStore(botDb.db); const sessions = createSessionStore(botDb.db); + const permissions = createPermissionStore(botDb.db); const admin = await users.createUser("admin", "pw-admin-pw", "admin"); const member = await users.createUser("member", "pw-member-pw", "member"); adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`; memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`; app = express(); app.use(cookieParser()); - app.use(createRequireAuth(sessions)); + app.use(createRequireAuth(sessions, permissions)); app.use(requireAdmin); app.get("/admin-only", (_req, res) => res.json({ ok: true })); }); diff --git a/src/web/middleware/requireAuth.test.ts b/src/web/middleware/requireAuth.test.ts index 0025bd1..fe9780f 100644 --- a/src/web/middleware/requireAuth.test.ts +++ b/src/web/middleware/requireAuth.test.ts @@ -5,6 +5,7 @@ import request from "supertest"; import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createUserStore } from "../../data/users.js"; import { createSessionStore } from "../../data/sessions.js"; +import { createPermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "./requireAuth.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; @@ -17,12 +18,13 @@ describe("requireAuth middleware", () => { botDb = createDatabase(":memory:"); const users = createUserStore(botDb.db); const sessions = createSessionStore(botDb.db); + const permissions = createPermissionStore(botDb.db); const u = await users.createUser("alice", "pw-alice", "admin"); validToken = sessions.createSession(u.id).token; app = express(); app.use(cookieParser()); - app.use(createRequireAuth(sessions)); + app.use(createRequireAuth(sessions, permissions)); app.get("/protected", (req, res) => { res.json({ ok: true, user: (req as any).user }); }); diff --git a/src/web/middleware/requireAuth.ts b/src/web/middleware/requireAuth.ts index 131235f..6865f21 100644 --- a/src/web/middleware/requireAuth.ts +++ b/src/web/middleware/requireAuth.ts @@ -1,6 +1,7 @@ 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 } from "../../data/permissions.js"; import { validateSessionFromHeaders, extractSessionToken, @@ -9,11 +10,17 @@ import { declare module "express-serve-static-core" { interface Request { - user?: { id: string; username: string; role: "admin" | "member" }; + user?: { + id: string; + username: string; + role: "admin" | "member"; + capabilities?: Set; + bots?: "all" | Set; + }; } } -export function createRequireAuth(sessions: SessionStore): RequestHandler { +export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler { return function requireAuth(req: Request, res: Response, next: NextFunction) { const result = validateSessionFromHeaders(req.headers.cookie, sessions); if (!result) { @@ -21,7 +28,14 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler { res.status(401).json({ error: "unauthenticated" }); return; } - req.user = { id: result.userId, username: result.username, role: result.role }; + const ctx = resolvePermissionContext(result.role, result.userId, permissions); + req.user = { + id: result.userId, + username: result.username, + role: result.role, + capabilities: ctx.capabilities, + bots: ctx.bots, + }; const token = extractSessionToken(req.headers.cookie); if (token) { res.cookie(SESSION_COOKIE_NAME, token, { diff --git a/src/web/middleware/requirePermission.test.ts b/src/web/middleware/requirePermission.test.ts new file mode 100644 index 0000000..db1df2d --- /dev/null +++ b/src/web/middleware/requirePermission.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import { requirePermission, requireBotAccess } from "./requirePermission.js"; + +function appWith(user: any) { + const app = express(); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true })); + app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true })); + return app; +} + +const member = (caps: string[], bots: "all" | string[]) => ({ + id: "u1", username: "a", role: "member", + capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots), +}); +const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" }; + +describe("requirePermission", () => { + it("401 when unauthenticated", async () => { + const app = express(); + app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true })); + expect((await request(app).post("/cap")).status).toBe(401); + }); + it("403 when member lacks the capability", async () => { + expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403); + }); + it("200 when member has the capability", async () => { + expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200); + }); + it("200 for admin regardless of capabilities", async () => { + expect((await request(appWith(admin)).post("/cap")).status).toBe(200); + }); +}); + +describe("requireBotAccess", () => { + it("200 when bots = all", async () => { + expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200); + }); + it("200 when botId in allow-list", async () => { + expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200); + }); + it("403 when botId not in allow-list", async () => { + expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403); + }); + it("200 for admin", async () => { + expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200); + }); + it("401 when unauthenticated", async () => { + const app = express(); + app.post("/bot/:botId", requireBotAccess("botId"), (_r, res) => res.json({ ok: true })); + expect((await request(app).post("/bot/b1")).status).toBe(401); + }); + it("403 when the route param is absent", async () => { + const app = express(); + app.use((req, _res, next) => { (req as any).user = member([], ["b1"]); next(); }); + app.post("/bot/:botId", requireBotAccess("nope"), (_r, res) => res.json({ ok: true })); + expect((await request(app).post("/bot/b1")).status).toBe(403); + }); +}); diff --git a/src/web/middleware/requirePermission.ts b/src/web/middleware/requirePermission.ts new file mode 100644 index 0000000..831c734 --- /dev/null +++ b/src/web/middleware/requirePermission.ts @@ -0,0 +1,24 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; + +// Generic over the route-param shape (`P`) so Express can keep inferring +// `req.params` from the route string (e.g. `/:id` → `{ id: string }`) when +// these are passed as a per-route middleware argument. Pinning the default +// `ParamsDictionary` here would otherwise force the broad +// `string | string[]` param overload on every route they guard. +export function requirePermission

>(capability: string): RequestHandler

{ + return (req: Request

, res: Response, next: NextFunction) => { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "admin" || req.user.capabilities?.has(capability)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +} + +export function requireBotAccess

>(paramName = "botId"): RequestHandler

{ + return (req: Request

, res: Response, next: NextFunction) => { + if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; } + if (req.user.role === "admin" || req.user.bots === "all") { next(); return; } + const botId = (req.params as Record)[paramName]; + if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; } + res.status(403).json({ error: "forbidden" }); + }; +} diff --git a/src/web/server.ts b/src/web/server.ts index 2cf611b..e89e5bf 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; +import { createPermissionStore } from "../data/permissions.js"; import { createRequireAuth } from "./middleware/requireAuth.js"; import { requireAdmin } from "./middleware/requireAdmin.js"; import { csrfOriginCheck } from "./middleware/csrf.js"; @@ -74,6 +75,7 @@ export function createWebServer(options: WebServerOptions): WebServer { const users = createUserStore(options.database.db); const sessions = createSessionStore(options.database.db); const audit = createAuditStore(options.database.db); + const permissions = createPermissionStore(options.database.db); // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── app.get("/api/health", (_req, res) => { @@ -93,10 +95,10 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use("/api/session/login", loginLimit); app.use("/api/session/setup", setupLimit); - app.use("/api/session", createSessionRouter(users, sessions, audit, logger)); + app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions)); // ─── Gates for everything else under /api ─────────────────────────────── - const requireAuth = createRequireAuth(sessions); + const requireAuth = createRequireAuth(sessions, permissions); app.use("/api", csrfOriginCheck); app.use("/api", requireAuth); @@ -127,7 +129,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use("/api/favorites", createFavoritesRouter(options.database, logger)); // admin-only routes - app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger)); + app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); app.use("/api/audit", requireAdmin, createAuditRouter(audit)); // ─── Static SPA (public) ──────────────────────────────────────────────── diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index bd05bd0..502c3cf 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -22,8 +22,9 @@ - -

+ +
- - - + + {{ formatTime(currentSong?.duration ?? 0) }}
- - + + @@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'; import { Icon } from '@iconify/vue'; import { useRoute, useRouter } from 'vue-router'; import { usePlayerStore } from '../stores/player.js'; +import { useSession } from '../composables/useSession.js'; import CoverArt from './CoverArt.vue'; import Queue from './Queue.vue'; @@ -86,6 +94,9 @@ const route = useRoute(); const router = useRouter(); const showQueue = ref(false); +const { can } = useSession(); +const canControl = computed(() => can('player.control')); + const store = usePlayerStore(); const activeBot = computed(() => store.activeBot); const currentSong = computed(() => store.currentSong); @@ -133,6 +144,7 @@ function updateProgress() { } async function onProgressClick(e: MouseEvent) { + if (!canControl.value) return; // seek requires player.control const bar = progressBarRef.value; if (!bar) return; const rect = bar.getBoundingClientRect(); @@ -237,6 +249,14 @@ function cycleMode() { .progress-bar-bg { height: 4px; } .progress-bar-thumb { opacity: 1; transform: scale(1); } } + + &.no-seek { + cursor: default; + &:hover { + .progress-bar-bg { height: 2px; } + .progress-bar-thumb { opacity: 0; transform: scale(0); } + } + } } .progress-bar-bg { diff --git a/web/src/components/Queue.vue b/web/src/components/Queue.vue index a6a3c04..e7ed692 100644 --- a/web/src/components/Queue.vue +++ b/web/src/components/Queue.vue @@ -3,10 +3,10 @@

播放队列

{{ botQueue.length }} 首 -
-
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue'; import { Icon } from '@iconify/vue'; import axios from 'axios'; import { usePlayerStore } from '../stores/player.js'; +import { useSession } from '../composables/useSession.js'; import CoverArt from './CoverArt.vue'; const props = defineProps<{ @@ -57,6 +58,7 @@ defineEmits<{ }>(); const store = usePlayerStore(); +const { can } = useSession(); const botQueue = computed(() => store.queue); // Fetch queue when panel opens @@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => { }); async function playAtIndex(index: number) { + if (!can('player.control')) return; await store.playAtIndex(index); await store.fetchQueue(); } diff --git a/web/src/composables/useSession.ts b/web/src/composables/useSession.ts index e8c17ff..725184e 100644 --- a/web/src/composables/useSession.ts +++ b/web/src/composables/useSession.ts @@ -4,6 +4,8 @@ interface User { id: string; username: string; role: 'admin' | 'member'; + capabilities?: string[]; + bots?: "all" | string[]; } const currentUser = ref(null); @@ -70,6 +72,8 @@ async function login(username: string, password: string): Promise { throw new Error(body.error ?? `login failed (${res.status})`); } currentUser.value = (await res.json()) as User; + // Login response omits capabilities/bots; fetch the authoritative ones from /me. + await refreshMe(); } async function setup(username: string, password: string): Promise { @@ -85,6 +89,8 @@ async function setup(username: string, password: string): Promise { } currentUser.value = (await res.json()) as User; needsSetup.value = false; + // Setup response omits capabilities/bots; fetch the authoritative ones from /me. + await refreshMe(); } async function logout(): Promise { @@ -93,6 +99,18 @@ async function logout(): Promise { currentUser.value = null; } +function can(cap: string): boolean { + const u = currentUser.value; + return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap)); +} + +function canControlBot(botId: string): boolean { + const u = currentUser.value; + if (!u) return false; + if (u.role === "admin" || u.bots === "all") return true; + return Array.isArray(u.bots) && u.bots.includes(botId); +} + export function useSession() { return { currentUser: readonly(currentUser), @@ -104,5 +122,7 @@ export function useSession() { login, logout, setup, + can, + canControlBot, }; } diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 2e9f887..5704be8 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -50,8 +50,8 @@

{{ ownPwSuccess }}

- -
+ +

机器人管理

@@ -157,8 +157,8 @@
- -
+ +

音乐账号

@@ -371,8 +371,8 @@
- -
+ +

音质设置

@@ -410,7 +410,7 @@
-
+

行为设置

@@ -448,7 +448,7 @@
-
+

机器人 Profile(TeamSpeak 行为)

控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。

还没有机器人,先在上面创建一个。
@@ -504,38 +504,96 @@

用户管理

-
-