vault, extracted into plugins/vault — the platform half

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/<name>, 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:43:32 +00:00
co-authored by Claude Opus 5
parent c775e8c7e3
commit f6a1194905
27 changed files with 141 additions and 1150 deletions
-236
View File
@@ -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<ArrayBuffer>)[]; ready: boolean };
const upstreams = new Map<ServerWebSocket<VaultWSData>, 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<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
const wsHandler = {
open(ws: ServerWebSocket<VaultWSData>) {
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<VaultWSData>, 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<VaultWSData>) {
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<VaultWSData>({
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<string, unknown>).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'));
-58
View File
@@ -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<redacted>');