// "This account's session ended" — announced by core, listened to by whoever holds per-account state. // // ── Why this exists ── // // Signout, distress and panic all have to reach state the PLATFORM does not own. The vault is the first // case and the reason this file was written (2026-08-18, during its extraction into `plugins/vault/`): // distress and panic wipe the brokered vault session and its protector key, which is the entire point of // those buttons — "someone got in, make the vault need a master password again". Before the extraction, // `auth/signout.ts`, `auth/revoke-handler.ts` and `auth/panic-handler.ts` imported `clearVaultTokens` // straight from `officerdb` and called it. // // A plugin cannot be imported by core, so that had to end. The two alternatives were both worse: // // • **Drop the wipe.** A silent security regression, and precisely on the path that exists for the // worst day. Not a trade a refactor gets to make by itself. // • **Have core DELETE from the plugin's tables by name.** Removes the import but keeps the coupling // and moves it somewhere worse: core would depend on a plugin's SCHEMA, so renaming a column would // silently stop the wipe. A security property that fails silently on an unrelated change is the // failure mode to design against, not accept. (This is the shape `docs/email-plugin-extraction.md` // took for READING a plugin's data, where a stale answer is a missing feature rather than a lock // left open. The stakes decide it, not the pattern.) // // So: core announces, and anything holding per-account state subscribes. Core names no plugin, no table // and no column, and a listener that is not there is a plugin that is not installed. // // ── What it is deliberately not ── // // Not a general event bus, and it should not grow into one. Three events, all meaning "a session ended // and here is how badly" — anything that is not about an account's session ending belongs elsewhere. // // Fire-and-forget by design. The caller is an auth route answering a person who pressed a button, and it // must not fail, hang or wait because a listener did. A listener that throws is logged and skipped. export type AccountLifecycleEvent = /** Ordinary logout. The mildest: drop live sessions, keep anything that makes signing back in easy. */ | 'signout' /** Distress — "someone got in". Drop everything reconstructible, including convenience material. */ | 'distress' /** Panic lockdown. Same severity as distress; the server also stops answering until it is restarted. */ | 'panic'; type Listener = (userId: number, event: AccountLifecycleEvent) => void | Promise; const listeners = new Set(); /** * Subscribe. Returns an unsubscribe function. * * Register from a module the plugin system already imports — a plugin's `api/router.ts` is imported once * when its routes are mounted, which makes the subscription's lifetime the same as the plugin being * installed and enabled. */ export function onAccountLifecycle(listener: Listener): () => void { listeners.add(listener); return () => listeners.delete(listener); } /** * Announce. Never throws, never awaited by the caller, and one listener failing cannot stop another. * * Deliberately not `await`ed at the call sites: signout returning 200 must not depend on every listener * having finished, and a listener that hangs must not hang the response. The cost is that a wipe is * ordered rather than confirmed — acceptable because the token it invalidates is already unusable the * moment the session it belonged to is blacklisted, which the same handler does synchronously. */ export function emitAccountLifecycle(userId: number, event: AccountLifecycleEvent): void { for (const listener of listeners) { try { const result = listener(userId, event); if (result instanceof Promise) { result.catch((err: unknown) => { console.error(`[account-lifecycle] ${event} listener failed:`, err instanceof Error ? err.message : err); }); } } catch (err) { console.error(`[account-lifecycle] ${event} listener threw:`, err instanceof Error ? err.message : err); } } }