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

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

Two things changed rather than moved:

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

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

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

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

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

106 lines
4.8 KiB
TypeScript

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<Cached> | null = null;
async function read(): Promise<Cached> {
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<Cached> {
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<string | null> {
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<string | null> {
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<redacted>');