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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user