Merge PR #80: feat(perm) fine-grained account permissions

Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
This commit is contained in:
saopig1 committed 2026-06-16 15:05:57 +08:00
commit bea2f92508
32 files changed
+2222 -134

No files matched your search

@@ -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 <path>`. 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<string>;
bots: "all" | Set<string>;
};
}
}
```
(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<string>;
bots: "all" | Set<string>;
}
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<string>).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.
@@ -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<string>` 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.
+6 -1
View File
@@ -12,6 +12,7 @@ import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js"; import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.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 * 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 config: BotConfig;
private logger: Logger; private logger: Logger;
private avatarStore: AvatarStore; private avatarStore: AvatarStore;
private permissions: PermissionStore;
constructor( constructor(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
@@ -84,7 +86,8 @@ export class BotManager extends EventEmitter {
database: BotDatabase, database: BotDatabase,
config: BotConfig, config: BotConfig,
logger: Logger, logger: Logger,
avatarStore: AvatarStore avatarStore: AvatarStore,
permissions: PermissionStore
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -95,6 +98,7 @@ export class BotManager extends EventEmitter {
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
this.avatarStore = avatarStore; this.avatarStore = avatarStore;
this.permissions = permissions;
} }
async createBot(params: CreateBotParams): Promise<BotInstance> { async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -152,6 +156,7 @@ export class BotManager extends EventEmitter {
this.bots.delete(id); this.bots.delete(id);
} }
this.database.deleteBotInstance(id); this.database.deleteBotInstance(id);
this.permissions.pruneBot(id);
this.emit("botInstanceRemoved", id); this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed"); this.logger.info({ botId: id }, "Bot instance removed");
} }
+2 -1
View File
@@ -6,7 +6,8 @@ export type AuditAction =
| "user.deleted" | "user.deleted"
| "user.password_reset" | "user.password_reset"
| "user.password_changed" | "user.password_changed"
| "user.role_changed"; | "user.role_changed"
| "user.permissions_changed";
export interface AuditEntry { export interface AuditEntry {
id: number; id: number;
+40
View File
@@ -1,4 +1,5 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
export interface PlayHistoryEntry { export interface PlayHistoryEntry {
botId: string; botId: string;
@@ -194,15 +195,54 @@ function initTables(db: Database.Database): void {
UNIQUE(userId, platform, playlistId) UNIQUE(userId, platform, playlistId)
); );
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId); 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 { export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON"); db.pragma("foreign_keys = ON");
initTables(db); initTables(db);
migrateSchema(db); migrateSchema(db);
backfillMemberPermissions(db);
const insertHistory = db.prepare(` const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
+58
View File
@@ -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([]);
});
});
+90
View File
@@ -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"]));
});
});
});
+89
View File
@@ -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<string>;
bots: "all" | Set<string>;
}
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),
};
}
+5 -1
View File
@@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js"; import { BiliBiliProvider } from "./music/bilibili.js";
import { createCookieStore } from "./music/auth.js"; import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js"; import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
import { BotManager } from "./bot/manager.js"; import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js"; import { createWebServer } from "./web/server.js";
@@ -56,6 +57,8 @@ async function main() {
const bilibiliCookie = cookieStore.load("bilibili"); const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie); if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const permissions = createPermissionStore(db.db);
const botManager = new BotManager( const botManager = new BotManager(
neteaseProvider, neteaseProvider,
qqProvider, qqProvider,
@@ -63,7 +66,8 @@ async function main() {
db, db,
config, config,
logger, logger,
avatarStore avatarStore,
permissions
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
+3 -1
View File
@@ -6,6 +6,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js"; import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js"; import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js"; import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js"; import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -20,6 +21,7 @@ describe("audit router", () => {
const users = createUserStore(botDb.db); const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db); const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db); const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin"); const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
@@ -32,7 +34,7 @@ describe("audit router", () => {
app = express(); app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use("/api", createRequireAuth(sessions)); app.use("/api", createRequireAuth(sessions, permissions));
app.use("/api/audit", createAuditRouter(audit)); app.use("/api/audit", createAuditRouter(audit));
}); });
+5 -4
View File
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js"; import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js"; import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js";
export function createAuthRouter( export function createAuthRouter(
neteaseProvider: MusicProvider, 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 { try {
const { platform } = req.body; const { platform } = req.body;
const provider = getProvider(platform); 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 { try {
const { phone } = req.body; const { phone } = req.body;
if (!phone) { 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 { try {
const { phone, code } = req.body; const { phone, code } = req.body;
if (!phone || !code) { 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; const { platform, cookie } = req.body;
if (!cookie) { if (!cookie) {
res.status(400).json({ error: "cookie is required" }); res.status(400).json({ error: "cookie is required" });
+90
View File
@@ -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<string>(),
bots: bots === "all" ? ("all" as const) : new Set(bots),
});
const admin = {
id: "a",
username: "admin",
role: "admin" as const,
capabilities: new Set<string>(),
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"]);
});
});
+2 -1
View File
@@ -11,6 +11,7 @@ import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js"; import { createSessionStore } from "../../data/sessions.js";
import { createAvatarStore } from "../../data/avatars.js"; import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js"; import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js"; import { getDefaultConfig, type BotConfig } from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -59,7 +60,7 @@ describe("bot router /settings", () => {
app = express(); app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use("/api", createRequireAuth(sessions)); app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
app.use( app.use(
"/api/bot", "/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore), createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
+25 -15
View File
@@ -5,6 +5,7 @@ import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js"; import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js"; import type { AvatarStore } from "../../data/avatars.js";
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
export function createBotRouter( export function createBotRouter(
botManager: BotManager, botManager: BotManager,
@@ -16,8 +17,13 @@ export function createBotRouter(
): Router { ): Router {
const router = Router(); const router = Router();
router.get("/", (_req, res) => { router.get("/", (req, res) => {
const bots = botManager.getAllBots().map((b) => b.getStatus()); 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 }); res.json({ bots });
}); });
@@ -30,8 +36,9 @@ export function createBotRouter(
}); });
}); });
// POST /api/bot/settings — 保存全局 bot 行为设置 // POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
router.post("/settings", (req, res) => { // 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 { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
const hasIdle = idleTimeoutMinutes !== undefined; 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); const bot = botManager.getBot(req.params.id);
if (!bot) { if (!bot) {
res.status(404).json({ error: "Bot not found" }); res.status(404).json({ error: "Bot not found" });
@@ -68,16 +75,19 @@ export function createBotRouter(
}); });
// Get saved config for a bot // 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); const saved = botManager.getBotConfig(req.params.id);
if (!saved) { if (!saved) {
res.status(404).json({ error: "Bot config not found" }); res.status(404).json({ error: "Bot config not found" });
return; 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<string, unknown>;
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); const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) { if (!path) {
res.status(404).end(); res.status(404).end();
@@ -99,7 +109,7 @@ export function createBotRouter(
res.send(buf); res.send(buf);
}); });
router.put("/:id/avatar", (req, res) => { router.put("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const exists = const exists =
botManager.getBot(req.params.id) || botManager.getBot(req.params.id) ||
botDb.getBotInstances().some((b) => b.id === req.params.id); botDb.getBotInstances().some((b) => b.id === req.params.id);
@@ -133,7 +143,7 @@ export function createBotRouter(
res.json({ path: rel }); 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); const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path); if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null); botDb.setCustomAvatarPath(req.params.id, null);
@@ -141,7 +151,7 @@ export function createBotRouter(
res.status(204).end(); res.status(204).end();
}); });
router.post("/", async (req, res) => { router.post("/", requirePermission("bot.manage"), async (req, res) => {
try { try {
const { const {
name, name,
@@ -177,7 +187,7 @@ export function createBotRouter(
}); });
// Update bot config (must be stopped first to apply connection changes) // 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 { try {
const bot = botManager.getBot(req.params.id); const bot = botManager.getBot(req.params.id);
if (!bot) { 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 { try {
await botManager.removeBot(req.params.id); await botManager.removeBot(req.params.id);
res.json({ success: true }); 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 { try {
await botManager.startBot(req.params.id); await botManager.startBot(req.params.id);
res.json({ success: true }); 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 { try {
botManager.stopBot(req.params.id); botManager.stopBot(req.params.id);
res.json({ success: true }); res.json({ success: true });
+2 -1
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js"; import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js"; import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js";
export function createMusicRouter( export function createMusicRouter(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
@@ -217,7 +218,7 @@ export function createMusicRouter(
}); });
// Set quality // Set quality
router.post("/quality", (req, res) => { router.post("/quality", requirePermission("quality"), (req, res) => {
const { quality, platform } = req.body; const { quality, platform } = req.body;
if (!quality) { if (!quality) {
res.status(400).json({ error: "quality is required" }); res.status(400).json({ error: "quality is required" });
+237
View File
@@ -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<string>(),
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);
});
});
});
+30 -22
View File
@@ -4,6 +4,7 @@ import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js"; import type { MusicProvider } from "../../music/provider.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js"; import { parseCommand } from "../../bot/commands.js";
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
export function createPlayerRouter( export function createPlayerRouter(
botManager: BotManager, botManager: BotManager,
@@ -15,6 +16,13 @@ export function createPlayerRouter(
): Router { ): Router {
const router = 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) => { router.use("/:botId", (req, res, next) => {
const bot = botManager.getBot(req.params.botId); const bot = botManager.getBot(req.params.botId);
if (!bot) { if (!bot) {
@@ -33,7 +41,7 @@ export function createPlayerRouter(
return ""; return "";
}; };
router.post("/:botId/play", async (req, res) => { router.post("/:botId/play", requirePermission("player.control"), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { query, platform } = req.body; 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { query, platform } = req.body; const { query, platform } = req.body;
@@ -80,14 +88,14 @@ export function createPlayerRouter(
} }
}; };
router.post("/:botId/pause", simpleCommand("!pause")); router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause"));
router.post("/:botId/resume", simpleCommand("!resume")); router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume"));
router.post("/:botId/next", simpleCommand("!next")); router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next"));
router.post("/:botId/prev", simpleCommand("!prev")); router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev"));
router.post("/:botId/stop", simpleCommand("!stop")); router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
router.post("/:botId/clear", simpleCommand("!clear")); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { platform } = req.body; 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { volume } = req.body; const { volume } = req.body;
@@ -137,7 +145,7 @@ export function createPlayerRouter(
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { mode } = req.body; const { mode } = req.body;
@@ -162,7 +170,7 @@ export function createPlayerRouter(
}); });
// Seek to position // Seek to position
router.post("/:botId/seek", async (req, res) => { router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { position } = req.body; // seconds const { position } = req.body; // seconds
@@ -186,7 +194,7 @@ export function createPlayerRouter(
res.json({ queue: bot.getQueue(), status: bot.getStatus() }); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!; 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) // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { index } = req.body; 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; 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 // Play a playlist by ID — stores metadata only, resolves URL for first song
// Respects current play mode (random = pick random 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; const { playlistId, platform } = req.body;
@@ -336,7 +344,7 @@ export function createPlayerRouter(
}); });
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { albumId, platform } = req.body; const { albumId, platform } = req.body;
@@ -408,7 +416,7 @@ export function createPlayerRouter(
}); });
// Play a single song by ID — resolves URL on demand // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { song } = req.body; const { song } = req.body;
@@ -436,7 +444,7 @@ export function createPlayerRouter(
// Insert a single song to play right after the current one. // Insert a single song to play right after the current one.
// If nothing is playing, behaves like /play-song (start immediately). // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { song } = req.body; 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { song } = req.body; const { song } = req.body;
@@ -502,7 +510,7 @@ export function createPlayerRouter(
}); });
// Add a song to queue by ID — metadata only // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { songId, platform } = req.body; const { songId, platform } = req.body;
@@ -540,7 +548,7 @@ export function createPlayerRouter(
res.json(bot.getProfileManager().getConfig()); res.json(bot.getProfileManager().getConfig());
}); });
router.put("/:botId/profile", (req, res) => { router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const pm = bot.getProfileManager(); const pm = bot.getProfileManager();
+9 -1
View File
@@ -7,6 +7,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js"; import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js"; import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createSessionRouter } from "./session.js"; import { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.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(express.json());
app.use(cookieParser()); app.use(cookieParser());
const audit = createAuditStore(botDb.db); 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; return app;
} }
@@ -100,6 +102,12 @@ describe("session router", () => {
const me = await request(app).get("/api/session/me").set("Cookie", cookie); const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(200); expect(me.status).toBe(200);
expect(me.body.username).toBe("alice"); 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"); const anon = await request(app).get("/api/session/me");
expect(anon.status).toBe(401); expect(anon.status).toBe(401);
+12 -2
View File
@@ -4,6 +4,7 @@ import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js"; import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.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_TTL_MS } from "../../data/sessions.js";
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
@@ -49,7 +50,8 @@ export function createSessionRouter(
users: UserStore, users: UserStore,
sessions: SessionStore, sessions: SessionStore,
audit: AuditStore, audit: AuditStore,
logger: Logger logger: Logger,
permissions: PermissionStore
): Router { ): Router {
const router = Router(); const router = Router();
@@ -133,7 +135,15 @@ export function createSessionRouter(
}); });
router.get("/me", requireAuthInline, (req, res) => { 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) => { router.post("/change-password", requireAuthInline, async (req, res) => {
+94 -7
View File
@@ -6,7 +6,8 @@ import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js"; import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.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 { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js"; import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -15,11 +16,12 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
const requireAuth = createRequireAuth(sessions); const permissions = createPermissionStore(botDb.db);
const requireAuth = createRequireAuth(sessions, permissions);
const audit = createAuditStore(botDb.db); const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth); app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }))); app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
return app; return { app, permissions, audit };
} }
describe("users router", () => { describe("users router", () => {
@@ -27,6 +29,8 @@ describe("users router", () => {
let users: UserStore; let users: UserStore;
let sessions: SessionStore; let sessions: SessionStore;
let app: express.Express; let app: express.Express;
let permissions: PermissionStore;
let audit: AuditStore;
let aliceId: string; let aliceId: string;
let aliceCookie: string; let aliceCookie: string;
let bobId: string; let bobId: string;
@@ -35,7 +39,7 @@ describe("users router", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
users = createUserStore(botDb.db); users = createUserStore(botDb.db);
sessions = createSessionStore(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"); const alice = await users.createUser("alice", "pw-alice", "admin");
aliceId = alice.id; aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`; aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
@@ -148,10 +152,10 @@ describe("users router", () => {
const localApp = express(); const localApp = express();
localApp.use(express.json()); localApp.use(express.json());
localApp.use(cookieParser()); localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions)); localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
localApp.use( localApp.use(
"/api/users", "/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" })) createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
); );
const res = await request(localApp) const res = await request(localApp)
.post("/api/users") .post("/api/users")
@@ -254,4 +258,87 @@ describe("users router", () => {
.send({ role: "admin" }); .send({ role: "admin" });
expect(res.status).toBe(404); 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: [] });
});
}); });
+44 -1
View File
@@ -4,6 +4,7 @@ import type { UserStore } from "../../data/users.js";
import { UsernameTakenError } from "../../data/users.js"; import { UsernameTakenError } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.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"; import { extractSessionToken } from "../auth/validateSession.js";
function isValidUsername(v: unknown): v is string { function isValidUsername(v: unknown): v is string {
@@ -18,7 +19,8 @@ export function createUsersRouter(
users: UserStore, users: UserStore,
sessions: SessionStore, sessions: SessionStore,
audit: AuditStore, audit: AuditStore,
logger: Logger logger: Logger,
permissions: PermissionStore
): Router { ): Router {
const router = Router(); const router = Router();
@@ -35,6 +37,9 @@ export function createUsersRouter(
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member"; const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
try { try {
const u = await users.createUser(username, password, role); const u = await users.createUser(username, password, role);
if (u.role === "member") {
permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
try { try {
audit.record({ audit.record({
actorId: req.user!.id, actorUsername: req.user!.username, actorId: req.user!.id, actorUsername: req.user!.username,
@@ -162,5 +167,43 @@ export function createUsersRouter(
res.status(204).end(); 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; return router;
} }
+3 -1
View File
@@ -5,6 +5,7 @@ import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js"; import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js"; import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js"; import { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js"; import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -19,13 +20,14 @@ describe("requireAdmin middleware", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db); const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db); const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin-pw", "admin"); const admin = await users.createUser("admin", "pw-admin-pw", "admin");
const member = await users.createUser("member", "pw-member-pw", "member"); const member = await users.createUser("member", "pw-member-pw", "member");
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`; adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`; memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express(); app = express();
app.use(cookieParser()); app.use(cookieParser());
app.use(createRequireAuth(sessions)); app.use(createRequireAuth(sessions, permissions));
app.use(requireAdmin); app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true })); app.get("/admin-only", (_req, res) => res.json({ ok: true }));
}); });
+3 -1
View File
@@ -5,6 +5,7 @@ import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js"; import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js"; import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js"; import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js"; import { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -17,12 +18,13 @@ describe("requireAuth middleware", () => {
botDb = createDatabase(":memory:"); botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db); const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db); const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin"); const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token; validToken = sessions.createSession(u.id).token;
app = express(); app = express();
app.use(cookieParser()); app.use(cookieParser());
app.use(createRequireAuth(sessions)); app.use(createRequireAuth(sessions, permissions));
app.get("/protected", (req, res) => { app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user }); res.json({ ok: true, user: (req as any).user });
}); });
+17 -3
View File
@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js"; import { SESSION_TTL_MS } from "../../data/sessions.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import { import {
validateSessionFromHeaders, validateSessionFromHeaders,
extractSessionToken, extractSessionToken,
@@ -9,11 +10,17 @@ import {
declare module "express-serve-static-core" { declare module "express-serve-static-core" {
interface Request { interface Request {
user?: { id: string; username: string; role: "admin" | "member" }; user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities?: Set<string>;
bots?: "all" | Set<string>;
};
} }
} }
export function createRequireAuth(sessions: SessionStore): RequestHandler { export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) { return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions); const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) { if (!result) {
@@ -21,7 +28,14 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler {
res.status(401).json({ error: "unauthenticated" }); res.status(401).json({ error: "unauthenticated" });
return; 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); const token = extractSessionToken(req.headers.cookie);
if (token) { if (token) {
res.cookie(SESSION_COOKIE_NAME, token, { res.cookie(SESSION_COOKIE_NAME, token, {
@@ -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);
});
});
+24
View File
@@ -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<P = Record<string, string>>(capability: string): RequestHandler<P> {
return (req: Request<P>, 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<P = Record<string, string>>(paramName = "botId"): RequestHandler<P> {
return (req: Request<P>, 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<string, string | undefined>)[paramName];
if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
+5 -3
View File
@@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js";
import { setupWebSocket } from "./websocket.js"; import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js"; import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js"; import { createSessionStore } from "../data/sessions.js";
import { createPermissionStore } from "../data/permissions.js";
import { createRequireAuth } from "./middleware/requireAuth.js"; import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js"; import { requireAdmin } from "./middleware/requireAdmin.js";
import { csrfOriginCheck } from "./middleware/csrf.js"; import { csrfOriginCheck } from "./middleware/csrf.js";
@@ -74,6 +75,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
const users = createUserStore(options.database.db); const users = createUserStore(options.database.db);
const sessions = createSessionStore(options.database.db); const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db); const audit = createAuditStore(options.database.db);
const permissions = createPermissionStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ─────────────────────────────────── // ─── Public routes (no auth, no CSRF) ───────────────────────────────────
app.get("/api/health", (_req, res) => { 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/login", loginLimit);
app.use("/api/session/setup", setupLimit); 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 ─────────────────────────────── // ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions); const requireAuth = createRequireAuth(sessions, permissions);
app.use("/api", csrfOriginCheck); app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth); app.use("/api", requireAuth);
@@ -127,7 +129,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use("/api/favorites", createFavoritesRouter(options.database, logger)); app.use("/api/favorites", createFavoritesRouter(options.database, logger));
// admin-only routes // 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)); app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// ─── Static SPA (public) ──────────────────────────────────────────────── // ─── Static SPA (public) ────────────────────────────────────────────────
+15 -5
View File
@@ -22,8 +22,9 @@
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button> <button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
</div> </div>
<!-- Normal: full selector with switching (shown when at least one bot exists) --> <!-- Normal: full selector with switching (shown when at least one
<div v-else-if="store.bots.length > 0" class="bot-selector" ref="selectorRef"> controllable bot exists — scope ∩ permission via displayedBots) -->
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen"> <button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" /> <span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span> <span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
@@ -143,17 +144,26 @@ import { useSession } from '../composables/useSession.js';
const store = usePlayerStore(); const store = usePlayerStore();
const session = useSession(); const session = useSession();
const { canControlBot } = session;
const navRouter = useRouter(); const navRouter = useRouter();
async function onLogout() { async function onLogout() {
await session.logout(); await session.logout();
navRouter.replace({ name: 'login' }); navRouter.replace({ name: 'login' });
} }
// Belt-and-suspenders: the backend already scopes store.bots to the allowed
// set for members, but filtering here keeps the UI correct if an admin (who
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
const activeBot = computed(() => store.activeBot); const activeBot = computed(() => store.activeBot);
// While scoped (dedicated link), the selector is locked to the single scoped // The bots shown in the selector are the INTERSECTION of the permission
// bot; otherwise the full list is shown and switching is allowed. // allow-list (controllableBots) and the dedicated-link scope: while scoped the
// selector is locked to the single scoped bot, otherwise the full controllable
// list is shown and switching is allowed.
const displayedBots = computed(() => const displayedBots = computed(() =>
store.isScoped ? store.bots.filter((b) => b.id === store.scopedBotId) : store.bots, store.isScoped
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
: controllableBots.value,
); );
const dropdownOpen = ref(false); const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null); const selectorRef = ref<HTMLElement | null>(null);
+43 -23
View File
@@ -3,9 +3,10 @@
<Queue :open="showQueue" @close="showQueue = false" /> <Queue :open="showQueue" @close="showQueue = false" />
<div class="player-bar frosted-glass"> <div class="player-bar frosted-glass">
<!-- Progress bar --> <!-- Progress bar (read-only display; seek interaction gated on player.control) -->
<div <div
class="progress-bar-container" class="progress-bar-container"
:class="{ 'no-seek': !canControl }"
ref="progressBarRef" ref="progressBarRef"
@click="onProgressClick" @click="onProgressClick"
@mousemove="onProgressHover" @mousemove="onProgressHover"
@@ -37,32 +38,38 @@
<div class="player-center"> <div class="player-center">
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span> <span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
<button class="control-btn" @click="store.prev()"> <!-- Transport controls require player.control -->
<Icon icon="mdi:skip-previous" /> <template v-if="canControl">
</button> <button class="control-btn" @click="store.prev()">
<button class="play-btn" @click="togglePlay"> <Icon icon="mdi:skip-previous" />
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" /> </button>
</button> <button class="play-btn" @click="togglePlay">
<button class="control-btn" @click="store.next()"> <Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
<Icon icon="mdi:skip-next" /> </button>
</button> <button class="control-btn" @click="store.next()">
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel"> <Icon icon="mdi:skip-next" />
<Icon :icon="modeIcon" /> </button>
<span class="mode-label">{{ modeLabel }}</span> <button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
</button> <Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
</template>
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span> <span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
</div> </div>
<div class="player-right"> <div class="player-right">
<Icon icon="mdi:volume-high" class="volume-icon" /> <!-- Volume requires player.control -->
<input <template v-if="canControl">
type="range" <Icon icon="mdi:volume-high" class="volume-icon" />
min="0" <input
max="100" type="range"
:value="activeBot?.volume ?? 75" min="0"
@change="onVolumeChange" max="100"
class="volume-slider" :value="activeBot?.volume ?? 75"
/> @change="onVolumeChange"
class="volume-slider"
/>
</template>
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue"> <button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
<Icon icon="mdi:playlist-music" /> <Icon icon="mdi:playlist-music" />
</button> </button>
@@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue'; import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue'; import Queue from './Queue.vue';
@@ -86,6 +94,9 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const showQueue = ref(false); const showQueue = ref(false);
const { can } = useSession();
const canControl = computed(() => can('player.control'));
const store = usePlayerStore(); const store = usePlayerStore();
const activeBot = computed(() => store.activeBot); const activeBot = computed(() => store.activeBot);
const currentSong = computed(() => store.currentSong); const currentSong = computed(() => store.currentSong);
@@ -133,6 +144,7 @@ function updateProgress() {
} }
async function onProgressClick(e: MouseEvent) { async function onProgressClick(e: MouseEvent) {
if (!canControl.value) return; // seek requires player.control
const bar = progressBarRef.value; const bar = progressBarRef.value;
if (!bar) return; if (!bar) return;
const rect = bar.getBoundingClientRect(); const rect = bar.getBoundingClientRect();
@@ -237,6 +249,14 @@ function cycleMode() {
.progress-bar-bg { height: 4px; } .progress-bar-bg { height: 4px; }
.progress-bar-thumb { opacity: 1; transform: scale(1); } .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 { .progress-bar-bg {
+5 -2
View File
@@ -4,7 +4,7 @@
<h3 class="queue-title">播放队列</h3> <h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span> <span class="queue-count">{{ botQueue.length }} 首</span>
<button <button
v-if="botQueue.length > 0" v-if="botQueue.length > 0 && can('player.control')"
class="clear-btn" class="clear-btn"
@click="clearAndStop" @click="clearAndStop"
title="清空队列并停止播放" title="清空队列并停止播放"
@@ -33,7 +33,7 @@
<div class="queue-song-name">{{ song.name }}</div> <div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div> <div class="queue-song-artist">{{ song.artist }}</div>
</div> </div>
<button class="remove-btn" @click="removeSong(i)" title="移除"> <button v-if="can('player.queue')" class="remove-btn" @click="removeSong(i)" title="移除">
<Icon icon="mdi:close" /> <Icon icon="mdi:close" />
</button> </button>
</div> </div>
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue'; import CoverArt from './CoverArt.vue';
const props = defineProps<{ const props = defineProps<{
@@ -57,6 +58,7 @@ defineEmits<{
}>(); }>();
const store = usePlayerStore(); const store = usePlayerStore();
const { can } = useSession();
const botQueue = computed(() => store.queue); const botQueue = computed(() => store.queue);
// Fetch queue when panel opens // Fetch queue when panel opens
@@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => {
}); });
async function playAtIndex(index: number) { async function playAtIndex(index: number) {
if (!can('player.control')) return;
await store.playAtIndex(index); await store.playAtIndex(index);
await store.fetchQueue(); await store.fetchQueue();
} }
+20
View File
@@ -4,6 +4,8 @@ interface User {
id: string; id: string;
username: string; username: string;
role: 'admin' | 'member'; role: 'admin' | 'member';
capabilities?: string[];
bots?: "all" | string[];
} }
const currentUser = ref<User | null>(null); const currentUser = ref<User | null>(null);
@@ -70,6 +72,8 @@ async function login(username: string, password: string): Promise<void> {
throw new Error(body.error ?? `login failed (${res.status})`); throw new Error(body.error ?? `login failed (${res.status})`);
} }
currentUser.value = (await res.json()) as User; 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<void> { async function setup(username: string, password: string): Promise<void> {
@@ -85,6 +89,8 @@ async function setup(username: string, password: string): Promise<void> {
} }
currentUser.value = (await res.json()) as User; currentUser.value = (await res.json()) as User;
needsSetup.value = false; needsSetup.value = false;
// Setup response omits capabilities/bots; fetch the authoritative ones from /me.
await refreshMe();
} }
async function logout(): Promise<void> { async function logout(): Promise<void> {
@@ -93,6 +99,18 @@ async function logout(): Promise<void> {
currentUser.value = null; 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() { export function useSession() {
return { return {
currentUser: readonly(currentUser), currentUser: readonly(currentUser),
@@ -104,5 +122,7 @@ export function useSession() {
login, login,
logout, logout,
setup, setup,
can,
canControlBot,
}; };
} }
+205 -38
View File
@@ -50,8 +50,8 @@
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p> <p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
</section> </section>
<!-- Bot Management --> <!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
<section class="settings-section"> <section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人管理</h2> <h2 class="section-title">机器人管理</h2>
<div class="bot-list"> <div class="bot-list">
<div v-for="bot in store.bots" :key="bot.id" class="bot-item"> <div v-for="bot in store.bots" :key="bot.id" class="bot-item">
@@ -157,8 +157,8 @@
</div> </div>
</section> </section>
<!-- Music Account - QR Code Login --> <!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
<section class="settings-section"> <section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">音乐账号</h2> <h2 class="section-title">音乐账号</h2>
<!-- NetEase --> <!-- NetEase -->
@@ -371,8 +371,8 @@
</div> </div>
</section> </section>
<!-- Audio Quality --> <!-- Audio Quality requires quality -->
<section class="settings-section"> <section v-if="can('quality')" class="settings-section">
<h2 class="section-title">音质设置</h2> <h2 class="section-title">音质设置</h2>
<div class="setting-row"> <div class="setting-row">
<div class="setting-label"> <div class="setting-label">
@@ -410,7 +410,7 @@
</section> </section>
<!-- Idle Timeout --> <!-- Idle Timeout -->
<section class="settings-section"> <section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">行为设置</h2> <h2 class="section-title">行为设置</h2>
<div class="setting-row"> <div class="setting-row">
<div class="setting-label"> <div class="setting-label">
@@ -448,7 +448,7 @@
</section> </section>
<!-- Bot Profile (TeamSpeak Behavior) --> <!-- Bot Profile (TeamSpeak Behavior) -->
<section class="settings-section"> <section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2> <h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p> <p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div> <div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
@@ -504,38 +504,96 @@
<section v-if="session.isAdmin.value" class="settings-section"> <section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">用户管理</h2> <h2 class="section-title">用户管理</h2>
<div class="user-list"> <div class="user-list">
<div v-for="u in userList" :key="u.id" class="user-item"> <div v-for="u in userList" :key="u.id" class="user-row-wrap">
<div class="user-info"> <div class="user-item">
<div class="user-name"> <div class="user-info">
{{ u.username }} <div class="user-name">
<span class="user-role-badge" :class="`role-${u.role}`"> {{ u.username }}
{{ u.role === 'admin' ? '管理员' : '成员' }} <span class="user-role-badge" :class="`role-${u.role}`">
</span> {{ u.role === 'admin' ? '管理员' : '成员' }}
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span> </span>
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
</div>
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
</div>
<div class="user-actions">
<span v-if="u.role === 'admin'" class="perm-admin-label">全部权限(管理员)</span>
<button
v-else
class="btn-sm"
:class="{ 'btn-primary': permEditingId === u.id }"
@click="onTogglePermEditor(u)"
>
<Icon icon="mdi:shield-key" /> 权限
</button>
<button class="btn-sm" @click="openResetPassword(u)">
<Icon icon="mdi:lock-reset" /> 重置密码
</button>
<button
class="btn-sm"
:disabled="changingRoleId === u.id || isLastAdmin(u)"
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
@click="onToggleRole(u)"
>
<Icon icon="mdi:account-cog" />
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
</button>
<button
class="btn-sm btn-delete"
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
@click="onDeleteUser(u)"
>
<Icon icon="mdi:delete" />
</button>
</div> </div>
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
</div> </div>
<div class="user-actions">
<button class="btn-sm" @click="openResetPassword(u)"> <!-- Inline permission editor (members only) -->
<Icon icon="mdi:lock-reset" /> 重置密码 <div v-if="permEditingId === u.id" class="perm-editor">
</button> <div v-if="permLoading" class="user-empty">加载权限中…</div>
<button <template v-else>
class="btn-sm" <div class="perm-group">
:disabled="changingRoleId === u.id || isLastAdmin(u)" <div class="perm-group-title">能力</div>
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')" <div class="perm-checks">
@click="onToggleRole(u)" <label v-for="cap in CAPABILITIES" :key="cap.token" class="perm-check">
> <input
<Icon icon="mdi:account-cog" /> type="checkbox"
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }} :checked="permDraft.capabilities.includes(cap.token)"
</button> @change="toggleCapability(cap.token, ($event.target as HTMLInputElement).checked)"
<button />
class="btn-sm btn-delete" {{ cap.label }}
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)" </label>
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')" </div>
@click="onDeleteUser(u)" </div>
>
<Icon icon="mdi:delete" /> <div class="perm-group">
</button> <div class="perm-group-title">机器人</div>
<label class="perm-check">
<input type="checkbox" v-model="permDraft.botsAll" />
全部机器人
</label>
<div v-if="!permDraft.botsAll" class="perm-checks perm-bots">
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
<input
type="checkbox"
:checked="permDraft.selectedBotIds.includes(bot.id)"
@change="toggleBotSelection(bot.id, ($event.target as HTMLInputElement).checked)"
/>
{{ bot.name }}
</label>
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
</div>
</div>
<p v-if="permError" class="user-error">{{ permError }}</p>
<div class="form-actions">
<button class="btn-sm" @click="permEditingId = null">取消</button>
<button class="btn-sm btn-primary" :disabled="permSaving" @click="onSavePermissions(u)">
{{ permSaving ? '保存中…' : '保存' }}
</button>
</div>
</template>
</div> </div>
</div> </div>
<div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div> <div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div>
@@ -987,6 +1045,7 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
// --- User Management --- // --- User Management ---
const session = useSession(); const session = useSession();
const { can } = session;
// --- Own password change (available to all authenticated users) --- // --- Own password change (available to all authenticated users) ---
const ownPw = reactive({ old: '', new: '', confirm: '' }); const ownPw = reactive({ old: '', new: '', confirm: '' });
@@ -1152,6 +1211,91 @@ async function onConfirmReset() {
} }
} }
// --- Per-user permission editor (members only) ---
const CAPABILITIES: { token: string; label: string }[] = [
{ token: 'player.control', label: '播放控制' },
{ token: 'player.queue', label: '队列管理' },
{ token: 'bot.manage', label: '机器人管理' },
{ token: 'platform.auth', label: '平台登录凭据' },
{ token: 'quality', label: '音质设置' },
];
const permEditingId = ref<string | null>(null);
const permLoading = ref(false);
const permSaving = ref(false);
const permError = ref('');
const permDraft = reactive<{ capabilities: string[]; botsAll: boolean; selectedBotIds: string[] }>({
capabilities: [],
botsAll: true,
selectedBotIds: [],
});
async function onTogglePermEditor(u: UserListEntry) {
if (permEditingId.value === u.id) {
permEditingId.value = null;
return;
}
permEditingId.value = u.id;
permError.value = '';
permLoading.value = true;
permDraft.capabilities = [];
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
try {
const res = await fetch(`/api/users/${u.id}/permissions`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
permDraft.capabilities = Array.isArray(body.capabilities) ? [...body.capabilities] : [];
if (body.bots === 'all') {
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
} else {
permDraft.botsAll = false;
permDraft.selectedBotIds = Array.isArray(body.bots) ? [...body.bots] : [];
}
} catch (e) {
permError.value = (e as Error).message;
} finally {
permLoading.value = false;
}
}
function toggleCapability(token: string, checked: boolean) {
const has = permDraft.capabilities.includes(token);
if (checked && !has) permDraft.capabilities.push(token);
else if (!checked && has) permDraft.capabilities = permDraft.capabilities.filter((t) => t !== token);
}
function toggleBotSelection(id: string, checked: boolean) {
const has = permDraft.selectedBotIds.includes(id);
if (checked && !has) permDraft.selectedBotIds.push(id);
else if (!checked && has) permDraft.selectedBotIds = permDraft.selectedBotIds.filter((b) => b !== id);
}
async function onSavePermissions(u: UserListEntry) {
permSaving.value = true;
permError.value = '';
try {
const res = await fetch(`/api/users/${u.id}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
capabilities: [...permDraft.capabilities],
bots: permDraft.botsAll ? 'all' : [...permDraft.selectedBotIds],
}),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
permEditingId.value = null;
} catch (e) {
permError.value = (e as Error).message;
} finally {
permSaving.value = false;
}
}
function formatDate(ms: number): string { function formatDate(ms: number): string {
const d = new Date(ms); const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -1202,6 +1346,7 @@ function describeAction(e: AuditEntry): string {
case 'user.password_reset': return `重置 ${target} 的密码`; case 'user.password_reset': return `重置 ${target} 的密码`;
case 'user.password_changed': return `修改自己的密码`; case 'user.password_changed': return `修改自己的密码`;
case 'user.role_changed': return `变更 ${target} 的角色`; case 'user.role_changed': return `变更 ${target} 的角色`;
case 'user.permissions_changed': return `权限变更 → ${target}`;
default: return `${e.action} → ${target}`; default: return `${e.action} → ${target}`;
} }
} }
@@ -1930,6 +2075,28 @@ onUnmounted(() => {
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); } .role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
.user-role-select { flex: 0 0 110px; } .user-role-select { flex: 0 0 110px; }
.user-row-wrap { display: flex; flex-direction: column; gap: 0; }
.perm-admin-label { font-size: 12px; color: var(--text-secondary); align-self: center; }
.perm-editor {
margin-top: -2px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.perm-group { display: flex; flex-direction: column; gap: 8px; }
.perm-group-title { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.perm-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.perm-bots { padding-left: 16px; }
.perm-check {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary); cursor: pointer;
}
.perm-check input { cursor: pointer; }
// --- Account section (own password change) --- // --- Account section (own password change) ---
.account-info-card { .account-info-card {
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;