# Vault — the extraction record Extracted from the platform on **2026-08-18**, the fourth plugin out (after offscale, music, email). `plugins/EXTRACTING-A-PLUGIN.md` is the runbook; this file is what was decided and what it cost. ## Where it came from | Was | Is now | | -------------------------------------- | ----------------------------- | | `src/servers/api/vault/router.ts` | `api/router.ts` (+ config routes) | | `src/servers/api/vault/broker.ts` | `api/broker.ts` | | `src/servers/api/vault/token-store.ts` | `api/token-store.ts` | | `src/servers/api/vault/proxy-util.ts` | `api/proxy-util.ts` | | `src/servers/api/vault/sidecar-server.ts` | `api/sidecar-server.ts` | | `src/servers/api/vault/public-router.ts` | **deleted** — see below | | `src/servers/api/vault/websocket.ts` | **deleted** — see below | | `src/servers/sidecar/vault/*` | `sidecar/*` | | `src/databases/officer_db/src/vault/*` | `db/*` | | — | `web/*` — new, there was no UI | ## The state it was found in Vault had been **switched off since 2026-08-13** (`1eecc400`, "switch off Vaultwarden's routers, pending extraction into a plugin") — mounts commented, code left on disk, tables commented out of `schema.ts` and therefore dropped by a later push. It was briefly re-enabled earlier the same day as this extraction (`c775e8c7`) on a misreading of what the owner wanted, then extracted properly. That commit is a dead end kept in history rather than reverted, per the repo's own rule about committing the mistakes. Consequence worth stating: **nothing regressed for anyone**, because nothing was live. `vault_tokens` and `vault_unlock_keys` did not exist in Postgres and `sidecar_installs` had no vault row. ## Decisions ### 1. The Vaultwarden URL moved from `.env` into the database — server-wide `VAULTWARDEN_URL` is gone. It is now `vault_config`, a one-row table (`id` pinned to 1 by a CHECK), with a settings panel at `/vault` writing it. Server-wide rather than per-user, decided with the owner: one Vaultwarden per install. Jellyfin's registry is per-user because that machine genuinely runs four instances; there is no such case here, and a `userId` column would be a join paid on every request for a second row that does not exist. The env var could not survive anyway — a plugin cannot ask the owner to edit a file and restart a process. Installing it has to be enough. **Cost:** the sidecar now reads its upstream from Postgres instead of memory. It caches with a 10s TTL (`sidecar/upstream.ts`) rather than querying per request, because this sits in front of every proxied byte including attachment uploads. The TTL is the whole mechanism — there is no push, because `sidecar-registry.ts` has no generic "send a command to the sidecar named X" and building one for a cache hint would be a channel built for the wrong reason. ### 2. Core stopped importing the vault, via a new lifecycle hook — NOT by reaching into its tables The reverse dependency that decided the split. Three core auth sites — `signout.ts`, `revoke-handler.ts`, `panic-handler.ts` — imported `clearVaultTokens` / `clearVaultUnlockKey` from `officerdb` and called them. That is the security property behind the distress and panic buttons: "someone got in, make the vault need a master password again." Three options, and the stakes chose: - **Drop the wipe.** A silent security regression on the path that exists for the worst day. No. - **Have core `DELETE FROM vault_tokens` by name.** Removes the import, keeps the coupling, and moves it somewhere worse — core would depend on this plugin's *schema*, so renaming a column would silently stop the wipe. 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. - **A lifecycle announcement.** Taken. `src/servers/account-lifecycle.ts` is ~30 lines: core emits `signout` / `distress` / `panic` with a user id, 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. The severity mapping moved into the plugin, where it belongs, and the behaviour is unchanged: `signout` drops the brokered session and keeps the protector key; `distress` and `panic` drop both. **Known hole, accepted:** uninstall keeps rows, so a panic while the plugin is uninstalled wipes nothing. The tokens are unusable in that state (no routes mounted) and reinstalling is an owner action, so this is narrow rather than dangerous — but it is real. ### 3. The unauthenticated Bitwarden passthrough was deleted, not carried `public-router.ts` served Vaultwarden at Officer's own hostname with no Officer auth, so the Bitwarden browser extension could be configured with the bare Officer URL — mounted at `/vaultwarden` and at four root prefixes (`/identity`, `/notifications`, `/icons`, `/events`), with an `/api/*` diverter keying off the `Bitwarden-Client-Name` header. **A plugin cannot serve any of that.** Plugin routers mount into `protectedRouter` at `/api/` — behind the account gate, by design. Top-level unauthenticated prefixes are not something a manifest can declare, and the Bun route table those paths need entries in is built once at `serve()`. Keeping it would have meant core naming `/vaultwarden` and `/identity` in its own route table: the exact vocabulary leak the runbook forbids, for a path its own comments already called TEMPORARY. The owner's call was "nice to have, not a requirement", with the intended end state being a first-party browser extension that authenticates to Officer like every other client — which deletes the need rather than restoring the workaround. So it is deleted here rather than carried as dead code. **This is the one user-visible loss**, and it was already dark since 2026-08-13. ### 4. The notifications websocket went with it `/api/vault/notifications/*` was upgraded at the serve level and piped to the sidecar with the Vaultwarden token injected. Same blocker as above plus a second one: **no plugin owns a websocket on this platform yet** — `server.reload({ routes })` is proven and never called, and `handlers` in `server.tsx` is a static map. Music parked `cliamp/` for exactly this. SignalR degrades to HTTP long-polling on its own, and that path *does* extract — it rides the ordinary HTTP proxy. So clients still sync; they learn about changes on their next poll rather than instantly. The sidecar's own websocket proxy is kept and still works (it is what a future platform mechanism would plug into). Its `open` handler had to become async because the upstream base now comes from the database — state is registered before the first `await` so frames arriving during setup are queued rather than dropped, which is the shape the deleted platform-side proxy already used. ### 5. Owner-only stayed owner-only, in the plugin's own gate A plugin permission is grantable per role by design — the rule is uniform and has no `ownerOnly`. So a member could hold `vault`. The router keeps its own `isSuperAdmin` gate rather than inheriting an answer: the permission decides whether the *route* is reachable, and the vault is the owner's. ## What the platform lost Deleted, not commented: both routers, the sidecar, the db directory, the `hono.ts` mounts and imports, the `server.tsx` ws import / handler entry / route-table entries, `/vault` from `UNPROTECTED_API_PREFIXES`, both entries in `totality.ts`'s exemption lists, both database barrels, and the `app-store/catalogue.ts` entry. **The catalogue entry deletion is required, not 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`, puts the route into `deniedRoutes`, and withholds the dock tile on a server where the plugin is installed and healthy. Two tests referenced the catalogue entry. Vault was the **only** `members: 'invite'` entry, so removing it would have silently dropped coverage of that branch in `plannedOutcome` — it is now tested against a synthetic entry instead, because 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. ## Still open - **The Bitwarden-direct path** (decision 3). Needs either a first-party extension or a platform mechanism for manifest-declared public prefixes; the latter also needs `server.reload({ routes })` to actually be called for the first time. - **The notifications websocket** (decision 4). Blocked on plugin-owned websockets, platform-wide. - **Additional accounts.** Everything is owner-only. Vaultwarden has its own users and mapping Officer accounts onto them is unbuilt. - **The panic-while-uninstalled hole** (decision 2).