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:
@@ -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`);
|
||||
Reference in New Issue
Block a user