add a factory for sidecar proxy routers

eight sidecars hand-rolled the same port capture — byte-identical once
the app name is normalised — and six repeated the same auth-and-forward
router. createSidecarProxy collapses both into one call and covers the
variants the others need: a ws:// url for music and vault, an onRegister
hook for opencode.

the wallet adopts it first: two files become one, 54 lines of router
become 16, and hono no longer needs a side-effect import to capture the
port. the no-body-parsing, no-body-logging rule moves into the factory
with its rationale, since that restraint is what keeps unlock
passphrases and macaroons out of the platform process.

costs 31 net lines today and pays back from the second adopter on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 07:28:24 +00:00
co-authored by Claude Opus 5
parent b442084618
commit 13b7f56b0f
4 changed files with 103 additions and 70 deletions
+11 -49
View File
@@ -1,54 +1,16 @@
import { createRouter } from '../../create-router';
import { getWalletServerUrl } from './sidecar-server';
import { createSidecarProxy } from '../../sidecar/create-proxy';
// Thin reverse-proxy for /api/wallet/*. The platform's ONLY job here is AUTH + FORWARDING: this router
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards
// the subpath + query + body to the officer-wallet sidecar, which owns every wallet contract and holds
// the key material.
// /api/wallet/* — auth, then forward to officer-wallet. No routes of its own, and no wallet knowledge
// whatsoever: not a seed, not a node credential, not an xpub. The platform cannot spend and cannot read a
// balance except by asking the sidecar.
//
// A catch-all with no routes of its own. This file must never grow wallet logic — and for this sidecar
// that rule carries more weight than usual. The platform process is long-lived, restarts on every
// deploy, and is the largest attack surface in the system. Keeping it structurally incapable of seeing a
// seed, a macaroon, or an unlock passphrase is the entire design.
//
// The unlock passphrase DOES transit this proxy on its way to the sidecar. That is unavoidable — the
// browser has to send it somewhere — but it is forwarded as an opaque body and never logged, never
// parsed, and never retained here. Note the deliberate absence of any body inspection below.
// The unlock passphrase DOES transit this proxy — the browser has to send it somewhere — but it is
// forwarded as an opaque body and never parsed, logged or retained. See sidecar/create-proxy.ts, which
// deliberately performs no body inspection; that restraint matters more for this sidecar than any other.
export const walletRouter = createRouter();
const proxy = createSidecarProxy({ name: 'wallet', prefix: '/api/wallet' });
const PREFIX = '/api/wallet';
export const walletRouter = proxy.router;
walletRouter.all('/*', async (ctx) => {
const baseUrl = getWalletServerUrl();
if (!baseUrl) return ctx.text('wallet sidecar not available', 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(PREFIX.length) || '/';
const target = `${baseUrl}${subpath}${url.search}`;
const method = ctx.req.method;
const headers: Record<string, string> = {};
const contentType = ctx.req.header('content-type');
if (contentType) headers['Content-Type'] = contentType;
// Forward the authenticated user id so the sidecar can scope every wallet to its owner. The sidecar
// binds loopback only, so this header is trusted.
headers['X-Officer-User'] = String(ctx.get('user').id);
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
// Deliberately logs the target path only — never the body, which may carry a passphrase.
console.error('[wallet] proxy fetch failed', { target, error: String(err) });
return ctx.text('wallet sidecar unreachable', 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getWalletServerUrl = proxy.getHttpUrl;
-20
View File
@@ -1,20 +0,0 @@
import * as sidecar from '@@/sidecar-registry';
// The officer-wallet sidecar starts its HTTP server on a random loopback port and reports it here on
// connect. We remember it so `/api/wallet/*` always forwards to the current sidecar. The platform holds
// NO wallet knowledge whatsoever — not a seed, not a node credential, not an xpub. It cannot spend, and
// it cannot read a balance except by asking the sidecar.
let serverPort: number | null = null;
sidecar.on('wallet:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
console.log(`[wallet] sidecar registered on port ${port}`);
});
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
export function getWalletServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}