add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getWalletServerUrl } from './sidecar-server';
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
export const walletRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/wallet';
|
||||
|
||||
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) });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
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