import { getVaultConfig } from '../db/queries'; // Vaultwarden upstream config + header hygiene for the officer-vault sidecar. // // HARD RULE: 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. // // ── The URL comes from the database now, not VAULTWARDEN_URL ── // // It was `process.env.VAULTWARDEN_URL`, read once at module load, until the plugin extraction on // 2026-08-18. See `db/schema.ts` → `vaultConfig` for why it moved. The consequence here is the only // interesting part: a value that used to be constant for the process lifetime is now something the owner // can change from a settings panel while the sidecar is running. // // ── Why a cache with a TTL, rather than reading per request ── // // This sits in front of every proxied byte, including attachment uploads. A Postgres round trip per // request would put the database on the hot path of a reverse-proxy whose entire design rule is that it // does as little as possible. So the answer is cached. // // The TTL is what makes "change it in the UI" work without a restart, and 10 seconds is chosen against // what the value actually is: a URL the owner edits by hand, roughly never, and then wants to see take // effect. Ten seconds is imperceptible to someone who just pressed Save and free the rest of the time. // // The TTL is the WHOLE mechanism, deliberately — there is no push. `sidecar-registry.ts` carries events // from a sidecar to the platform and typed helpers for the claude/opencode/vnc ones, but no generic // "send this command to the sidecar named X", so the config route in the other process has nothing to // call. Adding one for a value that changes twice a year would be a channel built for a cache hint. // // A failed read keeps serving the last known-good value rather than dropping to null. A database blip // must not turn into "vault not configured", which is indistinguishable on screen from the owner having // never set it up. const TTL_MS = 10_000; type Cached = { url: string | null; enabled: boolean; at: number }; let cache: Cached | null = null; let inflight: Promise | null = null; async function read(): Promise { try { const config = await getVaultConfig(); cache = { url: config.url, enabled: config.enabled, at: Date.now() }; return cache; } catch (err) { console.error(`[vault] could not read config: ${err instanceof Error ? err.message : String(err)}`); // Keep serving what we had. Only a cold cache (no successful read ever) reports "unconfigured". if (cache) return { ...cache, at: Date.now() }; return { url: null, enabled: true, at: Date.now() }; } } async function current(): Promise { if (cache && Date.now() - cache.at < TTL_MS) return cache; // One read at a time. Under load every request would otherwise miss the same expired entry and fire its // own query — the stampede that makes a cache slower than no cache. inflight ??= read().finally(() => { inflight = null; }); return inflight; } /** The Vaultwarden base URL (no trailing slash), or null when unconfigured/disabled (the sidecar 503s). */ export async function getVaultBase(): Promise { const { url, enabled } = await current(); if (!enabled) return null; return url || null; } /** The same base as a WebSocket scheme (http→ws, https→wss) for the notifications hub. */ export async function getVaultWsBase(): Promise { const base = await 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');