diff --git a/src/data/database.ts b/src/data/database.ts index f911756..d2d0462 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -165,6 +165,20 @@ function initTables(db: Database.Database): void { action TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC); + + 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); `); } diff --git a/src/data/permissions.test.ts b/src/data/permissions.test.ts new file mode 100644 index 0000000..9ec0bdc --- /dev/null +++ b/src/data/permissions.test.ts @@ -0,0 +1,74 @@ +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); + 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"]); + }); +}); diff --git a/src/data/permissions.ts b/src/data/permissions.ts new file mode 100644 index 0000000..37a66d8 --- /dev/null +++ b/src/data/permissions.ts @@ -0,0 +1,69 @@ +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); + }, + }; +}