// Server-side admin plugin-approval store. // // Closes the "C4" audit finding: previously a plugin's `adminApproved` flag // was client-only, so a malicious user could enable a plugin past the policy // gate via DevTools. The server now tracks per-(pluginId, bundleHash) status // and the `enablePlugin` flow consults it before letting a non-managed plugin // run. // // Each entry has one of three states: 'pending' (user installed, waiting for // admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we // don't keep asking). import { readFile, writeFile, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { logger } from '@/lib/logger'; import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; export type ApprovalStatus = 'pending' | 'approved' | 'denied'; export interface ApprovalEntry { pluginId: string; bundleHash: string; status: ApprovalStatus; /** Snapshot of the manifest at request time. */ manifest: { name?: string; version?: string; author?: string; description?: string; permissions?: string[]; httpOrigins?: string[]; apiPostPaths?: string[]; }; requestedBy: string; // JMAP username who triggered the request requestedAt: string; // ISO 8601 decidedBy?: string; // admin username (set on approve/deny) decidedAt?: string; } interface ApprovalsFile { entries: ApprovalEntry[]; } const APPROVALS_FILE = 'plugin-approvals.json'; const MAX_ENTRIES = 500; // hard cap so a misbehaving client can't grow the file unboundedly let cached: ApprovalsFile | null = null; let loadPromise: Promise | null = null; async function loadFromDisk(): Promise { await ensureConfigDir(); const path = getConfigPath(APPROVALS_FILE); if (!existsSync(path)) return { entries: [] }; try { const raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }; return { entries: parsed.entries.filter(isWellFormed) }; } catch (err) { logger.warn('[plugin-approvals] failed to read file', { error: err instanceof Error ? err.message : String(err) }); return { entries: [] }; } } async function ensureLoaded(): Promise { if (cached !== null) return; if (!loadPromise) { loadPromise = (async () => { cached = await loadFromDisk(); })(); } await loadPromise; } async function flushToDisk(): Promise { if (!cached) return; await ensureConfigDir(); assertWritable('plugin-approvals.flushToDisk'); const path = getConfigPath(APPROVALS_FILE); const tmp = `${path}.tmp`; await writeFile(tmp, JSON.stringify(cached, null, 2), 'utf-8'); await rename(tmp, path); } function isWellFormed(value: unknown): value is ApprovalEntry { if (!value || typeof value !== 'object') return false; const v = value as Record; return ( typeof v.pluginId === 'string' && typeof v.bundleHash === 'string' && (v.status === 'pending' || v.status === 'approved' || v.status === 'denied') && typeof v.requestedBy === 'string' && typeof v.requestedAt === 'string' && typeof v.manifest === 'object' && v.manifest !== null ); } function findEntry(file: ApprovalsFile, pluginId: string, bundleHash: string): ApprovalEntry | undefined { return file.entries.find(e => e.pluginId === pluginId && e.bundleHash === bundleHash); } // ─── Public API ────────────────────────────────────────────── export async function listApprovals(): Promise { await ensureLoaded(); return [...cached!.entries]; } export async function getApprovalStatus(pluginId: string, bundleHash: string): Promise<{ status: ApprovalStatus | 'not-requested'; decidedAt?: string }> { await ensureLoaded(); const entry = findEntry(cached!, pluginId, bundleHash); if (!entry) return { status: 'not-requested' }; return { status: entry.status, decidedAt: entry.decidedAt }; } export async function requestApproval( pluginId: string, bundleHash: string, manifest: ApprovalEntry['manifest'], requestedBy: string, ): Promise { if (!pluginId || !bundleHash) throw new Error('pluginId and bundleHash required'); await ensureLoaded(); const file = cached!; const existing = findEntry(file, pluginId, bundleHash); if (existing) return existing; if (file.entries.length >= MAX_ENTRIES) { // Drop the oldest pending entry so a new request can land. Approved/denied // entries are preserved. const oldestPendingIdx = file.entries.findIndex(e => e.status === 'pending'); if (oldestPendingIdx >= 0) file.entries.splice(oldestPendingIdx, 1); else throw new Error('plugin-approvals file is full'); } const entry: ApprovalEntry = { pluginId, bundleHash, status: 'pending', manifest, requestedBy, requestedAt: new Date().toISOString(), }; file.entries.push(entry); await flushToDisk(); return entry; } export async function decideApproval( pluginId: string, bundleHash: string, decision: 'approved' | 'denied', decidedBy: string, ): Promise { await ensureLoaded(); const file = cached!; const entry = findEntry(file, pluginId, bundleHash); if (!entry) throw new Error('approval entry not found'); entry.status = decision; entry.decidedAt = new Date().toISOString(); entry.decidedBy = decidedBy; await flushToDisk(); return entry; } export async function revokeApproval(pluginId: string, bundleHash: string): Promise { await ensureLoaded(); const file = cached!; const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash); if (idx < 0) return; file.entries.splice(idx, 1); await flushToDisk(); } /** Force a re-read on next access. Used in tests / after a manual file edit. */ export function invalidateApprovalsCache(): void { cached = null; loadPromise = null; }