Files
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

195 lines
6.8 KiB
TypeScript

import {
BackendError,
type Balances,
type BackendKind,
type Capability,
type Channel,
type CreateInvoiceRequest,
type DecodedInvoice,
type FeeEstimates,
type Invoice,
type KeysendRequest,
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
type PayInvoiceRequest,
type Payment,
type Peer,
type SendCoinsRequest,
type SendCoinsResult,
type SignMessageResult,
type Utxo,
type VerifyMessageResult,
type WalletBackend,
type AddressType,
} from '../types';
/**
* Shared backend base. Declares its capability set once and turns every unimplemented method into a
* clean 501 — the counterpart to Zeus's `BackendUtils.call()` returning `false` for a missing method
* (utils/BackendUtils.ts:56-58), but loud instead of silent.
*/
export abstract class BaseBackend implements WalletBackend {
abstract readonly kind: BackendKind;
protected abstract readonly capabilities: ReadonlySet<Capability>;
supports(cap: Capability): boolean {
return this.capabilities.has(cap);
}
protected notSupported(op: string): never {
throw new BackendError(`${this.kind} does not support ${op}`, 501, 'NOT_SUPPORTED');
}
abstract getInfo(): Promise<NodeInfo>;
abstract getBalances(): Promise<Balances>;
getTransactions(_opts?: { limit?: number }): Promise<OnchainTx[]> {
return this.notSupported('on-chain transaction history');
}
getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
return this.notSupported('on-chain receive');
}
getUtxos(): Promise<Utxo[]> {
return this.notSupported('coin control');
}
estimateFees(): Promise<FeeEstimates> {
return this.notSupported('fee estimation');
}
sendCoins(_req: SendCoinsRequest): Promise<SendCoinsResult> {
return this.notSupported('on-chain send');
}
getInvoices(_opts?: { limit?: number }): Promise<Invoice[]> {
return this.notSupported('invoice listing');
}
createInvoice(_req: CreateInvoiceRequest): Promise<Invoice> {
return this.notSupported('lightning receive');
}
lookupInvoice(_paymentHash: string): Promise<Invoice | null> {
return this.notSupported('invoice lookup');
}
decodeInvoice(_bolt11: string): Promise<DecodedInvoice> {
return this.notSupported('invoice decoding');
}
getPayments(_opts?: { limit?: number }): Promise<Payment[]> {
return this.notSupported('payment history');
}
payInvoice(_req: PayInvoiceRequest): Promise<Payment> {
return this.notSupported('lightning send');
}
sendKeysend(_req: KeysendRequest): Promise<Payment> {
return this.notSupported('keysend');
}
getChannels(): Promise<Channel[]> {
return this.notSupported('channels');
}
getPeers(): Promise<Peer[]> {
return this.notSupported('peers');
}
signMessage(_message: string): Promise<SignMessageResult> {
return this.notSupported('message signing');
}
verifyMessage(_message: string, _signature: string): Promise<VerifyMessageResult> {
return this.notSupported('message verification');
}
}
// ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
// Zeus reaches its REST backends through `react-native-blob-util` with a `trusty: true` flag, which
// disables TLS verification wholesale so a node with a self-signed cert is reachable (backends/LND.ts).
// Bun's fetch takes an equivalent per-request `tls` option, so no extra HTTP client is needed.
//
// Verification is only relaxed when the owner explicitly opts in for a specific node, and it is a real
// trade: it buys reachability for a node with a self-signed cert at the cost of MITM protection on that
// connection. Acceptable over loopback or a tailnet, not over the open internet — which is why it is a
// per-wallet flag the owner sets deliberately, never a default.
type BunFetchInit = RequestInit & { tls?: { rejectUnauthorized?: boolean } };
export type HttpOptions = {
method?: string;
path: string;
base: string;
headers?: Record<string, string>;
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
allowSelfSigned?: boolean;
timeoutMs?: number;
/** Return the raw Response instead of parsed JSON. */
raw?: boolean;
};
const DEFAULT_TIMEOUT_MS = 30_000;
/** The single door every REST backend goes through. Normalizes errors into BackendError. */
export async function httpJson<T = unknown>(opts: HttpOptions): Promise<T> {
const base = opts.base.replace(/\/+$/, '');
const url = new URL(`${base}${opts.path.startsWith('/') ? opts.path : `/${opts.path}`}`);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers };
let body: string | undefined;
if (opts.body !== undefined) {
body = JSON.stringify(opts.body);
headers['Content-Type'] ??= 'application/json';
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const init: BunFetchInit = {
method: opts.method ?? 'GET',
headers,
body,
signal: controller.signal,
};
if (opts.allowSelfSigned) init.tls = { rejectUnauthorized: false };
let res: Response;
try {
res = await fetch(url, init);
} catch (err) {
const msg = controller.signal.aborted ? `timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : String(err);
throw new BackendError(`request to ${url.host} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
} finally {
clearTimeout(timer);
}
if (opts.raw) return res as unknown as T;
const text = await res.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
if (!res.ok) throw new BackendError(`upstream ${res.status}: ${text.slice(0, 200)}`, res.status);
throw new BackendError(`upstream returned non-JSON: ${text.slice(0, 200)}`, 502);
}
}
if (!res.ok) {
// LND puts {error, code}; CLN puts {message}; LNDHub puts {error, message}. Try all three.
const p = parsed as { error?: string; message?: string; code?: number } | null;
const msg = p?.error ?? p?.message ?? `upstream returned ${res.status}`;
throw new BackendError(msg, res.status, p?.code != null ? String(p.code) : undefined);
}
return parsed as T;
}
/** Hex → base64. LND's REST API takes bytes fields base64-encoded; callers hold hex. */
export function hexToBase64(hex: string): string {
return Buffer.from(hex, 'hex').toString('base64');
}
/** Base64 → hex, for reading LND's bytes fields back out. */
export function base64ToHex(b64: string): string {
return Buffer.from(b64, 'base64').toString('hex');
}