diff --git a/src/data/database.ts b/src/data/database.ts index d2d0462..78a721a 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -182,12 +182,37 @@ function initTables(db: Database.Database): void { `); } +/** + * 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 = ["player.control", "player.queue", "bot.manage", "platform.auth", "quality", "bots.all"]; + const tx = db.transaction(() => { + for (const m of members) { + for (const t of tokens) insCap.run(m.id, t); + } + db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length)); + }); + tx(); +} + export function createDatabase(dbPath: string): BotDatabase { const db = new Database(dbPath); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); initTables(db); migrateSchema(db); + backfillMemberPermissions(db); const insertHistory = db.prepare(` INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) diff --git a/src/data/permissions-migration.test.ts b/src/data/permissions-migration.test.ts new file mode 100644 index 0000000..d290ce1 --- /dev/null +++ b/src/data/permissions-migration.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js"; +import { createPermissionStore, CAPABILITIES } from "./permissions.js"; + +describe("backfillMemberPermissions", () => { + let dbFile: string; + let db: BotDatabase; + function fresh() { + dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + db = createDatabase(dbFile); + } + afterEach(() => { + db.close(); + for (const s of ["", "-wal", "-shm"]) { + try { + fs.rmSync(dbFile + s, { force: true }); + } catch {} + } + }); + + it("grants existing members full access + bots.all, skips admins, once", () => { + fresh(); + // simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows + db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run(); + const now = Date.now(); + const ins = db.db.prepare( + "INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)" + ); + ins.run("m1", "mem", "x", now, now, "member"); + ins.run("a1", "adm", "x", now, now, "admin"); + + backfillMemberPermissions(db.db); + + const store = createPermissionStore(db.db); + expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort()); + expect(store.getBotAccess("m1")).toBe("all"); + expect(store.getCapabilities("a1")).toEqual([]); + expect(store.getBotAccess("a1")).toEqual([]); + }); + + it("is idempotent — running again does not change or re-grant", () => { + fresh(); + db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run(); + const now = Date.now(); + db.db + .prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)") + .run("m1", "mem", "x", now, now, "member"); + backfillMemberPermissions(db.db); + // member restricted afterwards + createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] }); + // second run must NOT re-grant (marker present) + backfillMemberPermissions(db.db); + expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]); + }); +});