vault, extracted into plugins/vault — the platform half

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>
This commit is contained in:
2026-08-18 13:43:32 +00:00
co-authored by Claude Opus 5
parent c775e8c7e3
commit f6a1194905
27 changed files with 141 additions and 1150 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
// The role is what makes this account the owner — isSuperAdmin and getOwnerUser both read it, and
// nothing else confers it. Without this the first account would take the column's 'Member' default
// and the platform would come up with no owner at all: no vault, no agent identity, and the web
// and the platform would come up with no owner at all: no owner-only surfaces, no agent identity, and the web
// origin locked to a Super Admin that does not exist. Bootstrap is gated on an empty user table
// above, so this cannot promote anyone but the first account.
const user = await createUser({
+3 -6
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
import { triggerLockdown } from './panic';
import { clientIp } from './client-ip';
@@ -10,11 +10,8 @@ export const panicHandler: Handler = async (ctx) => {
const origin = ctx.get('origin') as string | undefined;
triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`);
// Panic wipes the vault session AND the protector key (like distress).
if (user?.id) {
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
}
// Panic is distress plus a locked-down server, so listeners get the same severity.
if (user?.id) emitAccountLifecycle(user.id, 'panic');
return ctx.json({ ok: true });
};
+6 -5
View File
@@ -1,5 +1,6 @@
import type { Handler } from 'hono';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
import { clientIp } from './client-ip';
// Distress: blacklist the current token (same as signout) but log it as a security event.
@@ -13,10 +14,10 @@ export const revokeHandler: Handler = async (ctx) => {
await blacklistToken(user.jti, user.exp);
// Distress wipes the vault session AND the stored protector key — forces a one-time master-password
// re-setup, the correct "someone got in" behavior.
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
// Distress means "someone got in": listeners drop everything reconstructible, not just live sessions.
// Deliberately harsher than signout — convenience material goes too, even where that costs the owner a
// one-time re-setup on their next login.
emitAccountLifecycle(user.id, 'distress');
cleanupExpiredTokens().catch(() => {});
+5 -3
View File
@@ -1,6 +1,7 @@
import type { Handler } from 'hono';
import type { AuthIdentity } from '@@/auth-token';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as AuthIdentity;
@@ -9,8 +10,9 @@ export const signoutHandler: Handler = async (ctx) => {
// the keys API, which is the point of holding one instead of a session.
if (user.jti && user.exp) await blacklistToken(user.jti, user.exp);
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
clearVaultTokens(user.id).catch(() => {});
// Tell whatever holds per-account session state that this one ended. The mildest of the three levels:
// a listener should drop live sessions and keep whatever makes signing back in painless.
emitAccountLifecycle(user.id, 'signout');
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});