Files
platform/src/servers/sidecar/wallet/routes.ts
T
pastilhasandClaude Opus 5 e0f6a469aa rate-limit every passphrase check, and stop address issuance outrunning the scan
two holes found auditing the wallet sidecar after the frozen-utxo fix.

the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".

nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:41:46 +00:00

611 lines
26 KiB
TypeScript

import {
listWallets,
getWallet,
getSealedSeed,
createWallet,
updateWallet,
replaceSealedSeed,
setActiveWallet,
deleteWallet,
getActiveWallet,
getWalletLabels,
setWalletLabel,
getFrozenOutpoints,
setUtxoFrozen,
type WalletKind,
} from 'officerdb';
import { resolveBackend, invalidate } from './resolve';
import {
changePassphrase,
deriveAccountXpubs,
exportMnemonic,
generateSeed,
sealSeed,
sessionFor,
verifyPassphrase,
type SeedEnvelope,
} from './keys';
import { getConfig, hasStoreKey } from './upstream';
import {
asAddressType,
BackendError,
WalletLockedError,
BIP_ADDRESS_TYPE,
type BitcoinNetwork,
type Capability,
type SyncState,
type Utxo,
type WalletBackend,
} from './types';
// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User,
// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the
// sidecar binds loopback only.
//
// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a
// wallet named "unlock" can never shadow the unlock endpoint.
//
// SECURITY NOTES that apply to this whole file:
// - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase,
// except /export-seed, which exists for backup and demands the passphrase every single time.
// - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs
// the method and path only, deliberately not the body.
// - Capability checks happen before dispatch so an unsupported operation is a clean 501.
export type OfficerContext = { req: Request; url: URL; userId: number };
function json(data: unknown, status = 200): Response {
return Response.json(data as Record<string, unknown>, { status });
}
function badRequest(message: string): Response {
return json({ error: message }, 400);
}
async function body<T>(req: Request): Promise<T> {
try {
return (await req.json()) as T;
} catch {
throw new BackendError('expected a JSON body', 400, 'BAD_BODY');
}
}
/**
* Freshness of the data a read just returned, or null from a backend that has no cache to be stale.
* Attached to balances, transactions and utxos so the UI can distinguish an empty wallet from one it
* could not reach — those rendered identically before, as a zero balance.
*
* Must be read AFTER the response it describes: it reports the snapshot that was actually served.
*/
function syncOf(backend: WalletBackend): SyncState | null {
return backend.getSyncState?.() ?? null;
}
/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
if (!backend.supports(cap)) {
throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED');
}
}
// ── entry point ──────────────────────────────────────────────────────────────────────────────────
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
const officerUser = req.headers.get('X-Officer-User');
if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401);
const userId = Number(officerUser);
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
if (segments.length === 0) return null;
const ctx: OfficerContext = { req, url, userId };
try {
switch (segments[0]) {
case 'config':
return handleConfig();
case 'wallets':
return await handleWallets(ctx, segments.slice(1));
default:
return null;
}
} catch (err) {
if (err instanceof BackendError) {
return json({ error: err.message, code: err.code }, err.status);
}
throw err;
}
}
/**
* Non-secret deployment facts the UI needs before any wallet exists.
*
* No esplora URL: that is per-owner now and lives at `/_config`, which is also the only place that can
* say whether it is the owner's endpoint or the fallback. Reporting the default here would have shown
* the wrong URL to anyone who had set their own.
*/
function handleConfig(): Response {
const cfg = getConfig();
return json({
network: cfg.network,
unlockTtlSec: cfg.unlockTtlSec,
// The UI blocks wallet creation on this rather than letting the first write fail on a crypto error.
storeKeyConfigured: hasStoreKey(),
});
}
// ── /wallets ─────────────────────────────────────────────────────────────────────────────────────
const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Response | null> {
const { req, userId } = ctx;
// /_officer/wallets
if (seg.length === 0) {
if (req.method === 'GET') return json({ wallets: await listWallets(userId) });
if (req.method === 'POST') return await createWalletRoute(ctx);
return json({ error: 'method not allowed' }, 405);
}
// /_officer/wallets/active — resolves to whichever wallet is currently selected
if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') {
const active = await getActiveWallet(userId);
return json({ wallet: active });
}
const walletId = Number(seg[0]);
if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id');
const rest = seg.slice(1);
// /_officer/wallets/:id
if (rest.length === 0) {
if (req.method === 'GET') {
const wallet = await getWallet(userId, walletId);
return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404);
}
if (req.method === 'PATCH') {
const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(req);
// A rename goes through the same rule as a create: trimmed, and never blank. Without this a stray
// empty string would leave a wallet with no name anywhere in the UI and no way to type one back.
if (patch.name !== undefined) {
const name = patch.name.trim();
if (!name) return badRequest('name cannot be empty');
if (name.length > 64) return badRequest('name is too long (64 characters max)');
patch.name = name;
}
// Rebuilt field by field rather than forwarded: `body<T>` is a cast, so the parsed object holds
// whatever the caller sent, not what the type says. updateWallet can no longer write the seed
// either — that is the belt to this brace.
const updated = await updateWallet(userId, walletId, {
name: patch.name,
defaultBip: patch.defaultBip,
config: patch.config,
});
invalidate(walletId);
return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
}
if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId);
return json({ error: 'method not allowed' }, 405);
}
// Seed / lock lifecycle — matched before the generic operations below.
switch (rest[0]) {
case 'activate':
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
await setActiveWallet(userId, walletId);
return json({ ok: true });
case 'lock-state': {
const wallet = await getWallet(userId, walletId);
if (!wallet) return json({ error: 'wallet not found' }, 404);
const session = sessionFor(walletId);
return json({
hasSeed: wallet.hasSeed,
unlocked: session.isUnlocked(),
secondsRemaining: session.secondsRemaining(),
});
}
case 'unlock':
return await unlockRoute(ctx, walletId);
case 'lock':
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
sessionFor(walletId).lock();
return json({ ok: true, unlocked: false });
case 'passphrase':
return await changePassphraseRoute(ctx, walletId);
case 'export-seed':
return await exportSeedRoute(ctx, walletId);
}
// Everything else needs a live backend.
const { wallet, backend } = await resolveBackend(userId, walletId);
switch (rest[0]) {
case 'capabilities': {
const all: Capability[] = [
'onchainReceive',
'onchainSend',
'coinControl',
'psbt',
'bumpFee',
'sweep',
'accounts',
'lightningReceive',
'lightningSend',
'keysend',
'customPreimages',
'offers',
'channels',
'peers',
'routing',
'signMessage',
];
return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) });
}
case 'info':
return json({ info: await backend.getInfo() });
case 'balances':
return json({ balances: await backend.getBalances(), sync: syncOf(backend) });
case 'transactions': {
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
const txs = await backend.getTransactions({ limit });
// Overlay owner labels, which live in Officer's DB rather than any backend.
const labels = await getWalletLabels(walletId);
const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
return json({
transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })),
sync: syncOf(backend),
});
}
case 'address': {
requireCap(backend, 'onchainReceive', 'receiving on-chain');
const peek = ctx.url.searchParams.get('peek') === 'true';
// An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain
// backend would always fall back to its own preference order (native segwit), silently ignoring an
// owner who set the wallet to taproot or legacy.
const asked = ctx.url.searchParams.get('type');
if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`);
const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)];
return json(await backend.getNewAddress({ peek, type }));
}
case 'utxos':
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
// POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that
// rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it
// was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op.
case 'rescan': {
if (ctx.req.method === 'GET') return json({ rescan: syncOf(backend)?.rescan ?? null });
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
if (!backend.startRescan) {
throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
}
return json({ rescan: await backend.startRescan() });
}
case 'fees':
return json({ fees: await backend.estimateFees() });
case 'send': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'onchainSend', 'sending on-chain');
// The lock gate comes before body validation, not after. The backend checks it too, but only once
// the request has already passed every field check here — so a locked wallet was answering "your
// fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path.
if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError();
const req2 = await body<Parameters<WalletBackend['sendCoins']>[0]>(ctx.req);
if (!req2.address) return badRequest('address is required');
if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
// Never spend a frozen coin, even if the caller passed no explicit outpoint list.
const frozenList = await getFrozenOutpoints(walletId);
const frozen = new Set(frozenList);
if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
// Spread last so a caller cannot supply its own frozen list: the check above only ever covered
// outpoints the caller named explicitly, which left automatic selection free to pick a frozen coin.
const result = await backend.sendCoins({ ...req2, frozenOutpoints: frozenList });
if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
return json(result);
}
case 'invoices':
return await invoicesRoute(ctx, backend, rest.slice(1));
case 'decode': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { bolt11 } = await body<{ bolt11?: string }>(ctx.req);
if (!bolt11) return badRequest('bolt11 is required');
return json({ decoded: await backend.decodeInvoice(bolt11) });
}
case 'payments': {
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
return json({ payments: await backend.getPayments({ limit }) });
}
case 'pay': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'lightningSend', 'paying lightning invoices');
const payReq = await body<Parameters<WalletBackend['payInvoice']>[0]>(ctx.req);
if (!payReq.bolt11) return badRequest('bolt11 is required');
if (payReq.feeLimitMsat && payReq.feeLimitPercent) {
return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive');
}
return json({ payment: await backend.payInvoice(payReq) });
}
case 'keysend': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'keysend', 'keysend');
const ks = await body<Parameters<WalletBackend['sendKeysend']>[0]>(ctx.req);
if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required');
return json({ payment: await backend.sendKeysend(ks) });
}
case 'channels':
requireCap(backend, 'channels', 'channels');
return json({ channels: await backend.getChannels() });
case 'peers':
requireCap(backend, 'peers', 'peers');
return json({ peers: await backend.getPeers() });
case 'sign': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'signMessage', 'message signing');
const { message } = await body<{ message?: string }>(ctx.req);
if (!message) return badRequest('message is required');
return json(await backend.signMessage(message));
}
case 'verify': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'signMessage', 'message verification');
const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req);
if (!message || !signature) return badRequest('message and signature are required');
return json(await backend.verifyMessage(message, signature));
}
case 'labels': {
if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) });
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req);
if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"');
if (!ref) return badRequest('ref is required');
await setWalletLabel(walletId, kind, ref, label ?? '');
return json({ ok: true });
}
default:
return null;
}
}
// ── wallet lifecycle ─────────────────────────────────────────────────────────────────────────────
type CreateBody = {
name?: string;
kind?: string;
network?: string;
/** onchain only: omit `mnemonic` to generate a fresh seed. */
mnemonic?: string;
words?: 12 | 24;
passphrase?: string;
bip39Passphrase?: string;
defaultBip?: number;
config?: Record<string, unknown>;
makeActive?: boolean;
};
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
if (!hasStoreKey()) {
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
}
const b = await body<CreateBody>(ctx.req);
if (!b.name?.trim()) return badRequest('name is required');
// Same cap as the rename route, so a name you can create is always a name you can type back.
if (b.name.trim().length > 64) return badRequest('name is too long (64 characters max)');
if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) {
return badRequest(`kind must be one of ${KINDS.join(', ')}`);
}
const kind = b.kind as WalletKind;
const network = (b.network ?? getConfig().network) as BitcoinNetwork;
// Remote-node wallets: store the connection config, no seed involved.
if (kind !== 'onchain') {
if (!b.config) return badRequest(`${kind} wallets require a config object`);
const wallet = await createWallet({
userId: ctx.userId,
name: b.name.trim(),
kind,
network,
config: b.config,
makeActive: b.makeActive ?? true,
});
return json({ wallet }, 201);
}
// Self-custodial on-chain wallet: seal a seed under the owner passphrase.
if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet');
const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24);
const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase);
const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network);
const wallet = await createWallet({
userId: ctx.userId,
name: b.name.trim(),
kind,
network,
sealedSeed: JSON.stringify(envelope),
fingerprint,
xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])),
defaultBip: b.defaultBip ?? 84,
makeActive: b.makeActive ?? true,
});
// An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream
// only watches an account from the moment it is registered, so a wallet with a history would otherwise
// show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it
// would burn several minutes of the node's CPU to confirm nothing.
if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id);
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
}
/**
* Start a rescan behind the response that created the wallet.
*
* Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a
* successful wallet import into a failed one. The owner can trigger it by hand from Settings either way.
*/
async function kickOffRescan(userId: number, walletId: number): Promise<void> {
try {
const { backend } = await resolveBackend(userId, walletId);
await backend.startRescan?.();
} catch (err) {
console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err);
}
}
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
const wallet = await getWallet(ctx.userId, walletId);
if (!wallet) return json({ error: 'wallet not found' }, 404);
// Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
// passphrase, even when the wallet is already unlocked — an open session must not be enough.
if (wallet.hasSeed) {
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
const sealed = await getSealedSeed(ctx.userId, walletId);
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) {
return json({ error: 'incorrect passphrase' }, 401);
}
}
sessionFor(walletId).lock();
invalidate(walletId);
const deleted = await deleteWallet(ctx.userId, walletId);
return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404);
}
// ── lock lifecycle ───────────────────────────────────────────────────────────────────────────────
async function loadEnvelope(userId: number, walletId: number): Promise<SeedEnvelope> {
const sealed = await getSealedSeed(userId, walletId);
if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED');
return JSON.parse(sealed) as SeedEnvelope;
}
async function unlockRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required');
const env = await loadEnvelope(ctx.userId, walletId);
const session = sessionFor(walletId);
const max = getConfig().unlockTtlSec;
// A caller may shorten the window but never extend it past the deployment's configured maximum.
await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max));
return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() });
}
async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req);
if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');
const env = await loadEnvelope(ctx.userId, walletId);
const resealed = await changePassphrase(walletId, env, oldPassphrase, newPassphrase);
await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed));
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
sessionFor(walletId).lock();
return json({ ok: true });
}
async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required');
const env = await loadEnvelope(ctx.userId, walletId);
const mnemonic = await exportMnemonic(walletId, env, passphrase);
console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
}
// ── utxos ────────────────────────────────────────────────────────────────────────────────────────
async function utxosRoute(
ctx: OfficerContext,
walletId: number,
backend: WalletBackend,
seg: string[],
): Promise<Response> {
requireCap(backend, 'coinControl', 'coin control');
if (seg[0] === 'freeze') {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req);
if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"');
await setUtxoFrozen(walletId, outpoint, frozen !== false, reason);
return json({ ok: true });
}
const [utxos, frozenList, labels] = await Promise.all([
backend.getUtxos(),
getFrozenOutpoints(walletId),
getWalletLabels(walletId),
]);
const frozen = new Set(frozenList);
const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label]));
// The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent
// across every backend, including ones with no freeze concept of their own.
const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({
...u,
frozen: frozen.has(`${u.txid}:${u.vout}`),
label: byAddr.get(u.address) ?? null,
}));
return json({ utxos: merged, sync: syncOf(backend) });
}
// ── invoices ─────────────────────────────────────────────────────────────────────────────────────
async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise<Response> {
// /_officer/wallets/:id/invoices/:paymentHash
if (seg.length === 1) {
const invoice = await backend.lookupInvoice(seg[0]!);
return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404);
}
if (ctx.req.method === 'GET') {
requireCap(backend, 'lightningReceive', 'lightning');
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
return json({ invoices: await backend.getInvoices({ limit }) });
}
if (ctx.req.method === 'POST') {
requireCap(backend, 'lightningReceive', 'creating invoices');
const req = await body<Parameters<WalletBackend['createInvoice']>[0]>(ctx.req);
if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages');
return json({ invoice: await backend.createInvoice(req) }, 201);
}
return json({ error: 'method not allowed' }, 405);
}