import type { Agent } from "agents"; import { defaultSkillPermissions, skillPermissionChoices, skillPermissionRevision, type SkillPermissionSnapshot, type SkillPermissionAudit, } from "../shared/skill-permissions"; import type { SkillIdentity } from "../shared/skill-management"; import type { SkillListing } from "../shared/skills"; import { OperationFailure } from "./operation-result"; export interface SkillPermissionSubject { skill: SkillListing; installationId: string; } export class SkillPermissionStore { constructor( private readonly sql: Agent["sql"], private readonly storage: Pick, private readonly current: ( identity: SkillIdentity, ) => SkillPermissionSubject, ) {} initialize() { this .sql`CREATE TABLE IF NOT EXISTS flarebot_skill_permissions (source TEXT NOT NULL, name TEXT NOT NULL, installation TEXT NOT NULL, fingerprint TEXT NOT NULL, revision INTEGER NOT NULL, choices TEXT NOT NULL, PRIMARY KEY(source, name))`; this .sql`CREATE TABLE IF NOT EXISTS flarebot_skill_permission_audit (revision INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, name TEXT NOT NULL, installation TEXT NOT NULL, fingerprint TEXT NOT NULL, timestamp TEXT NOT NULL, before_choices TEXT NOT NULL, after_choices TEXT NOT NULL)`; } read(identity: SkillIdentity): SkillPermissionSnapshot { const subject = this.current(identity); const [row] = this.sql<{ revision: number; choices: string; }>`SELECT revision, choices FROM flarebot_skill_permissions WHERE source = ${identity.source} AND name = ${identity.name} AND installation = ${subject.installationId} AND fingerprint = ${identity.fingerprint}`; const audit = this.sql<{ revision: number; timestamp: string; before_choices: string; after_choices: string; }>`SELECT revision, timestamp, before_choices, after_choices FROM flarebot_skill_permission_audit WHERE source = ${identity.source} AND name = ${identity.name} AND installation = ${subject.installationId} AND fingerprint = ${identity.fingerprint} ORDER BY revision DESC LIMIT 20`; return { revision: row?.revision ?? 0, choices: row ? skillPermissionChoices.parse(JSON.parse(row.choices)) : { ...defaultSkillPermissions }, audit: audit.map((entry): SkillPermissionAudit => ({ revision: entry.revision, timestamp: entry.timestamp, before: skillPermissionChoices.parse(JSON.parse(entry.before_choices)), after: skillPermissionChoices.parse(JSON.parse(entry.after_choices)), })), }; } update(identity: SkillIdentity, revision: number, value: unknown) { const choices = skillPermissionChoices.parse(value); skillPermissionRevision.parse(revision); return this.storage.transactionSync(() => { const subject = this.current(identity); const before = this.read(identity); if (before.revision !== revision) throw new OperationFailure("skill_permissions_changed"); if (JSON.stringify(before.choices) === JSON.stringify(choices)) return before; const [entry] = this.sql<{ revision: number; }>`INSERT INTO flarebot_skill_permission_audit (source, name, installation, fingerprint, timestamp, before_choices, after_choices) VALUES (${identity.source}, ${identity.name}, ${subject.installationId}, ${identity.fingerprint}, ${new Date().toISOString()}, ${JSON.stringify(before.choices)}, ${JSON.stringify(choices)}) RETURNING revision`; skillPermissionRevision.parse(entry.revision); this .sql`INSERT INTO flarebot_skill_permissions VALUES (${identity.source}, ${identity.name}, ${subject.installationId}, ${identity.fingerprint}, ${entry.revision}, ${JSON.stringify(choices)}) ON CONFLICT(source, name) DO UPDATE SET installation = excluded.installation, fingerprint = excluded.fingerprint, revision = excluded.revision, choices = excluded.choices`; this .sql`DELETE FROM flarebot_skill_permission_audit WHERE revision NOT IN (SELECT revision FROM flarebot_skill_permission_audit ORDER BY revision DESC LIMIT 256)`; return this.read(identity); }); } remove(identity: SkillIdentity, installationId: string) { this .sql`DELETE FROM flarebot_skill_permissions WHERE source = ${identity.source} AND name = ${identity.name} AND installation = ${installationId}`; } }