mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(perm): permission store + capability tokens + tables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
547aaa304e
commit
aaf6ba2ab4
3 files changed
+157
No files matched your search
@@ -165,6 +165,20 @@ function initTables(db: Database.Database): void {
|
|||||||
action TEXT NOT NULL
|
action TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
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);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user