Files
pastilhas 2f2529d8d5 vault, extracted from the platform into its own repository
The fourth plugin out, after offscale, music and email. Two routers, a sidecar,
three tables and — new — a settings screen, since the vault never had a UI in
the platform.

What it is: a reverse-proxy to a Vaultwarden you already run, plus a credential
broker that keeps the vault session server-side so devices never hold one. It
stores no passwords and cannot read any; every byte through it is ciphertext.

Two things changed rather than moved:

  • VAULTWARDEN_URL left .env for a one-row `vault_config` table with a panel
    writing it. A plugin cannot ask the owner to edit a file and restart a
    process, so the env var could not survive the move. Server-wide, decided
    with the owner: one Vaultwarden per install. The sidecar reads it on a 10s
    cache rather than per request, since it sits in front of every proxied byte.

  • Core stopped importing clearVaultTokens/clearVaultUnlockKey from three auth
    handlers. That is the security property behind the distress and panic
    buttons, so it could not simply be dropped — the platform gained a small
    account-lifecycle hook instead, and the severity mapping moved here where it
    belongs. Core now names no table and no column of this plugin.

Two things were deleted rather than carried, both already dark since
2026-08-13 and both recorded in PLUGIN.md:

  • The unauthenticated Bitwarden passthrough. A plugin's routes mount behind
    Officer's account gate; top-level unauthenticated prefixes are not something
    a manifest can declare. Its own comments already called it TEMPORARY,
    pending a first-party browser extension that authenticates to Officer.

  • The notifications websocket. No plugin owns a socket on this platform yet.
    SignalR falls back to HTTP long-polling on its own, so clients still sync.
2026-08-18 13:42:44 +00:00

8.9 KiB

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/<name> — 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).