From f6a1194905a6ced7bb9e277c36c3ea95408c8574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 18 Aug 2026 13:43:32 +0000 Subject: [PATCH] =?UTF-8?q?vault,=20extracted=20into=20plugins/vault=20?= =?UTF-8?q?=E2=80=94=20the=20platform=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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 --- src/databases/officer_db/src/index.ts | 1 - src/databases/officer_db/src/schema.ts | 1 - src/databases/officer_db/src/vault/index.ts | 11 - src/databases/officer_db/src/vault/queries.ts | 101 -------- src/databases/officer_db/src/vault/schema.ts | 32 --- src/server.tsx | 22 +- src/servers/account-lifecycle.ts | 79 ++++++ src/servers/api/auth/bootstrap.ts | 2 +- src/servers/api/auth/panic-handler.ts | 9 +- src/servers/api/auth/revoke-handler.ts | 11 +- src/servers/api/auth/signout.ts | 8 +- src/servers/api/vault/broker.ts | 79 ------ src/servers/api/vault/proxy-util.ts | 29 --- src/servers/api/vault/public-router.ts | 110 -------- src/servers/api/vault/router.ts | 168 ------------- src/servers/api/vault/sidecar-server.ts | 24 -- src/servers/api/vault/token-store.ts | 34 --- src/servers/api/vault/websocket.ts | 162 ------------ src/servers/app-store/catalogue.test.ts | 9 +- src/servers/app-store/catalogue.ts | 28 +-- src/servers/app-store/members.test.ts | 14 +- src/servers/hono.ts | 31 --- src/servers/permissions/registry.test.ts | 8 +- src/servers/permissions/totality.ts | 4 - src/servers/plugins/marketplace.ts | 20 ++ src/servers/sidecar/vault/index.ts | 236 ------------------ src/servers/sidecar/vault/upstream.ts | 58 ----- 27 files changed, 141 insertions(+), 1150 deletions(-) delete mode 100644 src/databases/officer_db/src/vault/index.ts delete mode 100644 src/databases/officer_db/src/vault/queries.ts delete mode 100644 src/databases/officer_db/src/vault/schema.ts create mode 100644 src/servers/account-lifecycle.ts delete mode 100644 src/servers/api/vault/broker.ts delete mode 100644 src/servers/api/vault/proxy-util.ts delete mode 100644 src/servers/api/vault/public-router.ts delete mode 100644 src/servers/api/vault/router.ts delete mode 100644 src/servers/api/vault/sidecar-server.ts delete mode 100644 src/servers/api/vault/token-store.ts delete mode 100644 src/servers/api/vault/websocket.ts delete mode 100644 src/servers/sidecar/vault/index.ts delete mode 100644 src/servers/sidecar/vault/upstream.ts diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 1e755bc1..1a9884a8 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -51,7 +51,6 @@ export * from './jellyfin'; export * from './notify'; export * from './photos'; export * from './soulseek'; -export * from './vault'; export * from './wallet'; // `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index bc3b9264..0b768bcc 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -52,7 +52,6 @@ export * from './service-connections/schema'; // service_connections // export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin // export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf // export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs -export * from './vault/schema'; // vault_tokens, vault_unlock_keys officer-vault // export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache // ── Plugin tables are NOT here ─────────────────────────────────────────────────────────────────── diff --git a/src/databases/officer_db/src/vault/index.ts b/src/databases/officer_db/src/vault/index.ts deleted file mode 100644 index d680f033..00000000 --- a/src/databases/officer_db/src/vault/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - getVaultTokens, - setVaultTokens, - updateVaultAccess, - clearVaultTokens, - getVaultUnlockKey, - setVaultUnlockKey, - clearVaultUnlockKey, -} from './queries'; - -export type { VaultTokenSet } from './queries'; diff --git a/src/databases/officer_db/src/vault/queries.ts b/src/databases/officer_db/src/vault/queries.ts deleted file mode 100644 index 8926fb87..00000000 --- a/src/databases/officer_db/src/vault/queries.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { db } from '../db'; -import { vaultTokens, vaultUnlockKeys } from './schema'; -import { encryptSecret, decryptSecret } from '../crypto'; - -// Vault store access. Callers deal in PLAINTEXT — encryption to/from at-rest ciphertext happens here, so -// the token broker / proxy never touch the crypto directly. See ../crypto.ts and ../schema/vault.ts. - -export type VaultTokenSet = { - accessToken: string; - refreshToken: string; - expiresAt: Date | null; - deviceIdentifier: string | null; - clientId: string | null; -}; - -/** The owner's brokered Vaultwarden token set (decrypted), or null if none is stored. */ -export async function getVaultTokens(userId: number): Promise { - const [row] = await db.select().from(vaultTokens).where(eq(vaultTokens.userId, userId)); - if (!row) return null; - return { - accessToken: decryptSecret('vault', row.accessToken), - refreshToken: decryptSecret('vault', row.refreshToken), - expiresAt: row.expiresAt, - deviceIdentifier: row.deviceIdentifier, - clientId: row.clientId, - }; -} - -/** Upsert the owner's token set (one row per account). Secrets are encrypted before write. */ -export async function setVaultTokens(userId: number, t: VaultTokenSet): Promise { - const values = { - userId, - accessToken: encryptSecret('vault', t.accessToken), - refreshToken: encryptSecret('vault', t.refreshToken), - expiresAt: t.expiresAt, - deviceIdentifier: t.deviceIdentifier, - clientId: t.clientId, - updatedAt: new Date(), - }; - await db - .insert(vaultTokens) - .values(values) - .onConflictDoUpdate({ - target: vaultTokens.userId, - set: { - accessToken: values.accessToken, - refreshToken: values.refreshToken, - expiresAt: values.expiresAt, - deviceIdentifier: values.deviceIdentifier, - clientId: values.clientId, - updatedAt: values.updatedAt, - }, - }); -} - -/** Update only the token pair + expiry after a refresh (keeps device/client). */ -export async function updateVaultAccess( - userId: number, - accessToken: string, - refreshToken: string, - expiresAt: Date | null, -): Promise { - await db - .update(vaultTokens) - .set({ - accessToken: encryptSecret('vault', accessToken), - refreshToken: encryptSecret('vault', refreshToken), - expiresAt, - updatedAt: new Date(), - }) - .where(eq(vaultTokens.userId, userId)); -} - -/** Drop the token set (platform logout / distress / panic). */ -export async function clearVaultTokens(userId: number): Promise { - await db.delete(vaultTokens).where(eq(vaultTokens.userId, userId)); -} - -/** The owner's stored protector key (decrypted), or null. Officer-app unlock path only. */ -export async function getVaultUnlockKey(userId: number): Promise { - const [row] = await db.select().from(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId)); - return row ? decryptSecret('vault', row.wrappedKey) : null; -} - -/** Store/replace the protector key (encrypted). Set once at setup; persists across normal logout. */ -export async function setVaultUnlockKey(userId: number, wrappedKey: string): Promise { - const values = { userId, wrappedKey: encryptSecret('vault', wrappedKey), updatedAt: new Date() }; - await db - .insert(vaultUnlockKeys) - .values(values) - .onConflictDoUpdate({ - target: vaultUnlockKeys.userId, - set: { wrappedKey: values.wrappedKey, updatedAt: values.updatedAt }, - }); -} - -/** Wipe the protector key (distress / panic → forces a one-time master-password re-setup). */ -export async function clearVaultUnlockKey(userId: number): Promise { - await db.delete(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId)); -} diff --git a/src/databases/officer_db/src/vault/schema.ts b/src/databases/officer_db/src/vault/schema.ts deleted file mode 100644 index 88a046ac..00000000 --- a/src/databases/officer_db/src/vault/schema.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core'; -import { users } from '../auth/schema'; - -// Officer Vault server-side state (see VAULT_AUTH_SPEC.md). 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 (see ../crypto.ts). - -// 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(), -}); diff --git a/src/server.tsx b/src/server.tsx index 71b15ec7..2faaf7ac 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -18,7 +18,6 @@ import { chatWebsocket } from './servers/api/chat/websocket'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; // import { desktopWebsocket } from './servers/api/desktop/websocket'; -import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket'; import officerWeb from './apps/officer-web/index.gen.html'; // import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; @@ -52,7 +51,7 @@ type WSData = { userId: number; email: string; username: string; - provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'vault' | 'sidecar'; + provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'sidecar'; sessionId?: string; cwd?: string; command?: string; @@ -144,7 +143,6 @@ const handlers: Record = { 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, // desktop: desktopWebsocket, - vault: vaultWebsocket, sidecar: sidecarWebsocket, }; @@ -405,12 +403,6 @@ const server = serve({ if (!(await file.exists())) return new Response('Not found', { status: 404 }); return new Response(file); }, - // Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket); - // everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy. - '/api/vault/notifications/*': (req, server) => { - if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server); - return honoServer.fetch(req, server); - }, // A dev server's HMR socket, framed by the Preview panel. Must be in the route table rather than // behind the `/api/*` catch-all: Bun matches this table first, and an upgrade that reaches Hono is // an ordinary GET by then. @@ -437,18 +429,6 @@ const server = serve({ // '/.well-known/carddav': honoServer.fetch, // '/dav': honoServer.fetch, // '/dav/*': honoServer.fetch, - // Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own - // entry for the same reason /dav does: only the paths listed here reach hono, and anything else - // falls through to the SPA — which answers 200 with the React shell, so a missing line here looks - // like a working endpoint returning nonsense rather than a 404. - '/vaultwarden/*': honoServer.fetch, - // The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes - // are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing. - // `/api/*` already reaches hono below, where a Bitwarden client header diverts it. - '/identity/*': honoServer.fetch, - '/notifications/*': honoServer.fetch, - '/icons/*': honoServer.fetch, - '/events/*': honoServer.fetch, ...spaRoutes, '/api': honoServer.fetch, // A CLOSURE, deliberately, and not the bound `honoServer.fetch`. diff --git a/src/servers/account-lifecycle.ts b/src/servers/account-lifecycle.ts new file mode 100644 index 00000000..2650c9d1 --- /dev/null +++ b/src/servers/account-lifecycle.ts @@ -0,0 +1,79 @@ +// "This account's session ended" — announced by core, listened to by whoever holds per-account state. +// +// ── Why this exists ── +// +// Signout, distress and panic all have to reach state the PLATFORM does not own. The vault is the first +// case and the reason this file was written (2026-08-18, during its extraction into `plugins/vault/`): +// distress and panic wipe the brokered vault session and its protector key, which is the entire point of +// those buttons — "someone got in, make the vault need a master password again". Before the extraction, +// `auth/signout.ts`, `auth/revoke-handler.ts` and `auth/panic-handler.ts` imported `clearVaultTokens` +// straight from `officerdb` and called it. +// +// A plugin cannot be imported by core, so that had to end. The two alternatives were both worse: +// +// • **Drop the wipe.** A silent security regression, and precisely on the path that exists for the +// worst day. Not a trade a refactor gets to make by itself. +// • **Have core DELETE from the plugin's tables by name.** Removes the import but keeps the coupling +// and moves it 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, not accept. (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. The stakes decide it, not the pattern.) +// +// So: core announces, and 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. +// +// ── What it is deliberately not ── +// +// Not a general event bus, and it should not grow into one. Three events, all meaning "a session ended +// and here is how badly" — anything that is not about an account's session ending belongs elsewhere. +// +// Fire-and-forget by design. The caller is an auth route answering a person who pressed a button, and it +// must not fail, hang or wait because a listener did. A listener that throws is logged and skipped. + +export type AccountLifecycleEvent = + /** Ordinary logout. The mildest: drop live sessions, keep anything that makes signing back in easy. */ + | 'signout' + /** Distress — "someone got in". Drop everything reconstructible, including convenience material. */ + | 'distress' + /** Panic lockdown. Same severity as distress; the server also stops answering until it is restarted. */ + | 'panic'; + +type Listener = (userId: number, event: AccountLifecycleEvent) => void | Promise; + +const listeners = new Set(); + +/** + * Subscribe. Returns an unsubscribe function. + * + * Register from a module the plugin system already imports — a plugin's `api/router.ts` is imported once + * when its routes are mounted, which makes the subscription's lifetime the same as the plugin being + * installed and enabled. + */ +export function onAccountLifecycle(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * Announce. Never throws, never awaited by the caller, and one listener failing cannot stop another. + * + * Deliberately not `await`ed at the call sites: signout returning 200 must not depend on every listener + * having finished, and a listener that hangs must not hang the response. The cost is that a wipe is + * ordered rather than confirmed — acceptable because the token it invalidates is already unusable the + * moment the session it belonged to is blacklisted, which the same handler does synchronously. + */ +export function emitAccountLifecycle(userId: number, event: AccountLifecycleEvent): void { + for (const listener of listeners) { + try { + const result = listener(userId, event); + if (result instanceof Promise) { + result.catch((err: unknown) => { + console.error(`[account-lifecycle] ${event} listener failed:`, err instanceof Error ? err.message : err); + }); + } + } catch (err) { + console.error(`[account-lifecycle] ${event} listener threw:`, err instanceof Error ? err.message : err); + } + } +} diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index 4199ed3d..07132710 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -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({ diff --git a/src/servers/api/auth/panic-handler.ts b/src/servers/api/auth/panic-handler.ts index a89806d7..c20560aa 100644 --- a/src/servers/api/auth/panic-handler.ts +++ b/src/servers/api/auth/panic-handler.ts @@ -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 }); }; diff --git a/src/servers/api/auth/revoke-handler.ts b/src/servers/api/auth/revoke-handler.ts index 531edcf4..fcb39528 100644 --- a/src/servers/api/auth/revoke-handler.ts +++ b/src/servers/api/auth/revoke-handler.ts @@ -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(() => {}); diff --git a/src/servers/api/auth/signout.ts b/src/servers/api/auth/signout.ts index 43a72cc0..beef79f3 100644 --- a/src/servers/api/auth/signout.ts +++ b/src/servers/api/auth/signout.ts @@ -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(() => {}); diff --git a/src/servers/api/vault/broker.ts b/src/servers/api/vault/broker.ts deleted file mode 100644 index f1042362..00000000 --- a/src/servers/api/vault/broker.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { getVaultServerUrl } from './sidecar-server'; - -// Vaultwarden token broker. The platform (not the device) obtains and refreshes the Vaultwarden token via -// the sidecar's `/identity/connect/token`. We deal in the OAuth-style form grants only; the master -// password never appears here — the client sends a pre-derived `authHash` (Bitwarden's master password -// hash), exactly what Vaultwarden itself checks. - -export type ConnectResult = { ok: true; data: Record } | { ok: false; status: number; error: string }; - -async function postConnectToken(form: URLSearchParams): Promise { - const base = getVaultServerUrl(); - if (!base) return { ok: false, status: 503, error: 'vault sidecar unavailable' }; - let res: Response; - try { - res = await fetch(`${base}/identity/connect/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: form.toString(), - signal: AbortSignal.timeout(30_000), - }); - } catch { - return { ok: false, status: 502, error: 'vault upstream unreachable' }; - } - const data = (await res.json().catch(() => ({}))) as Record; - if (!res.ok) { - // Vaultwarden puts the human message in different places (error_description/error are often empty on a - // bad login; the real text is in `message` or `errorModel.message`). Check them all. - const errorModel = data.errorModel as Record | undefined; - const candidates = [data.error_description, errorModel?.message, data.message, data.error]; - const desc = candidates.find((c): c is string => typeof c === 'string' && c.length > 0) ?? 'vault login failed'; - return { ok: false, status: res.status, error: desc }; - } - return { ok: true, data }; -} - -export type PasswordGrantInput = { - email: string; - authHash: string; - clientId: string; - deviceIdentifier: string; - deviceType: string; - deviceName: string; -}; - -/** Owner login: exchange the client-derived auth hash for a Vaultwarden token set. */ -export function passwordGrant(p: PasswordGrantInput): Promise { - const form = new URLSearchParams(); - form.set('grant_type', 'password'); - form.set('username', p.email); - form.set('password', p.authHash); - form.set('scope', 'api offline_access'); // offline_access → a refresh token comes back - form.set('client_id', p.clientId); - form.set('deviceIdentifier', p.deviceIdentifier); - form.set('deviceType', p.deviceType); - form.set('deviceName', p.deviceName); - return postConnectToken(form); -} - -/** Renew the access token from the stored refresh token (no password needed). */ -export function refreshGrant(refreshToken: string, clientId: string): Promise { - const form = new URLSearchParams(); - form.set('grant_type', 'refresh_token'); - form.set('refresh_token', refreshToken); - form.set('client_id', clientId); - return postConnectToken(form); -} - -// Vaultwarden mixes PascalCase and camelCase field names across versions — read a field case-insensitively. -export function pick(data: Record, name: string): unknown { - const lower = name.charAt(0).toLowerCase() + name.slice(1); - const upper = name.charAt(0).toUpperCase() + name.slice(1); - return data[name] ?? data[lower] ?? data[upper]; -} - -/** Compute an absolute expiry from a token response's `expires_in` (seconds), if present. */ -export function expiryFrom(data: Record, now: number): Date | null { - const secs = data.expires_in; - return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null; -} diff --git a/src/servers/api/vault/proxy-util.ts b/src/servers/api/vault/proxy-util.ts deleted file mode 100644 index 5102f832..00000000 --- a/src/servers/api/vault/proxy-util.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Generic proxy header hygiene for the platform-side /api/vault forwarder. The platform knows nothing -// about Vaultwarden — it only forwards faithfully to the officer-vault sidecar (loopback). Bodies are -// never touched; we only drop hop-by-hop headers and redact token-shaped query strings from logs. - -const HOP_BY_HOP = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'host', -]); - -/** Copy headers, dropping only hop-by-hop / proxy-* ones. Authorization + device headers pass through. */ -export function stripHopByHop(src: Headers): Headers { - const out = new Headers(); - src.forEach((value, key) => { - const k = key.toLowerCase(); - if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; - out.set(key, value); - }); - return out; -} - -const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi; -export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1'); diff --git a/src/servers/api/vault/public-router.ts b/src/servers/api/vault/public-router.ts deleted file mode 100644 index a9c34b86..00000000 --- a/src/servers/api/vault/public-router.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { createRouter } from '../../create-router'; -import { getVaultServerUrl } from './sidecar-server'; -import { stripHopByHop, redactPath } from './proxy-util'; - -// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication. -// -// ── Why this exists ── -// -// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own -// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot -// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with -// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds -// no vault credential at all) and an impossible one for a third-party client. -// -// So this mount exists to retire a second public hostname. Andre pointed the extension straight at -// Vaultwarden; that URL can now go away, and the extension can use the platform's. -// -// ── Why it is acceptable to leave unauthenticated ── -// -// It is not a new exposure, it is the same exposure through one door instead of two: everything here was -// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now — -// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden -// client today because it cannot: it has no credential to check. -// -// It is also going behind Tailscale, so the surface is a private network rather than the internet. -// -// ── TEMPORARY ── -// -// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to -// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore) -// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is -// never addressed directly by anything, and this mount is deleted rather than adjusted. -// -// ── The rule that keeps it safe ── -// -// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one -// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The -// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body — -// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable -// without an Officer session, which is the whole reason it must stay boring. - -export const publicVaultRouter = createRouter(); - -const PREFIX = '/vaultwarden'; - -/** - * Paths that belong to Vaultwarden and to nothing else in this platform. - * - * These are served at the ROOT so the extension can be configured with the bare Officer URL — no - * `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them - * collide with an Officer route, so no sniffing is involved; the path alone is the answer. - */ -export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events']; - -/** - * Is this a Bitwarden client talking to us? - * - * Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has - * `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and - * everything else. The path cannot decide it, so the client says who it is: every Bitwarden client - * stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`. - * - * This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the - * worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly - * as it would have. Nothing is authorised by this function. - */ -export const isBitwardenClient = (headers: Headers): boolean => - headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type'); - -/** Everything Vaultwarden serves, whichever door it arrived through. */ -export const isVaultwardenPath = (path: string, headers: Headers): boolean => { - if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true; - return path.startsWith('/api/') && isBitwardenClient(headers); -}; - -publicVaultRouter.all('/*', async (ctx) => { - const base = getVaultServerUrl(); - if (!base) return ctx.text('Vault sidecar not available', 503); - - const url = new URL(ctx.req.url); - // Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there, - // so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path. - const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/'; - const target = `${base}${subpath}${url.search}`; - const method = ctx.req.method; - const hasBody = method !== 'GET' && method !== 'HEAD'; - - // Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden - // token is the only credential in play, and Vaultwarden is the only thing entitled to judge it. - const headers = stripHopByHop(ctx.req.raw.headers); - - const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' }; - if (hasBody) { - init.body = ctx.req.raw.body; - init.duplex = 'half'; - } - - let upstream: Response; - try { - upstream = await fetch(target, init); - } catch { - console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); - return ctx.text('Vault sidecar unreachable', 502); - } - - // No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it - // injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the - // client knows how to handle — it re-authenticates with the master password. - return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); -}); diff --git a/src/servers/api/vault/router.ts b/src/servers/api/vault/router.ts deleted file mode 100644 index 1d899705..00000000 --- a/src/servers/api/vault/router.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { MiddlewareHandler } from 'hono'; -import { createRouter } from '../../create-router'; -import { originMiddleware, userMiddleware } from '../../_middlewares'; -import { isSuperAdmin } from '../../super-admin'; -import * as errors from '../../custom-errors'; -import { getVaultServerUrl } from './sidecar-server'; -import { stripHopByHop, redactPath } from './proxy-util'; -import { passwordGrant, pick, expiryFrom, type PasswordGrantInput } from './broker'; -import { getValidAccessToken, refreshAccess } from './token-store'; -import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey } from 'officerdb'; - -// Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid -// platform session authorizes vault access. This router: -// • gates every /api/vault/* on an owner platform session (userMiddleware, no bodyParser → streaming), -// • serves the native broker/unlock-key endpoints, -// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored -// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten. - -export const vaultRouter = createRouter(); - -const PREFIX = '/api/vault'; - -// Owner-only. The global backstop already blocks non-owner tokens from /api/vault; this is explicit -// defense-in-depth and gives a clear error. -const ownerGate: MiddlewareHandler = async (ctx, next) => { - if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only'); - return next(); -}; - -vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check -vaultRouter.use(userMiddleware); // valid platform session (Authorization: Bearer or ?token=) -vaultRouter.use(ownerGate); - -// ── Native endpoints (registered before the catch-all) ── - -// Reachability probe (owner-gated): forwards to the sidecar's /_health, which probes Vaultwarden's /alive. -vaultRouter.get('/_health', async (ctx) => { - const base = getVaultServerUrl(); - if (!base) return ctx.json({ ok: false, error: 'vault sidecar unavailable' }, 503); - try { - const r = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) }); - const body = await r.json().catch(() => ({ ok: r.ok })); - return Response.json(body, { status: r.status }); - } catch { - return ctx.json({ ok: false, error: 'vault sidecar unreachable' }, 502); - } -}); - -// Token broker — exchange the client-derived auth hash for a Vaultwarden token set (stored server-side), -// and return the encrypted user-key material the client needs to unlock on-device. -vaultRouter.post('/session/login', async (ctx) => { - const user = ctx.get('user'); - const b = (await ctx.req.json().catch(() => ({}))) as Record; - const str = (v: unknown) => (typeof v === 'string' ? v : ''); - const email = str(b.email); - const authHash = str(b.authHash); - if (!email || !authHash) return ctx.json({ ok: false, error: 'email and authHash required' }, 400); - - const clientId = str(b.clientId) || 'mobile'; - const input: PasswordGrantInput = { - email, - authHash, - clientId, - deviceIdentifier: str(b.deviceIdentifier), - deviceType: b.deviceType != null ? String(b.deviceType) : '', - deviceName: str(b.deviceName), - }; - const result = await passwordGrant(input); - if (!result.ok) return Response.json({ ok: false, error: result.error }, { status: result.status }); - - const d = result.data; - const access = pick(d, 'access_token'); - const refresh = pick(d, 'refresh_token'); - if (typeof access !== 'string' || typeof refresh !== 'string') { - return ctx.json({ ok: false, error: 'vault returned no token' }, 502); - } - await setVaultTokens(user.id, { - accessToken: access, - refreshToken: refresh, - expiresAt: expiryFrom(d, Date.now()), - deviceIdentifier: input.deviceIdentifier || null, - clientId, - }); - - // Everything except the transport tokens (which the platform holds) is crypto material the SDK unlocks - // on-device — ciphertext to us. Vaultwarden returns BOTH classic (Key/PrivateKey/Kdf*) and v2 - // (UserDecryptionOptions.MasterPasswordUnlock, AccountKeys); pass the whole native response through - // under `connectToken` so the SDK gets whatever it uses, alongside the spec's named aliases. - const connectToken: Record = { ...d }; - for (const k of ['access_token', 'refresh_token', 'token_type', 'expires_in']) delete connectToken[k]; - return ctx.json({ - ok: true, - protectedUserKey: pick(d, 'Key') ?? null, - privateKey: pick(d, 'PrivateKey') ?? null, - kdf: { - kdf: pick(d, 'Kdf') ?? null, - kdfIterations: pick(d, 'KdfIterations') ?? null, - kdfMemory: pick(d, 'KdfMemory') ?? null, - kdfParallelism: pick(d, 'KdfParallelism') ?? null, - }, - connectToken, - }); -}); - -// Officer-app unlock path: store / release the platform-held protector key (owner session only). -vaultRouter.put('/unlock-key', async (ctx) => { - const user = ctx.get('user'); - const b = (await ctx.req.json().catch(() => ({}))) as Record; - if (typeof b.wrappedKey !== 'string' || !b.wrappedKey) return ctx.json({ error: 'wrappedKey required' }, 400); - await setVaultUnlockKey(user.id, b.wrappedKey); - return ctx.json({ ok: true }); -}); - -vaultRouter.get('/unlock-key', async (ctx) => { - const user = ctx.get('user'); - const wrappedKey = await getVaultUnlockKey(user.id); - if (!wrappedKey) return ctx.json({ error: 'no unlock key stored' }, 404); - return ctx.json({ wrappedKey }); -}); - -// ── Token-injecting proxy → sidecar → Vaultwarden ── -vaultRouter.all('/*', async (ctx) => { - const base = getVaultServerUrl(); - if (!base) return ctx.text('Vault sidecar not available', 503); - - const user = ctx.get('user'); - const url = new URL(ctx.req.url); - const subpath = url.pathname.slice(PREFIX.length) || '/'; - const target = `${base}${subpath}${url.search}`; - const method = ctx.req.method; - const hasBody = method !== 'GET' && method !== 'HEAD'; - const started = Date.now(); - - // Inject a valid Vaultwarden token (proactively refreshed near expiry). No token yet → forward without - // one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401. - const accessToken = await getValidAccessToken(user.id); - - const headers = stripHopByHop(ctx.req.raw.headers); - headers.delete('authorization'); // drop the platform JWT - if (accessToken) headers.set('authorization', `Bearer ${accessToken}`); - - const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' }; - if (hasBody) { - init.body = ctx.req.raw.body; - init.duplex = 'half'; - } - - let upstream: Response; - try { - upstream = await fetch(target, init); - } catch { - console.error(`[vault] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); - return ctx.text('Vault sidecar unreachable', 502); - } - - // A 401 on a replayable (no-body) request → token likely just expired/revoked: refresh once and retry. - if (upstream.status === 401 && !hasBody) { - const ts = await getVaultTokens(user.id); - const fresh = ts ? await refreshAccess(user.id, ts) : null; - if (fresh) { - headers.set('authorization', `Bearer ${fresh}`); - upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream); - } - } - - console.log(`[vault] ${method} ${redactPath(subpath)} -> ${upstream.status} ${Date.now() - started}ms`); - return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); -}); diff --git a/src/servers/api/vault/sidecar-server.ts b/src/servers/api/vault/sidecar-server.ts deleted file mode 100644 index 4a35ff58..00000000 --- a/src/servers/api/vault/sidecar-server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as sidecar from '@@/sidecar-registry'; - -// The officer-vault sidecar starts its Vaultwarden reverse-proxy on a random loopback port and reports it -// here on connect. We remember it so `/api/vault/*` (HTTP) and the notifications WebSocket always forward -// to the current sidecar. The platform holds NO knowledge of Vaultwarden itself — only where the sidecar is. - -let serverPort: number | null = null; - -sidecar.on('vault:server', (msg) => { - const port = (msg as { port?: number }).port; - if (typeof port !== 'number') return; - serverPort = port; - console.log(`[vault] sidecar proxy registered on port ${port}`); -}); - -/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */ -export function getVaultServerUrl(): string | null { - return serverPort ? `http://127.0.0.1:${serverPort}` : null; -} - -/** Same, as a WebSocket base for the notifications hub. */ -export function getVaultServerWsUrl(): string | null { - return serverPort ? `ws://127.0.0.1:${serverPort}` : null; -} diff --git a/src/servers/api/vault/token-store.ts b/src/servers/api/vault/token-store.ts deleted file mode 100644 index 614a030f..00000000 --- a/src/servers/api/vault/token-store.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from 'officerdb'; -import { refreshGrant, pick, expiryFrom } from './broker'; - -// Read-side of the brokered Vaultwarden token: hand out a currently-valid access token, refreshing it -// server-side (with the stored refresh token) when it's near expiry. Shared by the HTTP proxy and the -// notifications-WS proxy so both inject a fresh token. The device never sees any of this. - -const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401 - -/** Renew the access token from the stored refresh token; persist and return it, or null on failure. */ -export async function refreshAccess(userId: number, ts: VaultTokenSet): Promise { - const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile'); - if (!r.ok) return null; - const access = pick(r.data, 'access_token'); - const refresh = pick(r.data, 'refresh_token'); - if (typeof access !== 'string') return null; - await updateVaultAccess( - userId, - access, - typeof refresh === 'string' ? refresh : ts.refreshToken, - expiryFrom(r.data, Date.now()), - ); - return access; -} - -/** A currently-valid Vaultwarden access token for the owner, or null if none is stored. */ -export async function getValidAccessToken(userId: number): Promise { - const ts = await getVaultTokens(userId); - if (!ts) return null; - if (ts.expiresAt && ts.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) { - return (await refreshAccess(userId, ts)) ?? ts.accessToken; - } - return ts.accessToken; -} diff --git a/src/servers/api/vault/websocket.ts b/src/servers/api/vault/websocket.ts deleted file mode 100644 index 6a39a801..00000000 --- a/src/servers/api/vault/websocket.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { ServerWebSocket } from 'bun'; -import { resolveAuthToken } from '../../auth-token'; -import { isTokenBlacklisted } from 'officerdb'; -import { isSuperAdmin } from '../../super-admin'; -import { getVaultServerWsUrl } from './sidecar-server'; -import { getValidAccessToken } from './token-store'; - -// Platform side of the Bitwarden notifications WebSocket. The device connects with its platform JWT (via -// ?access_token=, how SignalR carries the token); we validate that session in `open`, then pipe the socket -// to the officer-vault sidecar with the stored Vaultwarden token injected — the device never holds it. -// Dumb pipe: text + binary frames both ways, no inspection. SignalR's HTTP long-poll fallback rides the -// HTTP proxy (vaultRouter) instead. - -export type VaultWSData = { - provider: 'vault'; - platformToken: string; // the platform JWT the device presented on the upgrade - vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token= - vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar -}; - -type UpstreamState = { - ws: WebSocket | null; - queue: (string | Uint8Array)[]; - ready: boolean; - closed: boolean; -}; -const upstreams = new Map, UpstreamState>(); - -// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is. -const asPayload = (raw: string | Buffer): string | Uint8Array => - typeof raw === 'string' ? raw : (raw as Uint8Array); - -function closeClient(ws: ServerWebSocket, state: UpstreamState, code: number, reason: string) { - state.closed = true; - upstreams.delete(ws); - try { - ws.close(code, reason); - } catch { - /* already closed */ - } -} - -// Swap the platform JWT in the path for the Vaultwarden access token before dialing upstream. -function injectToken(wsBase: string, vaultWsPath: string, vaultToken: string): string { - const qIdx = vaultWsPath.indexOf('?'); - const path = qIdx >= 0 ? vaultWsPath.slice(0, qIdx) : vaultWsPath; - const params = new URLSearchParams(qIdx >= 0 ? vaultWsPath.slice(qIdx + 1) : ''); - params.delete('token'); - params.set('access_token', vaultToken); - return `${wsBase}${path}?${params.toString()}`; -} - -export const vaultWebsocket = { - async open(ws: ServerWebSocket) { - // Register state synchronously so frames sent during the async setup below are buffered, not dropped. - const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false }; - upstreams.set(ws, state); - - // Deferred session validation (Bun requires the upgrade itself to be synchronous). - let userId: number; - try { - const payload = await resolveAuthToken(ws.data.platformToken); - if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized'); - if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized'); - if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden'); - userId = payload.id; - } catch { - return closeClient(ws, state, 4001, 'Unauthorized'); - } - if (state.closed) return; - - const vaultToken = await getValidAccessToken(userId); - if (!vaultToken) return closeClient(ws, state, 4001, 'No vault session'); - const wsBase = getVaultServerWsUrl(); - if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available'); - if (state.closed) return; - - const protocols = ws.data.vaultWsProtocol - ? ws.data.vaultWsProtocol - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : undefined; - const url = injectToken(wsBase, ws.data.vaultWsPath, vaultToken); - const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url); - upstream.binaryType = 'arraybuffer'; - state.ws = upstream; - - upstream.addEventListener('open', () => { - state.ready = true; - for (const m of state.queue) upstream.send(m); - state.queue.length = 0; - }); - upstream.addEventListener('message', (ev) => { - try { - ws.send(ev.data as string | ArrayBuffer); - } catch { - /* client gone */ - } - }); - upstream.addEventListener('close', (ev) => { - upstreams.delete(ws); - try { - ws.close(ev.code || 1000, ev.reason || ''); - } catch { - /* already closed */ - } - }); - upstream.addEventListener('error', () => { - upstreams.delete(ws); - try { - ws.close(1011, 'upstream error'); - } catch { - /* already closed */ - } - }); - }, - message(ws: ServerWebSocket, raw: string | Buffer) { - const state = upstreams.get(ws); - if (!state) return; - const payload = asPayload(raw); - if (state.ready && state.ws) state.ws.send(payload); - else state.queue.push(payload); // buffer until the upstream socket opens - }, - close(ws: ServerWebSocket) { - const state = upstreams.get(ws); - if (state) { - state.closed = true; - try { - state.ws?.close(); - } catch { - /* already closed */ - } - upstreams.delete(ws); - } - }, - drain() {}, -}; - -const PREFIX = '/api/vault'; - -// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query -// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device -// never sends a Vaultwarden token — we inject the stored one upstream. -// -// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation. -// It had defaulted to allow-everything, so it refused nothing on a real install. -export function upgradeVaultWs(req: Request, server: any): Response | undefined { - const url = new URL(req.url); - const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || ''; - if (!platformToken) return new Response('Unauthorized', { status: 401 }); - - const data: VaultWSData = { - provider: 'vault', - platformToken, - vaultWsPath: url.pathname.slice(PREFIX.length) + url.search, - vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined, - }; - const ok = server.upgrade(req, { data }); - if (!ok) return new Response('Upgrade failed', { status: 500 }); - return undefined; -} diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 9609f7ab..b03f3280 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -112,11 +112,10 @@ describe('member provisioning is declared for every entry', () => { for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none'); }); - it('marks the vault as invite-only, because it cannot be otherwise', () => { - // Vaultwarden derives its encryption key from the master password. A credential we could mint is a - // vault we could read, so 'accounts' here would be a security defect rather than a feature. - expect(byId('vault')!.members).toBe('invite'); - }); + // A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become + // `plugins/vault/`. Its reasoning — a service that derives its encryption key from the user's own + // password cannot have credentials minted for it — now belongs to that plugin, and `members.test.ts` + // keeps the 'invite' branch itself covered. }); describe('the UI manifest each sidecar carries', () => { diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 4b44fbe3..c25e8a68 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -229,27 +229,13 @@ export const CATALOGUE: CatalogueEntry[] = [ { key: 'secret', label: 'API token', type: 'secret', required: true }, ], }, - { - id: 'vault', - ui: { name: 'Vault', icon: 'KeyRound', color: '#175ddc', rootRoute: '/vault', routes: ['/vault'] }, - process: 'officer-vault', - label: 'Vault', - summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps', - members: 'invite', - modes: ['existing', 'provisioned'], - // No permission entry exists for this one, and the reason is about CREDENTIALS, not routing. - // - // Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the - // officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly. - // - // What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so - // `userMiddleware` would 401 them and a permission lookup would have no account to resolve. Hence - // `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead. - // The install still governs whether the sidecar runs at all. - permission: null, - composeTemplate: 'vaultwarden', - existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }], - }, + // Vault was here until 2026-08-18, when it became `plugins/vault/`. + // + // Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having + // bitten twice already. `permissionAvailability()` derives from `sidecar_installs`, and a plugin never + // gets a row there (its install state is `plugin_installs`), so a leftover entry would make the + // permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile — + // on a server where the plugin is installed and working. { id: 'transmission', ui: { diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index e7abad9e..c9172d20 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -1,19 +1,25 @@ import { describe, expect, it } from 'bun:test'; import { plannedOutcome, servicesNeedingMemberProvision } from './members'; -import { byId } from './catalogue'; +import { byId, type CatalogueEntry } from './catalogue'; + +// `members: 'invite'` has no catalogue entry as of 2026-08-18 — the vault was the only one, and it left +// to become `plugins/vault/`. The BRANCH is still live code any future entry can reach, so it is tested +// against a synthetic entry rather than dropped: deleting the assertion with the last real user of a +// case is how a switch arm stops being covered without anyone deciding that it should. +const inviteOnly = { members: 'invite' } as CatalogueEntry; describe('what adding a member would do', () => { it('predicts the outcome per service without contacting anything', () => { expect(plannedOutcome(byId('photos')!)).toBe('provisioned'); - expect(plannedOutcome(byId('vault')!)).toBe('invited'); + expect(plannedOutcome(inviteOnly)).toBe('invited'); expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable'); }); }); describe('which installed services have work to do', () => { it('skips single-tenant daemons entirely', () => { - const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'vault']).map((e) => e.id); - expect(ids.sort()).toEqual(['photos', 'vault']); + const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'memos']).map((e) => e.id); + expect(ids.sort()).toEqual(['memos', 'photos']); }); it('ignores services that are not installed', () => { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 2ea82c50..5d55bcb0 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -22,8 +22,6 @@ import { dashboardsRouter } from './api/dashboards'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { devProxyRouter } from './api/dev-proxy/router'; import { pluginsRouter } from './api/plugins/router'; -import { vaultRouter } from './api/vault/router'; -import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; import { agentHandoffRouter } from './api/agent-handoff/router'; // import { slskdRouter } from './api/slskd/router'; // import { transmissionRouter } from './api/transmission/router'; @@ -42,9 +40,6 @@ import { claimIosProfile } from './api/dav/ios-profile'; // import { notifyRouter } from './api/notify/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; -// Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar -// registers its listener when createSidecarProxy runs inside the router this file already imports. -import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; import { queueRouter } from './api/queue/queue'; @@ -175,7 +170,6 @@ export const UNPROTECTED_API_PREFIXES: string[] = [ '/landing-page-data', '/themes', '/waitlist', - '/vault', '/sidecar', '/agent-handoff', '/dev-proxy', @@ -202,31 +196,6 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H app.route('/api/landing-page-data', landingPageDataRouter); app.route('/api/themes', themesRouter); app.route('/api/waitlist', waitlistRouter); - // Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client - // carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The - // notifications WebSocket is upgraded at the serve level (server.tsx). - app.route('/api/vault', vaultRouter); - - // The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at - // this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather - // than a mode of the router above: that one requires an Officer session and swaps the caller's - // Authorization header for a server-held token, and blending the two would put an unauthenticated branch - // inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it - // open is not a new exposure. - app.route('/vaultwarden', publicVaultRouter); - - // …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all. - // - // Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win - // for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and - // to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client - // header. An ordinary Officer request never matches, so nothing that worked before changes. - for (const prefix of VAULT_ONLY_PREFIXES) app.route(prefix, publicVaultRouter); - - app.use('/api/*', async (ctx, next) => { - if (!isBitwardenClient(ctx.req.raw.headers)) return next(); - return publicVaultRouter.fetch(ctx.req.raw, ctx.env); - }); app.get('/api/integrations/google/callback', googleCallbackHandler); // Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index c6a3f514..c25e78ed 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -15,11 +15,13 @@ import { assertPermissionTotality, isExemptApiPath } from './totality'; // half, which is where the rules actually live. Everything here runs without a database. // Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching. -// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. -const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'vault', 'sidecar']; +// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. `vault` left +// on 2026-08-18 with `plugins/vault/`, taking its `/vault` api prefix and its ws provider with it: a +// plugin's routes are mounted behind the account gate like any other, so neither needs an exemption now. +const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'sidecar']; const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))]; const surface = () => ({ - apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'], + apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'], wsProviders: REAL_WS, }); diff --git a/src/servers/permissions/totality.ts b/src/servers/permissions/totality.ts index fe91820c..70ab7ac5 100644 --- a/src/servers/permissions/totality.ts +++ b/src/servers/permissions/totality.ts @@ -28,9 +28,6 @@ const EXEMPT_API_PREFIXES: Record = { // Public marketing surfaces, served to anonymous visitors. '/landing-page-data': 'public landing page content, no account involved', '/waitlist': 'public signup form, no account involved', - // The Bitwarden clients carry their own bearer token, not a platform JWT, so userMiddleware would 401 - // them and a permission lookup has no account to resolve. Gated by origin scoping and Vaultwarden itself. - '/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer', // Registration socket for sidecars. Process-to-process on loopback; there is no user on this path. '/sidecar': 'sidecar registration, loopback process-to-process', // The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so @@ -53,7 +50,6 @@ const EXEMPT_API_PREFIXES: Record = { */ const EXEMPT_WS_PROVIDERS: Record = { sidecar: 'sidecar registration, loopback process-to-process, no user', - vault: 'Vaultwarden notifications hub; upgraded by upgradeVaultWs with a Bitwarden token', // The HMR socket of a dev server being previewed. Upgraded by `upgradeDevProxyWs`, which requires the // same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner // without carrying an account through `upgradeWs`. Refused outright without that cookie. diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index c3047e3b..0d4a9b9e 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -109,6 +109,26 @@ const BUNDLED: CatalogueEntry[] = [ // Email ships all four: the proxy router, `email_accounts`, the sync sidecar, and the mail panels. has: { api: true, schema: true, sidecar: true, web: true }, }, + { + appName: 'vault', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Vault', + summary: 'Your Vaultwarden, behind an Officer session — passwords for the apps that speak Bitwarden', + color: '#175ddc', + icon: 'KeyRound', + source: 'https://gitea.officer.dev/plugins/vault.git', + permissions: [ + { + key: 'vault', + label: 'Vault', + description: 'The password vault, and the Officer-held session that opens it', + }, + ], + // No osDependencies: the sidecar is a Bun HTTP/WS proxy and reaches its upstream with `fetch`. + has: { api: true, schema: true, sidecar: true, web: true }, + }, { appName: 'music', publisher: 'officerdev', diff --git a/src/servers/sidecar/vault/index.ts b/src/servers/sidecar/vault/index.ts deleted file mode 100644 index e683d714..00000000 --- a/src/servers/sidecar/vault/index.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { ServerWebSocket } from 'bun'; -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { createSidecarConnector } from '../connect'; -import { getVaultBase, getVaultWsBase, stripHopByHop, redactPath } from './upstream'; -import { API_URL } from '../../officer-url.mjs'; - -// The officer-vault sidecar. Same philosophy as the other officer-* sidecars: a singleton process that -// registers with the API server and OWNS a contract — here, a transparent reverse-proxy to a self-hosted -// Vaultwarden (Bitwarden server). The platform API is just a thin, origin-gated forwarder to us; ALL -// knowledge of Vaultwarden (its URL, its paths, its notifications WebSocket) lives here, so the vault can -// one day be broken off into its own deployable unit without touching the platform. -// -// HARD RULE: dumb pass-through. The vault is end-to-end encrypted — we NEVER decrypt, parse, rewrite, or -// log request/response bodies. Method, path, query, headers, status and BOTH body streams pass through -// verbatim; nothing is buffered or cached. -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// HTTP CONTRACT — this server proxies to the Vaultwarden ROOT (the platform strips its /api/vault mount -// prefix before forwarding). So `/identity/*`, `/api/*`, `/notifications/*`, `/icons/*`, `/events/*` map -// straight through. `/notifications/hub` upgrades to a WebSocket (SignalR); its long-poll fallback rides -// the normal HTTP path. `GET /_health` is ours (probes Vaultwarden's /alive), not part of the contract. -// The server listens on a random loopback port, reported to the API on connect so it can route here. -// ───────────────────────────────────────────────────────────────────────────────────────────────── - -/** Grab an ephemeral free port by briefly binding one and releasing it. */ -function getFreePort(): number { - const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); - const p = probe.port; - probe.stop(true); - if (p == null) throw new Error('failed to acquire a free port'); - return p; -} - -// ── notifications WebSocket proxy (sidecar ↔ Vaultwarden) ── - -type VaultWSData = { vaultWsPath: string; vaultWsProtocol?: string }; -type UpstreamState = { ws: WebSocket; queue: (string | Uint8Array)[]; ready: boolean }; -const upstreams = new Map, UpstreamState>(); - -// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is. -const asPayload = (raw: string | Buffer): string | Uint8Array => - typeof raw === 'string' ? raw : (raw as Uint8Array); - -const wsHandler = { - open(ws: ServerWebSocket) { - const wsBase = getVaultWsBase(); - if (!wsBase) { - ws.close(1011, 'Vault upstream not configured'); - return; - } - const protocols = ws.data.vaultWsProtocol - ? ws.data.vaultWsProtocol - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : undefined; - const upstream = protocols?.length - ? new WebSocket(`${wsBase}${ws.data.vaultWsPath}`, protocols) - : new WebSocket(`${wsBase}${ws.data.vaultWsPath}`); - upstream.binaryType = 'arraybuffer'; - - const state: UpstreamState = { ws: upstream, queue: [], ready: false }; - upstreams.set(ws, state); - - upstream.addEventListener('open', () => { - state.ready = true; - for (const m of state.queue) upstream.send(m); - state.queue.length = 0; - }); - upstream.addEventListener('message', (ev) => { - try { - ws.send(ev.data as string | ArrayBuffer); - } catch { - /* client gone */ - } - }); - upstream.addEventListener('close', (ev) => { - upstreams.delete(ws); - try { - ws.close(ev.code || 1000, ev.reason || ''); - } catch { - /* already closed */ - } - }); - upstream.addEventListener('error', () => { - upstreams.delete(ws); - try { - ws.close(1011, 'upstream error'); - } catch { - /* already closed */ - } - }); - }, - message(ws: ServerWebSocket, raw: string | Buffer) { - const state = upstreams.get(ws); - if (!state) return; - const payload = asPayload(raw); - if (state.ready) state.ws.send(payload); - else state.queue.push(payload); // buffer until the upstream socket opens - }, - close(ws: ServerWebSocket) { - const state = upstreams.get(ws); - if (state) { - try { - state.ws.close(); - } catch { - /* already closed */ - } - upstreams.delete(ws); - } - }, - drain() {}, -}; - -// ── HTTP + WS server ── - -const port = getFreePort(); - -const server = Bun.serve({ - port, - hostname: '127.0.0.1', - idleTimeout: 255, // sync + attachments can idle; Bun caps this at 255s - maxRequestBodySize: 1024 * 1024 * 1024 * 2, // attachments - async fetch(req, server) { - const url = new URL(req.url); - - // Reachability probe — ours, never touches vault data. - if (url.pathname === '/_health') { - const base = getVaultBase(); - if (!base) return Response.json({ ok: false, error: 'VAULTWARDEN_URL not configured' }, { status: 503 }); - const started = Date.now(); - try { - const r = await fetch(`${base}/alive`, { method: 'GET', signal: AbortSignal.timeout(5000) }); - return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started }); - } catch { - return Response.json({ ok: false, error: 'upstream unreachable', ms: Date.now() - started }, { status: 502 }); - } - } - - // Notifications hub: upgrade the WebSocket here (piped to Vaultwarden by wsHandler); the SignalR - // long-poll fallback is not an upgrade, so it falls through to the HTTP proxy below. - if (url.pathname.startsWith('/notifications/') && req.headers.get('upgrade') === 'websocket') { - const ok = server.upgrade(req, { - data: { - vaultWsPath: url.pathname + url.search, - vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined, - } satisfies VaultWSData, - }); - return ok ? undefined : new Response('Upgrade failed', { status: 500 }); - } - - // Faithful HTTP pass-through to Vaultwarden. - const base = getVaultBase(); - if (!base) return new Response('Vault upstream not configured', { status: 503 }); - - const target = `${base}${url.pathname}${url.search}`; - const method = req.method; - const hasBody = method !== 'GET' && method !== 'HEAD'; - const started = Date.now(); - - // Bun/undici require half-duplex to stream a request body straight through (attachments can be large). - const init: RequestInit & { duplex?: 'half' } = { - method, - headers: stripHopByHop(req.headers), - body: hasBody ? req.body : undefined, - redirect: 'manual', // a transparent proxy passes 3xx through rather than following them - }; - if (hasBody) init.duplex = 'half'; - - let upstream: Response; - try { - upstream = await fetch(target, init); - } catch { - console.error(`[vault] ${method} ${redactPath(url.pathname + url.search)} -> upstream unreachable`); - return new Response('Vault upstream unreachable', { status: 502 }); - } - - // Log at most method / path / status / duration — never bodies, tokens, or Authorization. - console.log(`[vault] ${method} ${redactPath(url.pathname)} -> ${upstream.status} ${Date.now() - started}ms`); - - return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); - }, - websocket: wsHandler, -}); - -console.log(`[vault] reverse-proxy listening on 127.0.0.1:${port} -> ${getVaultBase() ?? '(VAULTWARDEN_URL unset)'}`); - -// ── Command handlers ── - -type ReplyFn = (msg: SidecarEvent) => void; - -function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { - switch (cmd.type) { - case 'ping': - reply({ type: 'pong', id: cmd.id }); - break; - default: - reply({ - type: 'error', - id: (cmd as SidecarCommand).id, - error: `Unknown command type: ${(cmd as Record).type}`, - }); - } -} - -// ── Connect to API server ── - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'vault', - handles: ['vault'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, - onConnected() { - // Tell the API where our proxy is listening, so it can forward /api/vault/* here. - connection.send({ type: 'vault:server', port }); - console.log(`[vault] reported proxy port ${port} to API`); - }, -}); - -// ── Graceful shutdown ── - -function shutdown(signal: string) { - console.log(`[vault] ${signal} received, shutting down...`); - try { - server.stop(true); - } catch { - /* already stopped */ - } - connection.destroy(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/vault/upstream.ts b/src/servers/sidecar/vault/upstream.ts deleted file mode 100644 index 72549f70..00000000 --- a/src/servers/sidecar/vault/upstream.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Vaultwarden upstream config + header hygiene for the officer-vault sidecar. -// -// HARD RULE (see BITWARDEN_SIDECAR_PROMPT.md): the vault is end-to-end encrypted. Nothing here — or in -// the sidecar that uses it — may decrypt, parse, rewrite, or log request/response BODIES. We only compute -// the upstream base URL and strip hop-by-hop headers. Bytes pass through untouched. - -const { VAULTWARDEN_URL } = process.env; - -let warnedUnset = false; - -/** The Vaultwarden base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */ -export function getVaultBase(): string | null { - const raw = VAULTWARDEN_URL?.trim(); - if (!raw) { - if (!warnedUnset) { - console.warn('[vault] VAULTWARDEN_URL is unset — the sidecar will respond 503 until it is set'); - warnedUnset = true; - } - return null; - } - return raw.replace(/\/+$/, ''); -} - -/** The same base as a WebSocket scheme (http→ws, https→wss) for the notifications hub. */ -export function getVaultWsBase(): string | null { - const base = getVaultBase(); - return base ? base.replace(/^http/i, 'ws') : null; -} - -// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `upgrade` is handled by the dedicated -// WebSocket path; `host` is dropped so the outgoing fetch sets the upstream authority itself. Everything -// else — crucially Authorization, Content-Type, and Bitwarden device headers — is forwarded verbatim. -const HOP_BY_HOP = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'host', -]); - -/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */ -export function stripHopByHop(src: Headers): Headers { - const out = new Headers(); - src.forEach((value, key) => { - const k = key.toLowerCase(); - if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; - out.set(key, value); - }); - return out; -} - -// Redact anything token-shaped from a path+query before it reaches a log line. -const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi; -export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1');