Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.
Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.
The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.
── The one new piece of platform: src/servers/account-lifecycle.ts ──
Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.
Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling 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. (docs/email-plugin-extraction.md took that shape 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.)
Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.
── Two things deleted rather than carried, both dark since 2026-08-13 ──
The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.
The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.
── Tests ──
Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.
bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.
Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
4.2 KiB
TypeScript
80 lines
4.2 KiB
TypeScript
// "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<void>;
|
|
|
|
const listeners = new Set<Listener>();
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|