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.
125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
import type { BitcoinNetwork } from './types';
|
|
import { getServiceCredentials } from 'officerdb';
|
|
import { getKey } from 'officerdb/secret-store';
|
|
|
|
// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub
|
|
// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in
|
|
// Postgres (databases/officer_db/src/wallet/schema.ts), not here. Env holds only what is genuinely
|
|
// deployment-wide: which chain we're on.
|
|
//
|
|
// WHERE CHAIN DATA COMES FROM IS NOT ENV. It used to be WALLET_ESPLORA_URL, which meant the one setting
|
|
// an owner actually has to change — off the public explorer, onto their own indexer — was the one
|
|
// setting they could only change with a shell and a restart. It now lives in `service_connections`
|
|
// under 'esplora' and is edited from Wallet → Settings; see getChainSource below.
|
|
|
|
const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest'];
|
|
|
|
// mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up
|
|
// — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a
|
|
// custody one. It never sees a private key and cannot authorize anything.
|
|
const DEFAULT_ESPLORA: Record<BitcoinNetwork, string> = {
|
|
bitcoin: 'https://mempool.space/api',
|
|
testnet: 'https://mempool.space/testnet/api',
|
|
signet: 'https://mempool.space/signet/api',
|
|
regtest: 'http://127.0.0.1:3002',
|
|
};
|
|
|
|
export type WalletConfig = {
|
|
network: BitcoinNetwork;
|
|
esploraUrl: string;
|
|
unlockTtlSec: number;
|
|
};
|
|
|
|
let warned = false;
|
|
|
|
export function getConfig(): WalletConfig {
|
|
const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin';
|
|
const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin';
|
|
if (raw && network !== raw && !warned) {
|
|
console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`);
|
|
warned = true;
|
|
}
|
|
|
|
const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
|
|
|
return {
|
|
network,
|
|
/** The built-in default, used until the owner stores one. `getChainSource` is the real answer. */
|
|
esploraUrl: DEFAULT_ESPLORA[network],
|
|
// Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely.
|
|
unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Which protocol the endpoint speaks. Not interchangeable — see chain-source.ts for why the two are
|
|
* different shapes rather than two URLs for the same thing.
|
|
*/
|
|
export const CHAIN_KINDS = ['nbxplorer', 'esplora'] as const;
|
|
|
|
export type ChainKind = (typeof CHAIN_KINDS)[number];
|
|
|
|
export type ChainSource = {
|
|
kind: ChainKind;
|
|
/** API root, no trailing slash. */
|
|
baseUrl: string;
|
|
/** True when this is the owner's stored endpoint rather than the built-in public default. */
|
|
custom: boolean;
|
|
};
|
|
|
|
/** Short TTL: a save invalidates explicitly, so this only bounds how stale a *concurrent* reader can be. */
|
|
const CHAIN_TTL_MS = 60_000;
|
|
const chainCache = new Map<number, { source: ChainSource; at: number }>();
|
|
|
|
export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/\/+$/, '');
|
|
|
|
/**
|
|
* Where to read the chain, for this owner.
|
|
*
|
|
* AT MOST ONE of the two rows exists: saving either kind retires the other (config.ts::save), because a
|
|
* wallet reads from one place and storing two would leave the answer to precedence rather than to the
|
|
* owner. The order below is therefore a tiebreak that should never be needed, not a preference.
|
|
*
|
|
* Falls back to the public default rather than failing: a wallet with no stored endpoint is still a
|
|
* working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address
|
|
* it asks about. The default is a starting point, not a destination.
|
|
*/
|
|
export async function getChainSource(userId: number): Promise<ChainSource> {
|
|
const hit = chainCache.get(userId);
|
|
if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source;
|
|
|
|
const source = await readChainSource(userId);
|
|
chainCache.set(userId, { source, at: Date.now() });
|
|
return source;
|
|
}
|
|
|
|
async function readChainSource(userId: number): Promise<ChainSource> {
|
|
for (const kind of CHAIN_KINDS) {
|
|
const creds = await getServiceCredentials(userId, kind);
|
|
if (creds?.url) return { kind, baseUrl: normalizeEsploraUrl(creds.url), custom: true };
|
|
}
|
|
return { kind: 'esplora', baseUrl: getConfig().esploraUrl, custom: false };
|
|
}
|
|
|
|
export function invalidateChainSource(userId: number): void {
|
|
chainCache.delete(userId);
|
|
}
|
|
|
|
/**
|
|
* Whether the wallet's at-rest key is usable. The sidecar can serve a locked, watch-only view without
|
|
* one, but every write path touching an encrypted column throws, so /_health reports it explicitly
|
|
* rather than letting the first wallet creation fail with a confusing crypto error.
|
|
*
|
|
* This tested `VAULT_STORE_KEY` in the environment until 2026-08-13. The key now lives in the secret
|
|
* store under the `wallet` purpose and is CREATED ON FIRST USE — so the honest question is no longer
|
|
* "did somebody set a variable" but "can this process open the store at all". A false here means the
|
|
* file is unreachable or unwritable, which is an install problem rather than a configuration one.
|
|
*/
|
|
export function hasStoreKey(): boolean {
|
|
try {
|
|
return getKey('wallet').length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|