Index, probe, and announce LNURL mints in the API.

Wire discovery and probing for LNURL mints, add rate-limited POST /api/index
for user submissions, and optionally announce confirmed state to relays.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:35 +02:00
co-authored by Cursor
parent c97b44018d
commit 2a9444942b
19 changed files with 4383 additions and 72 deletions
+474
View File
@@ -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<void>): Promise<void> {
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<string, Response>): { fetch: typeof fetch; seen: string[] } {
const seen: string[] = [];
const impl = (async (input: unknown): Promise<Response> => {
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<string, Response> = {
'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`);