Files
vault/PLUGIN.md
T
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

144 lines
8.9 KiB
Markdown

# 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).