diff --git a/.env.example b/.env.example index 57ca74c..f4d6fa1 100644 --- a/.env.example +++ b/.env.example @@ -156,6 +156,12 @@ FEDIMINT_OBSERVER_URL=https://observer.fedimint.org/api/federations # which is the literal BACKEND.md behaviour and ranks noticeably worse. SCORE_PRIOR_MEAN=3 +# How many mints one address may submit to `POST /api/index` in an hour. Ten is several +# times what any honest use of the review dialog needs; raise it while exercising the +# whole flow end to end, which trips ten in about a minute. Behind a proxy, the limiter +# can only tell visitors apart if `X-Forwarded-For` is forwarded (see README, "API"). +# INDEX_RATE_LIMIT=10 + # ─── Tooling ───────────────────────────────────────────────────────────────── # Dev server Boneyard captures against (`pnpm bones`). Defaults to WEB_PORT. diff --git a/api/package.json b/api/package.json index 0a4829a..908f6da 100644 --- a/api/package.json +++ b/api/package.json @@ -11,7 +11,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --env-file-if-exists=../.env --experimental-strip-types src/check.ts", "test:warnings": "node --env-file-if-exists=../.env --experimental-strip-types src/check-warnings.ts", - "test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts" + "test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts", + "test:lnurl": "node --env-file-if-exists=../.env --experimental-strip-types src/check-lnurl.ts", + "test:index": "node --experimental-strip-types src/check-index.ts" }, "dependencies": { "@cashumints/shared": "workspace:*", diff --git a/api/src/announce.ts b/api/src/announce.ts new file mode 100644 index 0000000..94315ef --- /dev/null +++ b/api/src/announce.ts @@ -0,0 +1,253 @@ +/** + * Publishing `kind:38174` announcements for LNURL mints this indexer has confirmed. + * + * **This writes to public relays.** Nostr has no delete that anyone is obliged to + * honour, so an event published here is on the network permanently, signed by this + * site's key and attributed to it. That is the whole reason for the gating below, and + * the reason it is two switches rather than one. + * + * Why publish at all: 38174 is a proposed kind (see `docs/KIND-LNURL-MINT.md`) and a + * proposed kind with no events is a document rather than a protocol. An indexer that + * has already probed a mint and confirmed what it serves is the one party in a position + * to put honest announcements on the network before any operator has heard of the kind. + * + * ## What is announced, and what deliberately is not + * + * Only what the probe actually **observed**. The vocabulary has eleven values; this + * publishes at most seven of them, and the four it never publishes are the point: + * + * - `rotate`, `split`, `merge` — these are only provable by calling `/w/cb`, which + * mutates or destroys a note. Not probeable, not published, even though the one + * implementation in existence supports all three unconditionally. Inferring them + * from a version string would be publishing a guess under this site's signature. + * - `lud21` — genuinely undetectable: verify-disabled and unknown-payment-hash return + * byte-identical responses. See NOTES-LNURL.md §5. + * + * An operator's own announcement can and should claim more; theirs is a statement about + * what they built, this is a statement about what was seen. When both exist the newer + * `created_at` wins, which is normal addressable-event behaviour and means an operator + * takes over their own listing simply by publishing one. + */ +import { finalizeEvent, getPublicKey, type EventTemplate } from 'nostr-tools/pure'; +import { SimplePool } from 'nostr-tools/pool'; +import * as nip19 from 'nostr-tools/nip19'; +import { + KIND_LNURL_ANNOUNCEMENT, lnurlIdentifier, observedFeatures, type LnurlFields, +} from '@cashumints/shared'; +import { announceConfig } from './config.ts'; +import { getDb, getState, setState } from './db.ts'; +import { log } from './log.ts'; +import { parseEcosystem, type MintRow } from './mints.ts'; + +/** + * Republish interval. + * + * An addressable event is replaced, not duplicated, so republishing is cheap — but it + * is still traffic to somebody else's relay and a new `created_at` on every cycle would + * make this site's announcement permanently outrank an operator's own. Content changes + * are published immediately; an unchanged announcement is refreshed once a week, which + * keeps it from ageing out of relays that prune. + */ +const REPUBLISH_AFTER_S = 7 * 24 * 60 * 60; + +/** State-table key holding what was last published for one mint, and when. */ +const stateKey = (url: string): string => `announce:${url}`; + +/** + * The secret key to sign with, as 32 bytes. + * + * Accepts an `nsec1…` or bare hex. Returns null — with a loud log line rather than a + * throw — for anything else: a misconfigured key must stop announcements and must not + * stop the indexer, which has a directory to serve either way. + */ +export function parseAnnounceKey(raw: string): Uint8Array | null { + const value = raw.trim(); + if (!value) return null; + + if (value.startsWith('nsec1')) { + try { + const decoded = nip19.decode(value); + if (decoded.type === 'nsec') return decoded.data; + } catch { + return null; + } + return null; + } + + if (!/^[0-9a-f]{64}$/i.test(value)) return null; + return Uint8Array.from(Buffer.from(value, 'hex')); +} + +/** + * The `features` this site is willing to sign for, from one mint's stored probe. + * + * Thin, and deliberately: the rule about what a probe may claim lives in + * `observedFeatures` in shared/, next to the vocabulary it draws from, so the list the + * page renders and the list this signs cannot drift apart. See that function for why + * `rotate`, `split`, `merge` and `lud21` are never among them. + */ +export function announcedFeatures(fields: LnurlFields): string[] { + return observedFeatures({ + fundingAvailable: fields.funding_available, + maxWithdrawableMsat: fields.max_withdrawable_msat, + maxSendableMsat: fields.max_sendable_msat, + lightningAddress: fields.lightning_address, + mintPubkey: fields.mint_pubkey, + onionUrl: fields.onion_url, + }); +} + +/** The event this site would publish for one confirmed LNURL mint. */ +export function announcementTemplate( + row: MintRow, + fields: LnurlFields, + createdAt: number, +): EventTemplate { + const tags: string[][] = [ + ['d', lnurlIdentifier(fields.base_url, fields.mint_pubkey)], + ['u', fields.base_url], + ]; + + const features = announcedFeatures(fields); + if (features.length > 0) tags.push(['features', features.join(',')]); + // Only when the mint said so. Absent reads as mainnet per the kind document, and + // asserting mainnet on a mint that never claimed a network would be this site + // inventing the one fact that decides whether the money is real. + if (fields.network) tags.push(['n', fields.network]); + + /* + * `content` is left empty unless the mint published a name of its own. + * + * Empty content means "use the publisher's kind 0", per NIP-87 and the kind document + * — and the publisher here is this site, whose kind 0 is this site. That is the + * honest default for an announcement this site wrote: it is not the mint speaking. + * A name the mint itself served (its node alias) is worth passing on, and nothing else + * from the row is, because everything else came from a previous announcement. + */ + const content = row.name ? JSON.stringify({ name: row.name }) : ''; + + return { kind: KIND_LNURL_ANNOUNCEMENT, created_at: createdAt, tags, content }; +} + +/** Stable fingerprint of an announcement's meaning, for the change detector. */ +function fingerprint(template: EventTemplate): string { + return JSON.stringify([template.tags, template.content]); +} + +/** + * Which rows are eligible. + * + * Confirmed by probing, and only that: `status = 'online'` with a base URL and a + * withdraw ceiling means an advertisement genuinely parsed at some point. A mint that + * has never answered, is offline, or is only "responding but invalid" is never + * announced — this site does not put its signature on the existence of something it has + * not seen. + * + * A `degraded-funding` mint **is** announced: it is up and serving, and the reduced + * `features` list already tells the whole story. + */ +function eligible(row: MintRow, fields: LnurlFields | null): fields is LnurlFields { + if (row.type !== 'lnurl' || row.status !== 'online') return false; + if (!fields?.base_url) return false; + if (fields.invalid_reason) return false; + return fields.max_withdrawable_msat !== null; +} + +export interface AnnounceResult { + eligible: number; + published: number; + skipped: number; + failed: number; +} + +/** + * Publish an announcement for every confirmed LNURL mint that needs one. + * + * Returns counts rather than throwing: a relay refusing an event is not a reason for a + * probe cycle to fail. Does nothing at all, and says why once, when announcing is off — + * which is the default and is how every deployment that has not opted in behaves. + */ +export async function announceLnurlMints( + now = Math.floor(Date.now() / 1000), +): Promise { + const empty: AnnounceResult = { eligible: 0, published: 0, skipped: 0, failed: 0 }; + const settings = announceConfig(); + if (!settings) return empty; + + const secret = parseAnnounceKey(settings.secretKey); + if (!secret) { + log.error('announce disabled: ANNOUNCE_KEY is not an nsec or 64 hex characters'); + return empty; + } + + const db = await getDb(); + const rows = await db.all(`SELECT * FROM mints WHERE type = 'lnurl'`); + + const pool = new SimplePool(); + const result: AnnounceResult = { ...empty }; + + try { + for (const row of rows) { + const fields = parseEcosystem(row); + if (!eligible(row, fields)) continue; + result.eligible++; + + const template = announcementTemplate(row, fields, now); + const mark = fingerprint(template); + + const previous = await getState(stateKey(row.url)); + if (previous) { + try { + const { mark: lastMark, at } = JSON.parse(previous) as { mark: string; at: number }; + if (lastMark === mark && now - at < REPUBLISH_AFTER_S) { + result.skipped++; + continue; + } + } catch { + // Unreadable marker: republish, which is the safe direction. + } + } + + const event = finalizeEvent(template, secret); + + /* + * `Promise.allSettled`, not `Promise.all`: one relay refusing the event (rate + * limits, a paid-relay policy, a write it does not accept) must not stop the + * others from taking it. One acceptance is a successful publish. + */ + const outcomes = await Promise.allSettled(pool.publish(settings.relays, event)); + const accepted = outcomes.filter((o) => o.status === 'fulfilled').length; + + if (accepted === 0) { + result.failed++; + log.warn('announcement rejected by every relay', { + url: row.url, + d: template.tags[0]?.[1], + relays: settings.relays.length, + }); + continue; + } + + await setState(stateKey(row.url), JSON.stringify({ mark, at: now })); + result.published++; + log.info('announced lnurl mint', { + url: row.url, + d: template.tags[0]?.[1], + features: template.tags.find((t) => t[0] === 'features')?.[1] ?? '', + relays: `${accepted}/${settings.relays.length}`, + event: event.id, + }); + } + } finally { + try { + pool.close(settings.relays); + } catch { + // A relay that is already gone throws on close. Nothing to do about it. + } + } + + if (result.eligible > 0) { + log.info('announce cycle', { ...result, pubkey: getPublicKey(secret) }); + } + return result; +} diff --git a/api/src/check-index.ts b/api/src/check-index.ts new file mode 100644 index 0000000..bd70089 --- /dev/null +++ b/api/src/check-index.ts @@ -0,0 +1,474 @@ +/** + * pnpm --filter ./api test:index + * + * The checks for on-demand indexing (`POST /api/index`). Same shape as `check.ts`: no + * framework, throws on the first failure, prints a count. + * + * Three things are being defended here, and they are in descending order of how bad it + * would be to get them wrong: + * + * 1. **SSRF.** This is the one endpoint that fetches an address a stranger chose, so + * every refusal it makes is asserted against a resolver and a fetch that this file + * controls. Nothing here touches the network: a rule that can only be exercised by + * pointing the test at a real host is a rule that stops being exercised the first + * time CI runs offline. + * 2. **Slug collapse.** Two spellings of one mint must never become two rows with half + * its reviews on each. That is the bug the normalizer was written for, and this + * endpoint is a new way to reintroduce it — a reader can now type the spelling + * discovery never saw. + * 3. **Invite decoding**, which is what makes a Fedimint submission possible at all. + */ +import assert from 'node:assert/strict'; +import { + checkIndexInput, + federationIdFromInviteCode, + isNut06Info, + isPrivateIpAddress, + lnurlKey, + normalizeMintUrl, + typeForPath, +} from '@cashumints/shared'; +import { checkDestination, safeFetchText, type FetchDeps } from './safe-fetch.ts'; +import { RATE_LIMIT, resetRateLimits, takeToken } from './rate-limit.ts'; + +/** A real code off the relay pool, the same one `check.ts` parses an announcement from. */ +const REAL_INVITE = + 'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' + + 'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg'; + +let checks = 0; +function check(name: string, fn: () => void): void { + try { + fn(); + checks++; + } catch (err) { + console.error(`FAIL: ${name}`); + throw err; + } +} + +async function checkAsync(name: string, fn: () => Promise): Promise { + try { + await fn(); + checks++; + } catch (err) { + console.error(`FAIL: ${name}`); + throw err; + } +} + +/* ---------- address rules ---------- */ + +check('every private, loopback and link-local range is refused', () => { + for (const address of [ + '127.0.0.1', '127.1.2.3', '10.0.0.1', '10.255.255.255', + '172.16.0.1', '172.20.10.5', '172.31.255.255', + '192.168.0.1', '192.168.1.5', + '169.254.169.254', // the cloud metadata endpoint, the reason this exists + '0.0.0.0', '100.64.0.1', // this-network and carrier-grade NAT + '224.0.0.1', '255.255.255.255', + '::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1', + ]) { + assert.equal(isPrivateIpAddress(address), true, `${address} must be refused`); + } +}); + +check('ordinary public addresses are not', () => { + for (const address of ['1.1.1.1', '8.8.8.8', '157.245.26.63', '172.15.0.1', '172.32.0.1', '2606:4700::1111']) { + assert.equal(isPrivateIpAddress(address), false, `${address} must be allowed`); + } +}); + +await checkAsync('a URL whose hostname is a private literal never reaches DNS', async () => { + // If any of these consulted the resolver, this one would throw rather than answer. + const explode: FetchDeps = { + resolve: () => { + throw new Error('a literal address must not be resolved'); + }, + }; + + for (const url of [ + 'https://127.0.0.1/v1/info', + 'https://10.0.0.1/v1/info', + 'https://172.16.4.4/v1/info', + 'https://192.168.1.5/v1/info', + 'https://169.254.169.254/latest/meta-data/', + 'https://[::1]/v1/info', + 'https://localhost/v1/info', + 'https://mint.local/v1/info', + 'https://abcdefghij234567.onion/v1/info', + ]) { + const verdict = await checkDestination(new URL(url), explode); + assert.equal(verdict?.kind, 'blocked', `${url} must be refused`); + } +}); + +await checkAsync('a public-looking name that resolves privately is refused', async () => { + const deps: FetchDeps = { resolve: async () => ['10.0.0.5'] }; + const verdict = await checkDestination(new URL('https://internal.example.com'), deps); + assert.equal(verdict?.kind, 'blocked'); + + // One private answer among several is enough: the socket would pick one of them. + const mixed: FetchDeps = { resolve: async () => ['93.184.216.34', '127.0.0.1'] }; + assert.equal((await checkDestination(new URL('https://mixed.example.com'), mixed))?.kind, 'blocked'); + + const public_: FetchDeps = { resolve: async () => ['93.184.216.34'] }; + assert.equal(await checkDestination(new URL('https://mint.example.com'), public_), null); +}); + +await checkAsync('a name that does not resolve is unresolved, not blocked', async () => { + // The distinction the rugged-mint case turns on: a mint whose operator let the domain + // lapse must reach the Nostr lookup, not be rejected as an inadmissible address. + const gone: FetchDeps = { resolve: async () => null }; + const verdict = await checkDestination(new URL('https://gone.example.com'), gone); + assert.equal(verdict?.kind, 'unresolved'); + + const outcome = await safeFetchText( + 'https://gone.example.com/v1/info', + { accept: 'application/json' }, + { ...gone, fetchImpl: (() => { throw new Error('must not connect'); }) as unknown as typeof fetch }, + ); + assert.equal(outcome.state, 'unreachable'); +}); + +await checkAsync('http is refused outright: this endpoint is https only', async () => { + assert.equal((await checkDestination(new URL('http://mint.example.com')))?.kind, 'blocked'); + assert.equal((await checkDestination(new URL('ftp://mint.example.com')))?.kind, 'blocked'); +}); + +/* ---------- redirects ---------- */ + +/** A fetch that answers from a table, and records every URL it was asked for. */ +function scriptedFetch(routes: Record): { fetch: typeof fetch; seen: string[] } { + const seen: string[] = []; + const impl = (async (input: unknown): Promise => { + const url = String(input); + seen.push(url); + const res = routes[url]; + if (!res) throw new Error(`unexpected fetch of ${url}`); + return res; + }) as typeof fetch; + return { fetch: impl, seen }; +} + +await checkAsync('a redirect to a private address is refused before it is fetched', async () => { + const { fetch: impl, seen } = scriptedFetch({ + 'https://mint.example.com/v1/info': new Response(null, { + status: 302, + headers: { location: 'https://169.254.169.254/latest/meta-data/' }, + }), + }); + + const outcome = await safeFetchText( + 'https://mint.example.com/v1/info', + { accept: 'application/json' }, + { fetchImpl: impl, resolve: async () => ['93.184.216.34'] }, + ); + + assert.equal(outcome.state, 'blocked'); + assert.match(outcome.state === 'blocked' ? outcome.reason : '', /redirected/); + // The crux: the metadata endpoint was never connected to, only reasoned about. + assert.deepEqual(seen, ['https://mint.example.com/v1/info']); +}); + +await checkAsync('a redirect to a name that resolves privately is refused too', async () => { + const { fetch: impl, seen } = scriptedFetch({ + 'https://mint.example.com/v1/info': new Response(null, { + status: 301, + headers: { location: 'https://internal.example.com/v1/info' }, + }), + }); + + const outcome = await safeFetchText( + 'https://mint.example.com/v1/info', + { accept: 'application/json' }, + { + fetchImpl: impl, + resolve: async (host) => (host === 'mint.example.com' ? ['93.184.216.34'] : ['10.1.2.3']), + }, + ); + + assert.equal(outcome.state, 'blocked'); + assert.equal(seen.length, 1, 'the private hop must never be fetched'); +}); + +await checkAsync('two redirects are followed, a third is not', async () => { + const ok = { 'content-type': 'application/json' }; + const routes: Record = { + 'https://a.example.com/v1/info': new Response(null, { + status: 302, + headers: { location: 'https://b.example.com/v1/info' }, + }), + 'https://b.example.com/v1/info': new Response(null, { + status: 302, + headers: { location: 'https://c.example.com/v1/info' }, + }), + 'https://c.example.com/v1/info': new Response('{"name":"ok"}', { headers: ok }), + }; + const deps = { resolve: async () => ['93.184.216.34'] }; + + const two = await safeFetchText( + 'https://a.example.com/v1/info', + { accept: 'application/json' }, + { ...deps, fetchImpl: scriptedFetch(routes).fetch }, + ); + assert.equal(two.state, 'ok', 'two hops are within the cap'); + + const deeper = { + ...routes, + 'https://c.example.com/v1/info': new Response(null, { + status: 302, + headers: { location: 'https://d.example.com/v1/info' }, + }), + }; + const three = scriptedFetch(deeper); + const over = await safeFetchText( + 'https://a.example.com/v1/info', + { accept: 'application/json' }, + { ...deps, fetchImpl: three.fetch }, + ); + assert.equal(over.state, 'unreachable'); + assert.equal(three.seen.length, 3, 'the fourth address is never fetched'); +}); + +await checkAsync('a body over the cap and a body of the wrong type are both refused', async () => { + const deps = { resolve: async () => ['93.184.216.34'] }; + + const big = scriptedFetch({ + 'https://mint.example.com/v1/info': new Response('x'.repeat(2048), { + headers: { 'content-type': 'application/json' }, + }), + }); + const capped = await safeFetchText( + 'https://mint.example.com/v1/info', + { accept: 'application/json', maxBytes: 512 }, + { ...deps, fetchImpl: big.fetch }, + ); + assert.equal(capped.state, 'unreachable'); + + const image = scriptedFetch({ + 'https://mint.example.com/v1/info': new Response('\x89PNG', { + headers: { 'content-type': 'image/png' }, + }), + }); + const wrongType = await safeFetchText( + 'https://mint.example.com/v1/info', + { accept: 'application/json' }, + { ...deps, fetchImpl: image.fetch }, + ); + assert.equal(wrongType.state, 'unreachable'); + assert.match(wrongType.state === 'unreachable' ? wrongType.reason : '', /content type/); +}); + +/* ---------- slug collapse ---------- */ + +check('every spelling of one mint collapses to one row key', () => { + const spellings = [ + 'https://mint.600.wtf', + 'mint.600.wtf', + 'mint.600.wtf/', + 'https://mint.600.wtf/', + 'https://MINT.600.WTF', + 'http://mint.600.wtf', + 'https://mint.600.wtf:443/', + ' https://mint.600.wtf/ ', + ].map((raw) => normalizeMintUrl(raw)); + + const urls = new Set(spellings.map((s) => s?.url)); + const hosts = new Set(spellings.map((s) => s?.host)); + assert.equal(urls.size, 1, `one canonical URL, got ${[...urls].join(', ')}`); + assert.equal(hosts.size, 1, `one routing slug, got ${[...hosts].join(', ')}`); + assert.equal([...urls][0], 'https://mint.600.wtf'); + assert.equal([...hosts][0], 'mint.600.wtf'); + + // And the LNURL row key derived from it is one value too, since that is what the + // endpoint actually looks the row up by. + assert.equal(new Set(spellings.map((s) => lnurlKey(s!.url))).size, 1); +}); + +check('a path is still part of a mint identity, and still collapses per path', () => { + const withPath = ['https://mint.example.com/Bitcoin', 'mint.example.com/Bitcoin/'].map((raw) => + normalizeMintUrl(raw), + ); + assert.equal(new Set(withPath.map((s) => s?.url)).size, 1); + assert.notEqual(withPath[0]?.url, normalizeMintUrl('https://mint.example.com')?.url); +}); + +/* ---------- what the browser checks before it asks ---------- */ + +check('the client-side pre-check accepts what the normalizer accepts', () => { + assert.deepEqual(checkIndexInput('lnurl', 'mint.600.wtf'), { + ok: true, + value: 'https://mint.600.wtf', + }); + assert.deepEqual(checkIndexInput('cashu', ' https://21mint.me/ '), { + ok: true, + value: 'https://21mint.me', + }); + + assert.deepEqual(checkIndexInput('cashu', ''), { ok: false, reason: 'empty' }); + assert.deepEqual(checkIndexInput('cashu', 'not a url at all'), { ok: false, reason: 'bad_url' }); + // A private address fails the pre-check for the same reason the server refuses it. + assert.deepEqual(checkIndexInput('cashu', 'http://127.0.0.1:3338'), { + ok: false, + reason: 'bad_url', + }); +}); + +check('an invite code pasted into a URL field is named as an invite code', () => { + const code = REAL_INVITE; + assert.deepEqual(checkIndexInput('cashu', code), { ok: false, reason: 'bad_invite' }); + assert.deepEqual(checkIndexInput('lnurl', code), { ok: false, reason: 'bad_invite' }); + assert.deepEqual(checkIndexInput('fedimint', code), { ok: true, value: code }); + assert.deepEqual(checkIndexInput('fedimint', 'https://mint.example.com'), { + ok: false, + reason: 'bad_invite', + }); +}); + +/* ---------- invite codes ---------- */ + +check('a real invite code yields the federation id its announcement carries', () => { + // The `d` tag of the announcement this code came in: decoding must agree with it, or + // a federation submitted by code would get a second row beside the announced one. + assert.equal( + federationIdFromInviteCode(REAL_INVITE), + 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21', + ); + assert.equal(federationIdFromInviteCode(REAL_INVITE.toUpperCase()), federationIdFromInviteCode(REAL_INVITE)); +}); + +check('anything that is not an invite code decodes to nothing', () => { + for (const junk of [ + '', + 'fed1', + 'fed11', + 'https://mint.example.com', + `${REAL_INVITE}x`, // checksum fails + REAL_INVITE.slice(0, -1), // truncated + REAL_INVITE.replace('fed11q', 'fed11p'), // one flipped character + 'lnbc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', // valid-ish bech32, wrong hrp + ]) { + assert.equal(federationIdFromInviteCode(junk), null, `${junk.slice(0, 24)} must not decode`); + } +}); + +/* ---------- what counts as a mint ---------- */ + +check('a NUT-06 document is told apart from an arbitrary JSON endpoint', () => { + assert.ok(isNut06Info({ nuts: { '4': { methods: [] } } })); + assert.ok(isNut06Info({ pubkey: '03c21ef6'.padEnd(66, 'a'), name: 'x' })); + assert.ok(isNut06Info({ name: 'Some mint', version: 'cdk-mintd/0.17.5' })); + + // The shapes a host that is not a mint actually answers with. + assert.ok(!isNut06Info({ status: 'ok' })); + assert.ok(!isNut06Info({ detail: 'Not Found' })); + assert.ok(!isNut06Info({ tag: 'withdrawRequest', minWithdrawable: 1 })); + assert.ok(!isNut06Info([])); + assert.ok(!isNut06Info('hello')); + assert.ok(!isNut06Info(null)); + assert.ok(!isNut06Info({ nuts: {} }), 'an empty nuts object proves nothing'); +}); + +/* ---------- rate limiting ---------- */ + +check('the eleventh submission in an hour is refused, and refusals do not extend it', () => { + resetRateLimits(); + const now = 1_800_000_000_000; + + for (let i = 0; i < RATE_LIMIT; i++) { + assert.equal(takeToken('1.2.3.4', now + i).ok, true, `submission ${i + 1} must be allowed`); + } + + const refused = takeToken('1.2.3.4', now + RATE_LIMIT); + assert.equal(refused.ok, false); + assert.ok(refused.retryAfter > 0 && refused.retryAfter <= 3600); + + // Pressing the button again must not push the window out. + const again = takeToken('1.2.3.4', now + RATE_LIMIT + 1000); + assert.ok(again.retryAfter <= refused.retryAfter, 'a refusal must not extend the wait'); + + // A different address has its own budget. + assert.equal(takeToken('5.6.7.8', now + RATE_LIMIT).ok, true); + + // An hour later the window has slid past the first submission. + assert.equal(takeToken('1.2.3.4', now + 3_600_001).ok, true); + resetRateLimits(); +}); + +/* ---------- the deep-link routes ---------- */ + +check('every deep link shape maps to the ecosystem that owns it', () => { + assert.deepEqual(typeForPath('/mint/mint.600.wtf'), { type: 'cashu', host: 'mint.600.wtf' }); + assert.deepEqual(typeForPath('/lnurl-mint/mint.600.wtf'), { type: 'lnurl', host: 'mint.600.wtf' }); + assert.deepEqual(typeForPath('/fedimint/fed-aeca6cc80ffc530b'), { + type: 'fedimint', + host: 'fed-aeca6cc80ffc530b', + }); + // A trailing slash is the same page; anything else is not a mint page at all. + assert.deepEqual(typeForPath('/mint/x.example.com/'), { type: 'cashu', host: 'x.example.com' }); + assert.equal(typeForPath('/mints'), null); + assert.equal(typeForPath('/'), null); + assert.equal(typeForPath('/mint/'), null); +}); + +/* ---------- one submission, one row ---------- */ + +/* + * The dedup and the write path, against a throwaway in-memory database and with no + * network involved at all: a Fedimint submission is decoded rather than fetched, so it + * exercises `indexSubmission` end to end — the in-flight map, the insert, and the + * payload read back — without touching a relay or a mint. + * + * The import is deferred until after `DATABASE_URL` is set, because the config resolves + * its target on first use and this must not open the real database. + */ +process.env['DATABASE_URL'] = ':memory:'; +const { indexSubmission, inFlightCount } = await import('./index-mint.ts'); +const { closeDb } = await import('./db.ts'); + +await checkAsync('two simultaneous submissions of one address share one probe', async () => { + assert.equal(inFlightCount(), 0); + + const first = indexSubmission('fedimint', REAL_INVITE); + assert.equal(inFlightCount(), 1, 'the first submission claims the key immediately'); + + // Deliberately a different spelling of the same code: the key is the decoded + // federation id, so the two collapse before anything is written. + const second = indexSubmission('fedimint', REAL_INVITE.toUpperCase()); + assert.equal(inFlightCount(), 1, 'the second submission joins the first, it does not start'); + + const [a, b] = await Promise.all([first, second]); + assert.equal(a, b, 'both callers get the same answer object'); + assert.equal(a.status, 201); + assert.equal(inFlightCount(), 0, 'the entry is released when the work finishes'); + + const created = a.body as { host?: string; status?: string; invite_codes?: string[] }; + assert.equal(created.host, 'fed-aeca6cc80ffc530b'); + assert.equal(created.status, 'announced', 'nothing checks a federation, so nothing claims it is up'); + assert.deepEqual(created.invite_codes, [REAL_INVITE.toLowerCase()]); + + // And once it is a row, submitting it again is a lookup rather than a write. + const again = await indexSubmission('fedimint', REAL_INVITE); + assert.equal(again.status, 200); + assert.equal((again.body as { existing?: boolean }).existing, true); +}); + +await checkAsync('a submission of the wrong shape never reaches the work at all', async () => { + const junk = await indexSubmission('fedimint', 'fed11not-a-real-code'); + assert.equal(junk.status, 422); + assert.equal((junk.body as { error?: string }).error, 'invalid_invite'); + assert.equal(inFlightCount(), 0); + + const wrongType = await indexSubmission('cashu-ish', 'https://mint.example.com'); + assert.equal((wrongType.body as { error?: string }).error, 'bad_type'); + + // A private address is refused by the normalizer, before DNS and before the map. + const private_ = await indexSubmission('cashu', 'https://192.168.1.5'); + assert.equal((private_.body as { error?: string }).error, 'blocked_host'); + assert.equal(inFlightCount(), 0); +}); + +await closeDb(); + +console.log(`ok, ${checks} index checks passed`); diff --git a/api/src/check-lnurl.ts b/api/src/check-lnurl.ts new file mode 100644 index 0000000..1e6ed07 --- /dev/null +++ b/api/src/check-lnurl.ts @@ -0,0 +1,935 @@ +/** + * pnpm --filter ./api test:lnurl + * + * The LNURL ecosystem, checked against the two things it has to agree with: the + * responses recorded in `NOTES-LNURL.md`, and the rules written down in + * `docs/KIND-LNURL-MINT.md`. Those two documents are the specification; this file is + * what stops the code and the documents drifting apart in silence. + * + * Four groups: + * + * 1. **Parsers**, against verbatim captures of the live reference instance and of a + * locally run mint with no funding source. Not hand-written approximations — the + * bytes that were actually on the wire. + * 2. **The `d` rules**, including the sticky-identity case that the kind document + * spends a section on and that a naive implementation gets wrong. + * 3. **The features vocabulary**, including the two negative cases that matter: + * nothing invents `lud21`, and nothing invents `rotate`/`split`/`merge`. + * 4. **A full round trip** — announcement and review published to a real relay by this + * site's own publisher, read back by the indexer's own parsers, resolved to a row. + * The relay is `test-relay.ts`, in-process and on an ephemeral port, so this runs + * in CI with nothing installed and never touches a public relay. + */ +import assert from 'node:assert/strict'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { SimplePool } from 'nostr-tools/pool'; +import * as nip19 from 'nostr-tools/nip19'; +import { + ANNOUNCEMENT_KINDS, + FEATURE_VOCABULARY, + KIND_LNURL_ANNOUNCEMENT, + KIND_REVIEW, + addressFromPayLink, + baseUrlFromKey, + ecosystemForKind, + featureStates, + getMintWarnings, + hasFeature, + hostIdentifier, + isMintPubkey, + lnurlIdentifier, + lnurlIdentifiers, + lnurlKey, + mintChip, + msatToSat, + normalizeLnurlNetwork, + normalizeNetwork, + onionFromHtml, + otherFeatures, + parseAdvertisement, + parseFeatures, + parseLnurlAnnouncement, + parsePayInfo, + parseRating, + parseSoftware, + reviewEcosystem, + type LnurlFields, + type NostrEventLike, +} from '@cashumints/shared'; +import { announcedFeatures, announcementTemplate, parseAnnounceKey } from './announce.ts'; +import { announceConfig } from './config.ts'; +import type { MintRow } from './mints.ts'; +import { startTestRelay } from './test-relay.ts'; + +let checks = 0; +function check(name: string, fn: () => void): void { + try { + fn(); + checks++; + } catch (err) { + console.error(`FAIL: ${name}`); + throw err; + } +} + +async function checkAsync(name: string, fn: () => Promise): Promise { + try { + await fn(); + checks++; + } catch (err) { + console.error(`FAIL: ${name}`); + throw err; + } +} + +/* ------------------------------------------------------------------ * + * Captures. Verbatim, from NOTES-LNURL.md. + * ------------------------------------------------------------------ */ + +/** `GET https://lnurl.21mint.me/.well-known/lnurlw/_`, 2026-08-21. */ +const LIVE_WITHDRAW = { + tag: 'withdrawRequest', + callback: 'https://lnurl.21mint.me/w', + minWithdrawable: 5000, + maxWithdrawable: 999899000, + defaultDescription: 'lnurlcash bearer note on lnurl.21mint.me', + mintPubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555', + payLink: 'https://lnurl.21mint.me/.well-known/lnurlp/mint', + nodeAlias: 'Azzamo', + nodeUri: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736', + nodeColor: '#68f442', + nodeCapacity: 30027500000, + nodeNumChannels: 8, + nodeNumPeers: 18, +}; + +/** `GET https://lnurl.21mint.me/.well-known/lnurlp/_`, same run. */ +const LIVE_PAY = { + tag: 'payRequest', + callback: 'https://lnurl.21mint.me/p/cb', + minSendable: 6000, + maxSendable: 1000000000, + metadata: + '[["text/plain", "Mint an lnurlcash bearer note on lnurl.21mint.me"], ' + + '["text/identifier", "_@lnurl.21mint.me"], ["text/plain", "Mint fees: 1000,100"]]', + withdrawLink: 'https://lnurl.21mint.me/w', +}; + +/** + * The same endpoint on a locally run mint with no `FUNDINGSOURCE_*` configured at all. + * + * Every node field is *absent* rather than null, because the software runs with + * `response_model_exclude_none`. This object is the degraded state, and it is the reason + * the probe can report one. + */ +const NO_FUNDING_WITHDRAW = { + tag: 'withdrawRequest', + callback: 'https://lnurl.test/w', + minWithdrawable: 10000, + maxWithdrawable: 999999000, + defaultDescription: 'lnurlcash bearer note on lnurl.test', + payLink: 'https://lnurl.test/.well-known/lnurlp/mint', +}; + +/* ------------------------------------------------------------------ * + * 1. Parsers + * ------------------------------------------------------------------ */ + +check('the live mint advertisement parses into every field the site renders', () => { + const ad = parseAdvertisement(LIVE_WITHDRAW); + assert.ok(ad, 'the live advertisement must parse'); + assert.equal(ad.minWithdrawableMsat, 5000); + assert.equal(ad.maxWithdrawableMsat, 999899000); + assert.equal(ad.defaultDescription, 'lnurlcash bearer note on lnurl.21mint.me'); + assert.equal(ad.mintPubkey, '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555'); + assert.equal(ad.nodeAlias, 'Azzamo'); + assert.equal(ad.nodeCapacityMsat, 30027500000); + assert.equal(ad.nodeChannels, 8); + assert.equal(ad.nodePeers, 18); + assert.equal(ad.fundingAvailable, true); +}); + +check('millisatoshi limits become the sat figures the verdict strip shows', () => { + assert.equal(msatToSat(5000), 5); + assert.equal(msatToSat(999899000), 999899); + // Floored, not rounded: both numbers are bounds, and rounding a ceiling up would + // advertise a note larger than the mint will ever issue. + assert.equal(msatToSat(1999), 1); + assert.equal(msatToSat(999), 0); + assert.equal(msatToSat(null), null); + assert.equal(msatToSat(-1), null); +}); + +check('a missing mintPubkey is the degraded state, not a parse failure', () => { + const ad = parseAdvertisement(NO_FUNDING_WITHDRAW); + assert.ok(ad, 'a mint with no funding source still serves a valid advertisement'); + assert.equal(ad.fundingAvailable, false); + assert.equal(ad.mintPubkey, null); + assert.equal(ad.nodeAlias, null); + assert.equal(ad.nodeUri, null); + // The limits are real and must still render. This is the whole point of the state. + assert.equal(msatToSat(ad.minWithdrawableMsat), 10); + assert.equal(msatToSat(ad.maxWithdrawableMsat), 999999); +}); + +check('an LNURL error body is not a mint advertisement, despite arriving as HTTP 200', () => { + // These endpoints answer their errors with 200. A probe keying on the status code + // would call every one of these "online". + assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown user.' }), null); + assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown note.' }), null); + assert.equal(parseAdvertisement({ detail: 'Not Found' }), null); + assert.equal(parseAdvertisement(LIVE_PAY), null, 'a payRequest is not a withdrawRequest'); + assert.equal(parseAdvertisement(null), null); + assert.equal(parseAdvertisement('withdrawRequest'), null); + assert.equal(parseAdvertisement([]), null); +}); + +check('an advertisement with inverted or missing bounds is rejected', () => { + assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 900, maxWithdrawable: 100 }), null); + assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: undefined }), null); + assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: 'lots' }), null); +}); + +check('a zero withdraw ceiling parses, because the warning needs to see it', () => { + // Rejecting this would turn "withdrawals disabled" into "offline", which is a + // different and much less useful thing to tell a reader. + const ad = parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 0, maxWithdrawable: 0 }); + assert.ok(ad); + assert.equal(ad.maxWithdrawableMsat, 0); +}); + +check('the payRequest metadata is parsed twice and yields fee, description and address', () => { + const pay = parsePayInfo(LIVE_PAY); + assert.ok(pay); + assert.equal(pay.minSendableMsat, 6000); + assert.equal(pay.maxSendableMsat, 1000000000); + assert.equal(pay.description, 'Mint an lnurlcash bearer note on lnurl.21mint.me'); + assert.equal(pay.feeBaseMsat, 1000); + assert.equal(pay.feePpm, 100); + assert.equal(pay.withdrawLink, 'https://lnurl.21mint.me/w'); +}); + +check('the "Mint fees:" entry never becomes the description', () => { + // It shares `text/plain` with the description, so a naive first-match reader shows + // "Mint fees: 1000,100" as what the mint is. + const pay = parsePayInfo({ + ...LIVE_PAY, + metadata: '[["text/plain", "Mint fees: 2000,50"], ["text/plain", "A real description"]]', + }); + assert.equal(pay?.description, 'A real description'); + assert.equal(pay?.feeBaseMsat, 2000); + assert.equal(pay?.feePpm, 50); +}); + +check('a fee-free mint reports no fee rather than zero', () => { + const pay = parsePayInfo({ ...LIVE_PAY, metadata: '[["text/plain", "Just a mint"]]' }); + assert.equal(pay?.feeBaseMsat, null); + assert.equal(pay?.feePpm, null); +}); + +check('unparseable metadata costs the description, not the whole response', () => { + const pay = parsePayInfo({ ...LIVE_PAY, metadata: 'not json at all' }); + assert.ok(pay, 'the limits above the metadata are still good'); + assert.equal(pay.minSendableMsat, 6000); + assert.equal(pay.description, null); +}); + +check('the lightning address comes from payLink, never from the echoed identifier', () => { + // `text/identifier` echoes whichever username was queried, so probing `_` gets back + // `_@host` — LUD-16's bare-domain form, and not a name to show a reader. + assert.equal(parsePayInfo(LIVE_PAY)?.identifier, '_@lnurl.21mint.me'); + assert.equal(addressFromPayLink(LIVE_WITHDRAW.payLink), 'mint@lnurl.21mint.me'); + + assert.equal(addressFromPayLink('https://x.example/.well-known/lnurlp/_'), null); + assert.equal(addressFromPayLink('https://x.example/somewhere/else'), null); + assert.equal(addressFromPayLink(null), null); + assert.equal(addressFromPayLink('not a url'), null); +}); + +check('an onion address is found in the one-pager and nowhere else', () => { + const html = + '

