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.
201 lines
8.9 KiB
TypeScript
201 lines
8.9 KiB
TypeScript
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
|
import { invalidateAll } from './resolve';
|
|
import type { ChainKind } from './upstream';
|
|
import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
|
|
import type { BitcoinNetwork } from './types';
|
|
|
|
// `/_config` — which endpoint this owner's on-chain wallets read the chain from, and which protocol it
|
|
// speaks.
|
|
//
|
|
// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every
|
|
// on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated
|
|
// read APIs. It went from an env var to a row for one reason — it is the setting most likely to need
|
|
// changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you
|
|
// own) shows up long after the deployment that set it.
|
|
//
|
|
// TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive:
|
|
// saving one deletes the other, so "which endpoint is in use" never depends on precedence. See
|
|
// upstream.ts::getChainSource.
|
|
//
|
|
// PRIVACY, NOT CUSTODY. A chain endpoint learns which addresses you ask about, so pointing this at your
|
|
// own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and a
|
|
// hostile one can lie about balances but cannot move a coin.
|
|
|
|
export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number };
|
|
|
|
/** What each kind reports itself as, versus what this deployment is configured for. */
|
|
const NETWORK_ALIASES: Record<BitcoinNetwork, readonly string[]> = {
|
|
bitcoin: ['mainnet', 'main', 'bitcoin'],
|
|
testnet: ['testnet', 'test'],
|
|
signet: ['signet'],
|
|
regtest: ['regtest'],
|
|
};
|
|
|
|
/** Does this endpoint answer, does it speak the protocol we think, and is it on OUR chain? */
|
|
export async function probe(baseUrl: string, kind: ChainKind): Promise<ChainProbe> {
|
|
return kind === 'nbxplorer' ? probeNbxplorer(baseUrl) : probeEsplora(baseUrl);
|
|
}
|
|
|
|
/**
|
|
* `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a
|
|
* bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an
|
|
* nginx default page) fails here rather than at the first address scan, which is the whole point: the
|
|
* owner finds out while looking at the form.
|
|
*
|
|
* Esplora does not say which chain it serves, so unlike NBXplorer there is nothing to cross-check here.
|
|
*/
|
|
async function probeEsplora(baseUrl: string): Promise<ChainProbe> {
|
|
const started = Date.now();
|
|
try {
|
|
const res = await fetch(`${baseUrl}/blocks/tip/height`, {
|
|
headers: { Accept: 'text/plain' },
|
|
signal: AbortSignal.timeout(8_000),
|
|
redirect: 'manual',
|
|
});
|
|
const body = (await res.text()).trim();
|
|
if (!res.ok) {
|
|
return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
|
|
}
|
|
const height = Number(body);
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return {
|
|
ok: false,
|
|
error: `that URL answered, but not like an Esplora API (expected a block height, got "${body.slice(0, 60)}")`,
|
|
ms: Date.now() - started,
|
|
};
|
|
}
|
|
return { ok: true, blockHeight: height, ms: Date.now() - started };
|
|
} catch (err) {
|
|
return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `/v1/cryptos/BTC/status` — NBXplorer's own liveness endpoint, and richer than Esplora's.
|
|
*
|
|
* It names the chain it is indexing, so a testnet NBXplorer accepted for a mainnet wallet is caught HERE
|
|
* rather than by the owner wondering why their balance is zero. That mistake is easy to make (one docker
|
|
* flag) and expensive to discover late, so the mismatch is a hard failure, not a warning.
|
|
*
|
|
* A node still catching up is allowed through with its height reported: it will be correct shortly, and
|
|
* refusing to save a config because of a temporary sync state would be its own kind of wrong.
|
|
*/
|
|
async function probeNbxplorer(baseUrl: string): Promise<ChainProbe> {
|
|
const started = Date.now();
|
|
try {
|
|
const res = await fetch(`${baseUrl}/v1/cryptos/BTC/status`, {
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(8_000),
|
|
redirect: 'manual',
|
|
});
|
|
const body = (await res.text()).trim();
|
|
if (!res.ok) {
|
|
return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
|
|
}
|
|
|
|
let status: { chainHeight?: unknown; networkType?: unknown; isFullySynched?: unknown };
|
|
try {
|
|
status = JSON.parse(body) as typeof status;
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
error: `that URL answered, but not like NBXplorer (expected JSON, got "${body.slice(0, 60)}")`,
|
|
ms: Date.now() - started,
|
|
};
|
|
}
|
|
|
|
const height = Number(status.chainHeight);
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return { ok: false, error: 'that endpoint answered but reported no chain height', ms: Date.now() - started };
|
|
}
|
|
|
|
const want = getConfig().network;
|
|
const got = String(status.networkType ?? '').toLowerCase();
|
|
if (got && !NETWORK_ALIASES[want].includes(got)) {
|
|
return {
|
|
ok: false,
|
|
error: `that NBXplorer is indexing ${got}, but this wallet is on ${want}. Refusing to point a ${want} wallet at a ${got} node.`,
|
|
ms: Date.now() - started,
|
|
};
|
|
}
|
|
|
|
return { ok: true, blockHeight: height, ms: Date.now() - started };
|
|
} catch (err) {
|
|
return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
|
|
}
|
|
}
|
|
|
|
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
|
|
|
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
|
|
|
/**
|
|
* The endpoint in use, which protocol it speaks, and whether it is the owner's or the built-in default.
|
|
* No secrets exist here at all.
|
|
*
|
|
* `esploraUrl` is kept as a field name despite now possibly holding an NBXplorer URL: it is the URL in
|
|
* use, and renaming it would break the stored dashboard state of anything already reading it. `kind`
|
|
* says what it actually is.
|
|
*/
|
|
async function state(userId: number): Promise<Response> {
|
|
const source = await getChainSource(userId);
|
|
const connection = source.custom ? await getServiceConnection(userId, source.kind) : null;
|
|
return Response.json({
|
|
configured: !!connection,
|
|
connection,
|
|
kind: source.kind,
|
|
esploraUrl: source.baseUrl,
|
|
custom: source.custom,
|
|
defaultUrl: getConfig().esploraUrl,
|
|
network: getConfig().network,
|
|
});
|
|
}
|
|
|
|
type ConfigBody = { url?: unknown; kind?: unknown };
|
|
|
|
const isChainKind = (v: unknown): v is ChainKind =>
|
|
typeof v === 'string' && (CHAIN_KINDS as readonly string[]).includes(v);
|
|
|
|
async function save(req: Request, userId: number): Promise<Response> {
|
|
const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
|
|
const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : '';
|
|
// Absent kind means a client written before the picker existed; those only ever spoke Esplora.
|
|
const kind: ChainKind = isChainKind(body.kind) ? body.kind : 'esplora';
|
|
|
|
if (!url) return bad('url is required');
|
|
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
|
|
|
const result = await probe(url, kind);
|
|
if (!result.ok) return bad(result.error ?? 'that endpoint did not answer');
|
|
|
|
await saveServiceConnection({ userId, service: kind, url });
|
|
// Exactly one chain source exists at a time. Retiring the other kind here is what lets getChainSource
|
|
// answer without a precedence rule, and what stops a stale row from resurfacing if this one is deleted.
|
|
for (const other of CHAIN_KINDS) if (other !== kind) await deleteServiceConnection(userId, other);
|
|
|
|
invalidateChainSource(userId);
|
|
// Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken
|
|
// through it. Both are now wrong, so drop the instances rather than trying to patch them.
|
|
invalidateAll();
|
|
|
|
return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result });
|
|
}
|
|
|
|
/** `/_config` — GET what is in use, PUT to change it, DELETE to fall back to the built-in default. */
|
|
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
|
if (subpath && subpath !== '/') return bad('not found', 404);
|
|
|
|
if (req.method === 'GET') return state(userId);
|
|
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
|
|
|
if (req.method === 'DELETE') {
|
|
// Both, not just the active one: "reset to default" has to mean it regardless of which row exists.
|
|
for (const kind of CHAIN_KINDS) await deleteServiceConnection(userId, kind);
|
|
invalidateChainSource(userId);
|
|
invalidateAll();
|
|
return state(userId);
|
|
}
|
|
|
|
return bad('method not allowed', 405);
|
|
}
|