import { pgTable, integer, text, timestamp, boolean, check } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { users } from 'officerdb/auth/schema'; // Officer Vault server-side state. The device never holds a Vaultwarden token; the platform brokers it, // stores it here tied to the owner account, and injects it on proxied /api/vault requests. All secret // columns are AES-256-GCM encrypted at rest under the `vault` key purpose (officerdb/crypto). /** * Where this install's Vaultwarden lives. ONE ROW, ever — `id` is pinned to 1 by a CHECK. * * ── Why this is a table and not `VAULTWARDEN_URL` in .env ── * * It was the environment variable until the extraction (2026-08-18). Three things were wrong with that, * and only the first is about tidiness: * * 1. `.env` is auto-loaded by bun into EVERY pm2 process, so a value only the vault sidecar needs was * readable from `/proc//environ` of every other one. Same argument `docs/secret-store.md` makes * about keys, applied to a URL — lower stakes, identical shape. * 2. A plugin cannot ask the owner to edit a file and restart a process. Installing it has to be enough, * and pointing it at a server has to be something the UI can do. * 3. An env var read at module load cannot change without a restart, so "point it somewhere else" meant * editing a file, restarting the sidecar, and knowing to do both. * * ── Why server-wide rather than per-user ── * * Decided with the owner 2026-08-18: one Vaultwarden per install. Jellyfin's registry is per-user because * this machine genuinely runs four instances; there is no such case here, and a `userId` column would be * a join every request pays for a second row that does not exist. Onboarding additional accounts onto the * same instance is a separate, later question — Vaultwarden has its own users, and mapping Officer * accounts onto them is not a column here. */ export const vaultConfig = pgTable( 'vault_config', { /** * Always 1. A single-row table wants a way to say so structurally, or the second row arrives * eventually and nothing says which one is real. */ id: integer('id').primaryKey().default(1), /** Base URL, no trailing slash (normalised on write). Null before the owner has pointed it anywhere. */ url: text('url'), /** * Whether the reverse-proxy should serve at all. Distinct from `url` being set: the owner may want to * stop exposing a vault without forgetting where it is. */ enabled: boolean('enabled').notNull().default(true), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, // The singleton rule, said structurally rather than by convention — `src/databases/CLAUDE.md`'s point // about the schema owning what the database MAY contain, not only its shape. (table) => [check('ck_vault_config_singleton', sql`${table.id} = 1`)], ); /** * The brokered Vaultwarden token set. One row per owner account (single active vault session). Dropped on * platform logout / distress / panic. */ export const vaultTokens = pgTable('vault_tokens', { userId: integer('user_id') .primaryKey() .references(() => users.id, { onDelete: 'cascade' }), accessToken: text('access_token').notNull(), // encrypted refreshToken: text('refresh_token').notNull(), // encrypted expiresAt: timestamp('expires_at', { withTimezone: true }), deviceIdentifier: text('device_identifier'), // plaintext; the last device that brokered a login clientId: text('client_id'), // plaintext; the connect/token client_id, needed to refresh updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); /** * The platform-held "protector key" for the Officer-app unlock path: it unwraps the on-device wrapped user * key, so a valid platform session opens the vault without the master password. Persists across normal * logout (frictionless re-login); wiped on distress/panic (forces a one-time master-password re-setup). * The zero-knowledge OffVault path never writes here (its protector stays in the Secure Enclave). */ export const vaultUnlockKeys = pgTable('vault_unlock_keys', { userId: integer('user_id') .primaryKey() .references(() => users.id, { onDelete: 'cascade' }), wrappedKey: text('wrapped_key').notNull(), // encrypted (the protector key) updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), });