Also via Tor

'; + assert.equal( + onionFromHtml(html), + 'abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion', + ); + assert.equal(onionFromHtml('

a mint with no tor section

'), null); +}); + +check('the version comes from openapi, and the unknown sentinel is not a version', () => { + assert.equal( + parseSoftware({ info: { title: 'lnurl-mint', version: '0.1.0' } }), + 'lnurl-mint/0.1.0', + ); + // What a source checkout with no installed package metadata reports. It is the + // library saying "I do not know", not a release, and must not reach a reader. + assert.equal(parseSoftware({ info: { title: 'lnurl-mint', version: '0.0.0+unknown' } }), null); + assert.equal(parseSoftware({ info: { version: '1.2.3' } }), null); + assert.equal(parseSoftware({}), null); + assert.equal(parseSoftware(null), null); +}); + +/* ------------------------------------------------------------------ * + * 2. Identity: the `d` rules + * ------------------------------------------------------------------ */ + +check('a mint pubkey is 66 hex characters beginning 02 or 03', () => { + assert.equal(isMintPubkey(LIVE_WITHDRAW.mintPubkey), true); + assert.equal(isMintPubkey('03' + 'a'.repeat(64)), true); + // A Cashu mint pubkey or a federation id is 64 characters, and must never be + // mistaken for one of these. + assert.equal(isMintPubkey('a'.repeat(64)), false); + assert.equal(isMintPubkey('04' + 'a'.repeat(64)), false); + assert.equal(isMintPubkey('021ab8'), false); + assert.equal(isMintPubkey(null), false); +}); + +check('the two `d` forms can never be confused for one another', () => { + const host = hostIdentifier('https://lnurl.21mint.me'); + assert.equal(host, 'lnurl.21mint.me'); + assert.equal(isMintPubkey(host), false, 'a host is never pubkey-shaped'); + assert.ok(host.includes('.'), 'a host always contains a dot; a pubkey never does'); +}); + +check('the `d` fallback drops the scheme and the trailing slash but keeps the path', () => { + assert.equal(hostIdentifier('https://mint.example.com/lnurl/'), 'mint.example.com/lnurl'); + assert.equal(hostIdentifier('https://Mint.Example.com'), 'mint.example.com'); +}); + +check('the identifier prefers the pubkey and falls back to the host', () => { + assert.equal( + lnurlIdentifier('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey), + LIVE_WITHDRAW.mintPubkey, + ); + assert.equal(lnurlIdentifier('https://lnurl.21mint.me', null), 'lnurl.21mint.me'); + // Junk in the pubkey position falls back rather than being published as a `d`. + assert.equal(lnurlIdentifier('https://lnurl.21mint.me', 'nonsense'), 'lnurl.21mint.me'); +}); + +check('a mint that gained a pubkey is still reviewable under its old host identifier', () => { + // The case the kind document spends a section on. Reviews written before the mint had + // a funding source carry the host `d`; asking only for the current identifier would + // silently strand every one of them. + const both = lnurlIdentifiers('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey); + assert.deepEqual(both, [LIVE_WITHDRAW.mintPubkey, 'lnurl.21mint.me']); + + const hostOnly = lnurlIdentifiers('https://lnurl.21mint.me', null); + assert.deepEqual(hostOnly, ['lnurl.21mint.me']); +}); + +check('the row key round-trips the base URL', () => { + const key = lnurlKey('https://lnurl.21mint.me'); + assert.equal(key, 'lnurl:https://lnurl.21mint.me'); + assert.equal(baseUrlFromKey(key), 'https://lnurl.21mint.me'); + // Not an LNURL key, and must not be mistaken for one. + assert.equal(baseUrlFromKey('https://mint.example.com'), null); + assert.equal(baseUrlFromKey('fedimint:' + 'a'.repeat(64)), null); +}); + +/* ------------------------------------------------------------------ * + * 3. The features vocabulary + * ------------------------------------------------------------------ */ + +check('the features tag splits like a modules tag, and tolerates what publishers write', () => { + assert.deepEqual(parseFeatures('mint,melt,rotate'), ['mint', 'melt', 'rotate']); + assert.deepEqual(parseFeatures('mint, melt, rotate'), ['mint', 'melt', 'rotate']); + assert.deepEqual(parseFeatures('MINT,Melt'), ['mint', 'melt']); + assert.deepEqual(parseFeatures('mint,mint,melt'), ['mint', 'melt']); + assert.deepEqual(parseFeatures(''), []); + assert.deepEqual(parseFeatures(null), []); + // Junk tokens are dropped, not carried into a chip. + assert.deepEqual(parseFeatures('mint,