@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Acceptance criterion #1, as a runnable check.
|
||||
*
|
||||
* pnpm --filter ./api test:offline
|
||||
*
|
||||
* A mint whose /v1/info is unreachable must still serve its full detail payload from
|
||||
* cached metadata. This is the single most important fix over the old site, so it gets
|
||||
* a test that actually kills a mint rather than a unit test of a helper.
|
||||
*
|
||||
* Runs against a throwaway copy of the seeded database, so the real one is untouched.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const source = process.env['DB_PATH'] ?? path.resolve(import.meta.dirname, '..', 'data', 'cashumints.db');
|
||||
if (!fs.existsSync(source)) {
|
||||
console.error(`No seeded database at ${source}. Run "pnpm seed" first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Point the whole process at a copy before anything opens the real database.
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-'));
|
||||
const copy = path.join(scratch, 'test.db');
|
||||
fs.copyFileSync(source, copy);
|
||||
process.env['DB_PATH'] = copy;
|
||||
|
||||
const { getDb, closeDb } = await import('./db.ts');
|
||||
const { probeMint } = await import('./probe.ts');
|
||||
const { getMintDetail } = await import('./queries.ts');
|
||||
const { mintByUrl } = await import('./mints.ts');
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Pick a mint that is online and has real cached metadata to lose.
|
||||
const victim = db
|
||||
.prepare(
|
||||
`SELECT * FROM mints
|
||||
WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL
|
||||
ORDER BY LENGTH(info_json) DESC LIMIT 1`,
|
||||
)
|
||||
.get() as { url: string; host: string; name: string } | undefined;
|
||||
|
||||
assert.ok(victim, 'seeded database has no online mint with cached metadata');
|
||||
console.log(`victim: ${victim.name} (${victim.host})`);
|
||||
|
||||
const before = getMintDetail(victim.host);
|
||||
assert.ok(before, 'detail must exist before the mint dies');
|
||||
assert.equal(before.status, 'online');
|
||||
assert.ok(before.info, 'must have cached /v1/info before');
|
||||
assert.ok(before.nuts.length > 0, 'must have parsed NUTs before');
|
||||
|
||||
// Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test.
|
||||
const DEAD = 'https://this-mint-is-gone.invalid';
|
||||
db.prepare('UPDATE mints SET url = ? WHERE host = ?').run(DEAD, victim.host);
|
||||
db.prepare('UPDATE reviews SET mint_url = ? WHERE mint_url = ?').run(DEAD, victim.url);
|
||||
|
||||
// Three failures is the offline threshold.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const row = mintByUrl(DEAD);
|
||||
assert.ok(row, 'row must survive the URL change');
|
||||
const result = await probeMint(row);
|
||||
assert.equal(result.ok, false, 'a dead URL must not probe ok');
|
||||
}
|
||||
|
||||
const after = getMintDetail(victim.host);
|
||||
assert.ok(after, 'the mint page must still resolve when the mint is dead');
|
||||
assert.equal(after.status, 'offline', 'three failures means offline');
|
||||
|
||||
// The whole point: none of the cached metadata was touched by the failed probes.
|
||||
assert.equal(after.name, before.name, 'name survives');
|
||||
assert.equal(after.description, before.description, 'description survives');
|
||||
assert.equal(after.version, before.version, 'version survives');
|
||||
assert.equal(after.pubkey, before.pubkey, 'pubkey survives');
|
||||
assert.deepEqual(after.nuts, before.nuts, 'supported NUTs survive');
|
||||
assert.deepEqual(after.info, before.info, 'full cached /v1/info survives');
|
||||
assert.equal(after.icon, before.icon, 'cached icon survives');
|
||||
|
||||
// Reviews must still be readable, so the mint stays reviewable.
|
||||
assert.equal(after.review_count, before.review_count, 'reviews survive');
|
||||
assert.deepEqual(after.rating_distribution, before.rating_distribution, 'distribution survives');
|
||||
|
||||
// The page needs a date for the "Offline since {date}" banner.
|
||||
assert.ok(after.last_online !== null, 'last_online must be set so the banner has a date');
|
||||
assert.ok(after.last_online <= Math.floor(Date.now() / 1000));
|
||||
|
||||
// And it must sink below every online mint without disappearing.
|
||||
const { listMints } = await import('./queries.ts');
|
||||
const list = listMints();
|
||||
const index = list.findIndex((m) => m.host === victim.host);
|
||||
assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it');
|
||||
const lastOnline = list.map((m) => m.status).lastIndexOf('online');
|
||||
assert.ok(index > lastOnline, 'offline mints rank below every online mint');
|
||||
|
||||
closeDb();
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
|
||||
console.log(
|
||||
`ok, offline mint still serves ${after.nuts.length} NUTs, ${after.review_count} reviews, ` +
|
||||
`and full cached metadata (offline since ${new Date(after.last_online * 1000).toISOString()})`,
|
||||
);
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* pnpm --filter ./api test:warnings
|
||||
*
|
||||
* getMintWarnings drives the banner on every mint page, the chip on every card and
|
||||
* the live upgrade in between, so it gets its own fixture-driven check. Each fixture
|
||||
* in shared/fixtures/warnings is a real /v1/info payload, or a real one with a single
|
||||
* flag flipped, and the file says which.
|
||||
*
|
||||
* No framework: it throws on the first failure and prints a count on success.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getMintWarnings, mintChip, primaryWarning, readCapabilities, warningRank,
|
||||
type MintWarning, type MintWarningInput, type MintWarningKind,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
const DIR = path.resolve(import.meta.dirname, '..', '..', 'shared', 'fixtures', 'warnings');
|
||||
|
||||
/** The instant every fixture's dates were written against. */
|
||||
const NOW = 1_787_000_000;
|
||||
|
||||
// Fixed formatters: the assertions are about the copy, not about the runner's locale.
|
||||
const OPTIONS = {
|
||||
now: NOW,
|
||||
formatDate: (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10),
|
||||
formatMonth: (unix: number) => new Date(unix * 1000).toISOString().slice(0, 7),
|
||||
};
|
||||
|
||||
interface Fixture extends MintWarningInput {
|
||||
note: string;
|
||||
}
|
||||
|
||||
function load(name: string): Fixture {
|
||||
return JSON.parse(fs.readFileSync(path.join(DIR, `${name}.json`), 'utf8')) as Fixture;
|
||||
}
|
||||
|
||||
function warnings(name: string): MintWarning[] {
|
||||
return getMintWarnings(load(name), OPTIONS);
|
||||
}
|
||||
|
||||
let checks = 0;
|
||||
function check(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
checks++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL: ${name}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every fixture, and the banner its mint must show. null means no banner at all. */
|
||||
const EXPECTED: [string, MintWarningKind | null][] = [
|
||||
['healthy-cdk', null],
|
||||
['healthy-nutshell', null],
|
||||
['no-nut04-nut05', null],
|
||||
['degraded', null],
|
||||
['melt-only', 'melt-only'],
|
||||
['melt-disabled', 'melt-disabled'],
|
||||
['frozen', 'frozen'],
|
||||
['no-lightning-methods', 'melt-only'],
|
||||
['offline-recent', 'offline'],
|
||||
['offline-long', 'offline-long'],
|
||||
['offline-gone', 'gone'],
|
||||
['offline-never', 'gone'],
|
||||
['offline-gone-melt-disabled', 'gone'],
|
||||
['offline-recent-melt-disabled', 'melt-disabled'],
|
||||
['offline-long-melt-only', 'offline-long'],
|
||||
];
|
||||
|
||||
check('every fixture resolves to the banner it is named for', () => {
|
||||
for (const [name, kind] of EXPECTED) {
|
||||
const primary = primaryWarning(load(name), OPTIONS);
|
||||
assert.equal(primary?.kind ?? null, kind, `${name} should show ${kind ?? 'no banner'}`);
|
||||
}
|
||||
});
|
||||
|
||||
check('the fixture set covers every warning kind plus healthy', () => {
|
||||
const covered = new Set(EXPECTED.map(([, kind]) => kind));
|
||||
for (const kind of ['gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline'] as const) {
|
||||
assert.ok(covered.has(kind), `no fixture produces ${kind}`);
|
||||
}
|
||||
assert.ok(covered.has(null), 'no fixture produces a healthy mint');
|
||||
});
|
||||
|
||||
check('a healthy mint produces nothing at all, not an empty-ish banner', () => {
|
||||
assert.deepEqual(warnings('healthy-cdk'), []);
|
||||
assert.deepEqual(warnings('healthy-nutshell'), []);
|
||||
assert.deepEqual(warnings('no-nut04-nut05'), [], 'a NUT nobody published is not a NUT switched off');
|
||||
assert.deepEqual(warnings('degraded'), [], 'degraded is not offline');
|
||||
});
|
||||
|
||||
check('melt only: NUT-04 disabled, NUT-05 alive (mint.belgianbitcoinembassy.org)', () => {
|
||||
const [w, ...rest] = warnings('melt-only');
|
||||
assert.equal(rest.length, 0, 'one condition, one warning');
|
||||
assert.equal(w?.severity, 'warning');
|
||||
assert.ok(w?.lead.startsWith('Melt only.'));
|
||||
assert.match(w!.body, /New minting \(NUT-04\) is disabled/);
|
||||
assert.match(w!.body, /existing ecash can still be withdrawn/);
|
||||
assert.equal(w?.meta, 'Currently melt only');
|
||||
});
|
||||
|
||||
check('withdrawals disabled outranks everything but a mint being gone', () => {
|
||||
const w = warnings('melt-disabled')[0];
|
||||
assert.equal(w?.kind, 'melt-disabled');
|
||||
assert.equal(w?.severity, 'critical', 'sats in with no way out is critical, not a nudge');
|
||||
assert.ok(w?.lead.startsWith('Withdrawals disabled.'));
|
||||
assert.match(w!.body, /you can put sats in, but you cannot withdraw them/);
|
||||
assert.ok(warningRank('melt-disabled') < warningRank('melt-only'));
|
||||
assert.ok(warningRank('gone') < warningRank('melt-disabled'));
|
||||
});
|
||||
|
||||
check('both disabled collapses into one frozen banner (lekmint.cash)', () => {
|
||||
const list = warnings('frozen');
|
||||
assert.equal(list.length, 1, 'frozen replaces the melt-disabled and melt-only banners');
|
||||
assert.equal(list[0]?.kind, 'frozen');
|
||||
assert.equal(list[0]?.severity, 'critical');
|
||||
assert.match(list[0]!.body, /Both minting and melting are disabled/);
|
||||
assert.ok(!list.some((w) => w.kind === 'melt-disabled' || w.kind === 'melt-only'));
|
||||
});
|
||||
|
||||
check('an empty methods list is off, whatever the flag says', () => {
|
||||
// LekMint publishes disabled: true AND methods: []. Either one alone must be enough.
|
||||
assert.deepEqual(readCapabilities({ '4': { methods: [] }, '5': { methods: [] } }), {
|
||||
mintDisabled: true, meltDisabled: true, mintPublished: true, meltPublished: true,
|
||||
});
|
||||
const onchainOnly = warnings('no-lightning-methods')[0];
|
||||
assert.equal(onchainOnly?.kind, 'melt-only', 'onchain-only minting cannot take a Lightning deposit');
|
||||
});
|
||||
|
||||
check('capabilities read the shapes both mint softwares publish', () => {
|
||||
// Nutshell and cdk-mintd both use plain numeric keys; the prefixed spellings are
|
||||
// accepted because parseNuts has always accepted them.
|
||||
const shapes: unknown[] = [
|
||||
{ '4': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
|
||||
{ nut4: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
|
||||
{ 'NUT-04': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
|
||||
];
|
||||
for (const nuts of shapes) assert.equal(readCapabilities(nuts).mintDisabled, true);
|
||||
|
||||
// An absent flag means enabled: several Nutshell builds omit it.
|
||||
assert.equal(readCapabilities({ '5': { methods: [{ method: 'bolt11', unit: 'sat' }] } }).meltDisabled, false);
|
||||
// bolt12 is Lightning too, so a bolt12-only mint is not "disabled".
|
||||
assert.equal(readCapabilities({ '4': { methods: [{ method: 'bolt12', unit: 'sat' }] } }).mintDisabled, false);
|
||||
// Nothing published at all: no claim either way.
|
||||
assert.deepEqual(readCapabilities(undefined), {
|
||||
mintDisabled: false, meltDisabled: false, mintPublished: false, meltPublished: false,
|
||||
});
|
||||
});
|
||||
|
||||
check('offline tiers split at 7 and 30 days', () => {
|
||||
const base = load('offline-long');
|
||||
const at = (days: number) =>
|
||||
primaryWarning({ ...base, last_online: NOW - days * 86_400 }, OPTIONS)?.kind;
|
||||
|
||||
assert.equal(at(0), 'offline');
|
||||
assert.equal(at(6), 'offline');
|
||||
assert.equal(at(7), 'offline-long', '7 days is the first day of tier 2');
|
||||
assert.equal(at(29), 'offline-long');
|
||||
assert.equal(at(30), 'gone', '30 days is the first day of tier 3');
|
||||
assert.equal(at(400), 'gone');
|
||||
});
|
||||
|
||||
check('an offline mint that has never answered is treated as gone, not as fresh', () => {
|
||||
const w = warnings('offline-never')[0];
|
||||
assert.equal(w?.kind, 'gone');
|
||||
assert.equal(w?.severity, 'critical');
|
||||
assert.match(w!.body, /has not answered a single check/);
|
||||
assert.match(w!.body, /watching it in 2026-08/, 'dates the silence from first_seen');
|
||||
assert.ok(!w!.body.includes('NaN') && !w!.body.includes('null'));
|
||||
assert.equal(w?.meta, 'Never reachable since it was discovered');
|
||||
});
|
||||
|
||||
check('day counts and dates are the real ones, and read as English', () => {
|
||||
const gone = warnings('offline-gone')[0];
|
||||
assert.match(gone!.body, /unreachable since 2026-06-14 \(64 days\)/);
|
||||
|
||||
const recent = warnings('offline-recent')[0];
|
||||
assert.equal(recent?.severity, 'warning');
|
||||
assert.equal(recent?.lead, 'Offline since 2026-08-14.');
|
||||
|
||||
const oneDay = primaryWarning({ ...load('offline-recent'), last_online: NOW - 86_400 }, OPTIONS);
|
||||
assert.equal(oneDay?.lead, 'Offline since 2026-08-16.', 'singular day, no "1 days"');
|
||||
assert.equal(
|
||||
primaryWarning({ ...load('offline-long'), last_online: NOW - 7 * 86_400 }, OPTIONS)?.lead,
|
||||
'Offline for 7 days, since 2026-08-10.',
|
||||
);
|
||||
});
|
||||
|
||||
check('offline 30+ days plus disabled withdrawals is one banner with one extra sentence', () => {
|
||||
const list = warnings('offline-gone-melt-disabled');
|
||||
const primary = list[0];
|
||||
|
||||
assert.equal(primary?.kind, 'gone', 'the mint being gone is the headline');
|
||||
assert.ok(primary!.body.endsWith('Before going offline it had also disabled withdrawals.'));
|
||||
assert.match(primary!.body, /Treat funds held there as at risk/);
|
||||
|
||||
// The melt fact is still in the list for the card, it just does not get a banner.
|
||||
assert.ok(list.some((w) => w.kind === 'melt-disabled'), 'the card still needs to know');
|
||||
assert.equal(list.filter((w) => w.kind === 'gone').length, 1);
|
||||
});
|
||||
|
||||
check('offline 7 to 30 days plus disabled minting collapses the same way', () => {
|
||||
const primary = warnings('offline-long-melt-only')[0];
|
||||
assert.equal(primary?.kind, 'offline-long');
|
||||
assert.ok(primary!.body.endsWith('Before going offline it had also disabled new minting.'));
|
||||
});
|
||||
|
||||
check('a capability banner that outranks an offline one still says it is offline', () => {
|
||||
const primary = warnings('offline-recent-melt-disabled')[0];
|
||||
assert.equal(primary?.kind, 'melt-disabled');
|
||||
assert.match(primary!.body, /This mint had Lightning melting/, 'cached flags are past tense');
|
||||
assert.ok(primary!.body.endsWith('It has also been offline since 2026-08-14 (3 days).'));
|
||||
});
|
||||
|
||||
check('cached flags never claim to be current on an offline mint', () => {
|
||||
assert.match(warnings('offline-recent-melt-disabled')[0]!.body, /\bhad\b/);
|
||||
assert.match(warnings('melt-disabled')[0]!.body, /\bhas\b/);
|
||||
assert.match(warnings('melt-only')[0]!.body, /\bis disabled\b/);
|
||||
|
||||
const offlineMeltOnly = getMintWarnings(
|
||||
{ ...load('melt-only'), status: 'offline', last_online: NOW - 86_400 },
|
||||
OPTIONS,
|
||||
);
|
||||
assert.match(offlineMeltOnly[0]!.body, /\bwas disabled\b/);
|
||||
assert.equal(offlineMeltOnly[0]?.meta, 'Melt only when last seen');
|
||||
});
|
||||
|
||||
check('an offline banner does not repeat itself in its own last sentence', () => {
|
||||
// The collapse sentence belongs to a capability banner that outranked an offline
|
||||
// one. On an offline banner the fact is already in the lead.
|
||||
for (const name of ['offline-recent', 'offline-long', 'offline-gone', 'offline-never']) {
|
||||
const body = warnings(name)[0]!.body;
|
||||
assert.ok(!body.includes('It has also been offline'), `${name} says it twice`);
|
||||
}
|
||||
});
|
||||
|
||||
check('warnings come back in severity order, most severe first', () => {
|
||||
for (const [name] of EXPECTED) {
|
||||
const ranks = warnings(name).map((w) => warningRank(w.kind));
|
||||
assert.deepEqual(ranks, [...ranks].sort((a, b) => a - b), `${name} is out of order`);
|
||||
}
|
||||
});
|
||||
|
||||
check('cards get a chip for the capability states and nothing for offline alone', () => {
|
||||
assert.deepEqual(mintChip(warnings('melt-only')), { label: 'Melt only', severity: 'warning' });
|
||||
assert.deepEqual(mintChip(warnings('frozen')), { label: 'Frozen', severity: 'critical' });
|
||||
assert.deepEqual(mintChip(warnings('melt-disabled')), { label: 'No withdrawals', severity: 'critical' });
|
||||
assert.equal(mintChip(warnings('offline-gone')), null, 'offline cards already look offline');
|
||||
assert.equal(mintChip(warnings('healthy-cdk')), null);
|
||||
// The chip survives an offline banner outranking the capability warning.
|
||||
assert.deepEqual(mintChip(warnings('offline-gone-melt-disabled')), {
|
||||
label: 'No withdrawals', severity: 'critical',
|
||||
});
|
||||
});
|
||||
|
||||
check('a card can be warned from precomputed flags, with no info payload', () => {
|
||||
const fromInfo = warnings('frozen');
|
||||
const fromFlags = getMintWarnings(
|
||||
{ status: 'online', last_online: NOW - 90, capabilities: { mintDisabled: true, meltDisabled: true } },
|
||||
OPTIONS,
|
||||
);
|
||||
assert.deepEqual(fromFlags.map((w) => w.kind), fromInfo.map((w) => w.kind));
|
||||
});
|
||||
|
||||
check('the copy obeys DESIGN.md: no em dashes, no all-caps, no emoji', () => {
|
||||
for (const [name] of EXPECTED) {
|
||||
for (const w of warnings(name)) {
|
||||
const text = `${w.lead} ${w.body} ${w.meta}`;
|
||||
assert.ok(!text.includes('—'), `${name}/${w.kind} contains an em dash`);
|
||||
assert.ok(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text), `${name}/${w.kind} contains an emoji`);
|
||||
assert.ok(!/\b[A-Z]{4,}\b/.test(text.replace(/NUT-\d+/g, '')), `${name}/${w.kind} shouts`);
|
||||
assert.ok(w.lead.length <= 60, `${name}/${w.kind} lead is not a short phrase`);
|
||||
assert.ok(/[.!?]$/.test(w.lead.trim()) && /[.!?]$/.test(w.body.trim()));
|
||||
assert.ok(w.meta.length > 0 && !w.meta.endsWith('.'), 'meta is a clause, not a sentence');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
check('nothing is warned about without a reason: no now, no crash', () => {
|
||||
// Called with no options at all, the way a page that forgot them would call it.
|
||||
assert.deepEqual(getMintWarnings(load('healthy-cdk')), []);
|
||||
assert.equal(getMintWarnings(load('frozen'))[0]?.kind, 'frozen');
|
||||
assert.equal(getMintWarnings({ status: 'unknown', last_online: null }).length, 0);
|
||||
});
|
||||
|
||||
console.log(`ok, ${checks} warning checks passed over ${fs.readdirSync(DIR).length} fixtures`);
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* pnpm --filter ./api test
|
||||
*
|
||||
* One runnable check over the logic that is easy to get quietly wrong: URL
|
||||
* normalization, rating parsing, scoring, and the review-dedupe SQL. No framework:
|
||||
* it throws on the first failure and prints a count on success.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { bayesianScore, compareMints, NEUTRAL_PRIOR_MEAN, normalizeMintUrl, parseNuts, parseLimits, parseRating } from '@cashumints/shared';
|
||||
import { statusForFails } from './probe.ts';
|
||||
|
||||
let checks = 0;
|
||||
function check(name: string, fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
checks++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL: ${name}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
check('normalize forces https and strips trailing slash', () => {
|
||||
assert.equal(normalizeMintUrl('http://Mint.Example.com/')?.url, 'https://mint.example.com');
|
||||
assert.equal(normalizeMintUrl('mint.example.com')?.url, 'https://mint.example.com');
|
||||
assert.equal(normalizeMintUrl('https://mint.example.com//')?.url, 'https://mint.example.com');
|
||||
});
|
||||
|
||||
check('normalize keeps the path as part of mint identity', () => {
|
||||
const a = normalizeMintUrl('https://mint.minibits.cash/Bitcoin');
|
||||
const b = normalizeMintUrl('https://mint.minibits.cash');
|
||||
assert.equal(a?.url, 'https://mint.minibits.cash/Bitcoin');
|
||||
assert.notEqual(a?.url, b?.url);
|
||||
assert.notEqual(a?.host, b?.host);
|
||||
assert.equal(a?.host, 'mint.minibits.cash-bitcoin');
|
||||
assert.equal(b?.host, 'mint.minibits.cash');
|
||||
});
|
||||
|
||||
check('normalize collapses the trailing-slash duplicate the old site created', () => {
|
||||
const withSlash = normalizeMintUrl('https://21mint.me/');
|
||||
const without = normalizeMintUrl('https://21mint.me');
|
||||
assert.equal(withSlash?.url, without?.url);
|
||||
assert.equal(withSlash?.host, without?.host);
|
||||
});
|
||||
|
||||
check('normalize drops default ports but keeps explicit ones', () => {
|
||||
assert.equal(normalizeMintUrl('https://x.example.com:443')?.url, 'https://x.example.com');
|
||||
assert.equal(normalizeMintUrl('https://smilemoji.cash:3338')?.url, 'https://smilemoji.cash:3338');
|
||||
assert.equal(normalizeMintUrl('https://smilemoji.cash:3338')?.host, 'smilemoji.cash-3338');
|
||||
});
|
||||
|
||||
check('normalize rejects onion, localhost and private ranges', () => {
|
||||
assert.equal(normalizeMintUrl('http://2tcc5ozz.onion'), null);
|
||||
assert.equal(normalizeMintUrl('http://localhost:3338'), null);
|
||||
assert.equal(normalizeMintUrl('https://192.168.1.5:3338'), null);
|
||||
assert.equal(normalizeMintUrl('https://127.0.0.1'), null);
|
||||
assert.equal(normalizeMintUrl('ftp://mint.example.com'), null);
|
||||
assert.equal(normalizeMintUrl(''), null);
|
||||
});
|
||||
|
||||
check('rating comes from the [N/5] content prefix when there is no rating tag', () => {
|
||||
// This is the shape the old publisher actually shipped, and most reviews on the
|
||||
// network have no rating tag at all.
|
||||
const event = {
|
||||
id: 'a',
|
||||
pubkey: 'b',
|
||||
kind: 38000,
|
||||
created_at: 1,
|
||||
content: '[4/5] Solid mint, fast melts',
|
||||
tags: [['k', '38172'], ['u', 'https://mint.example.com', 'cashu']],
|
||||
};
|
||||
assert.equal(parseRating(event), 4);
|
||||
});
|
||||
|
||||
check('rating tag wins over content', () => {
|
||||
assert.equal(
|
||||
parseRating({
|
||||
id: 'a', pubkey: 'b', kind: 38000, created_at: 1,
|
||||
content: '[1/5] ignore me',
|
||||
tags: [['rating', '5']],
|
||||
}),
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
check('unparseable rating is null, never a silent 5', () => {
|
||||
// The old parser defaulted to 5 here, which is why every mint showed 5/5.
|
||||
assert.equal(
|
||||
parseRating({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: 'nice', tags: [] }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
check('fractional rating tags are mapped onto 1..5', () => {
|
||||
assert.equal(
|
||||
parseRating({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: '', tags: [['rating', '0.8']] }),
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
check('many reviews at 4.6 outrank few at 5.0 (the point of the whole formula)', () => {
|
||||
const now = 1_800_000_000;
|
||||
const at = (v: number, R: number) =>
|
||||
bayesianScore(
|
||||
{ review_count: v, rating_avg: R, status: 'online', last_review_at: now - 3600 },
|
||||
NEUTRAL_PRIOR_MEAN, now,
|
||||
);
|
||||
|
||||
assert.ok(at(39, 4.6) > at(4, 5.0), `39@4.6=${at(39, 4.6)} must beat 4@5.0=${at(4, 5.0)}`);
|
||||
assert.ok(at(39, 4.6) > at(1, 5.0), 'and must beat a lone five star review');
|
||||
assert.ok(at(4, 5.0) > at(1, 5.0), 'among equal means, more reviews wins');
|
||||
});
|
||||
|
||||
check('the observed global mean as prior breaks that guarantee', () => {
|
||||
// Documents why NEUTRAL_PRIOR_MEAN is the default instead of the literal
|
||||
// BACKEND.md "C = global mean". Cashu reviews are almost all five stars, so the
|
||||
// observed mean sits near 4.7 and shrinking toward it cannot reorder two means
|
||||
// that are both above it. If this check ever starts failing, the network's rating
|
||||
// spread changed and the default prior is worth revisiting.
|
||||
const now = 1_800_000_000;
|
||||
const observed = 4.7;
|
||||
const many = bayesianScore(
|
||||
{ review_count: 39, rating_avg: 4.6, status: 'online', last_review_at: now - 3600 },
|
||||
observed, now,
|
||||
);
|
||||
const one = bayesianScore(
|
||||
{ review_count: 1, rating_avg: 5.0, status: 'online', last_review_at: now - 3600 },
|
||||
observed, now,
|
||||
);
|
||||
assert.ok(one > many, 'with C=4.7 a single 5 star review outranks 39 considered ones');
|
||||
});
|
||||
|
||||
check('offline mints are penalised and always sort below online ones', () => {
|
||||
const now = 1_800_000_000;
|
||||
const input = { review_count: 20, rating_avg: 5, status: 'offline', last_review_at: now - 100 };
|
||||
const off = bayesianScore(input, 4.5, now);
|
||||
const on = bayesianScore({ ...input, status: 'online' }, 4.5, now);
|
||||
assert.ok(off < on);
|
||||
|
||||
// A perfect offline mint still ranks below a mediocre online one.
|
||||
const sorted = [
|
||||
{ status: 'offline', score: 4.9, review_count: 99 },
|
||||
{ status: 'online', score: 1.2, review_count: 1 },
|
||||
].sort(compareMints);
|
||||
assert.equal(sorted[0]?.status, 'online');
|
||||
});
|
||||
|
||||
check('stale mints lose 10 percent', () => {
|
||||
const now = 1_800_000_000;
|
||||
const base = { review_count: 10, rating_avg: 4.5, status: 'online' };
|
||||
const fresh = bayesianScore({ ...base, last_review_at: now - 100 }, 4.5, now);
|
||||
const stale = bayesianScore({ ...base, last_review_at: now - 200 * 86400 }, 4.5, now);
|
||||
assert.ok(Math.abs(stale - fresh * 0.9) < 0.002, `${stale} vs ${fresh * 0.9}`);
|
||||
// Never reviewed counts as stale too.
|
||||
assert.ok(bayesianScore({ ...base, last_review_at: null }, 4.5, now) < fresh);
|
||||
});
|
||||
|
||||
check('probe status ladder: 1-2 fails degraded, 3+ offline', () => {
|
||||
assert.equal(statusForFails(0), 'online');
|
||||
assert.equal(statusForFails(1), 'degraded');
|
||||
assert.equal(statusForFails(2), 'degraded');
|
||||
assert.equal(statusForFails(3), 'offline');
|
||||
});
|
||||
|
||||
check('nuts parse handles all three encodings seen in the wild', () => {
|
||||
const nuts = parseNuts({
|
||||
'4': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
|
||||
'5': { methods: [{ method: 'bolt11', unit: 'sat' }] },
|
||||
'7': { supported: true },
|
||||
'9': { supported: false },
|
||||
'15': { supported: [{ method: 'bolt11', unit: 'sat' }] },
|
||||
'16': { supported: [] },
|
||||
'17': true,
|
||||
'19': { disabled: true, methods: [{ method: 'bolt11' }] },
|
||||
});
|
||||
assert.deepEqual(nuts, ['4', '5', '7', '15', '17']);
|
||||
});
|
||||
|
||||
check('limits come from NUT-04 methods', () => {
|
||||
const limits = parseLimits({
|
||||
'4': { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 1, max_amount: 1_000_000 }] },
|
||||
});
|
||||
assert.deepEqual(limits, { min: 1, max: 1_000_000, unit: 'sat' });
|
||||
assert.equal(parseLimits({ '4': { methods: [{ method: 'bolt11' }] } }), null);
|
||||
assert.equal(parseLimits(undefined), null);
|
||||
});
|
||||
|
||||
// The dedupe rule lives in SQL, so exercise it against a real in-memory database
|
||||
// using the same statement the API uses.
|
||||
check('one review per author per mint, newest wins', async () => {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE reviews (event_id TEXT PRIMARY KEY, mint_url TEXT, pubkey TEXT,
|
||||
rating INTEGER, created_at INTEGER)`);
|
||||
const insert = db.prepare('INSERT INTO reviews VALUES (?,?,?,?,?)');
|
||||
insert.run('e1', 'https://m', 'alice', 1, 100);
|
||||
insert.run('e2', 'https://m', 'alice', 5, 200); // alice changed her mind
|
||||
insert.run('e3', 'https://m', 'bob', 3, 150);
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT pubkey, rating FROM (
|
||||
SELECT pubkey, rating, ROW_NUMBER() OVER (
|
||||
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id) rn
|
||||
FROM reviews) WHERE rn = 1 ORDER BY pubkey`,
|
||||
)
|
||||
.all() as { pubkey: string; rating: number }[];
|
||||
|
||||
assert.equal(rows.length, 2, 'alice must count once');
|
||||
assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, 'newest review wins');
|
||||
db.close();
|
||||
});
|
||||
|
||||
// The last check is async, so report after the microtask queue drains.
|
||||
queueMicrotask(() => console.log(`ok, ${checks} checks passed`));
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_RELAYS } from '@cashumints/shared';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = path.resolve(here, '..');
|
||||
|
||||
function int(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: int('PORT', 8787),
|
||||
dbPath: process.env['DB_PATH'] ?? path.join(apiRoot, 'data', 'cashumints.db'),
|
||||
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
|
||||
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
|
||||
[...DEFAULT_RELAYS]) as string[],
|
||||
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
|
||||
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
|
||||
probeConcurrency: int('PROBE_CONCURRENCY', 8),
|
||||
probeTimeoutMs: int('PROBE_TIMEOUT_MS', 5000),
|
||||
userAgent: 'cashumints.space-indexer/1.0',
|
||||
} as const;
|
||||
|
||||
export const startedAt = Math.floor(Date.now() / 1000);
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.ts';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS mints (
|
||||
url TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon_url TEXT,
|
||||
icon_file TEXT,
|
||||
pubkey TEXT,
|
||||
info_json TEXT,
|
||||
nuts_json TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
consecutive_fails INTEGER NOT NULL DEFAULT 0,
|
||||
last_online INTEGER,
|
||||
last_probe INTEGER,
|
||||
first_seen INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
mint_url TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
rating INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS probes (
|
||||
mint_url TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
let db: DB | null = null;
|
||||
|
||||
export function getDb(): DB {
|
||||
if (db) return db;
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
fs.mkdirSync(config.iconDir, { recursive: true });
|
||||
|
||||
const handle = new Database(config.dbPath);
|
||||
handle.pragma('journal_mode = WAL');
|
||||
handle.pragma('synchronous = NORMAL');
|
||||
handle.pragma('busy_timeout = 5000');
|
||||
handle.exec(SCHEMA);
|
||||
|
||||
db = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
db?.close();
|
||||
db = null;
|
||||
}
|
||||
|
||||
export function getState(key: string): string | null {
|
||||
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setState(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
export function getStateNumber(key: string): number | null {
|
||||
const raw = getState(key);
|
||||
if (raw === null) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Delete probe rows older than 90 days. Called once a day. */
|
||||
export function pruneProbes(): number {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import type { Event as NostrEvent, Filter } from 'nostr-tools';
|
||||
import {
|
||||
KIND_MINT_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
isCashuMintReview,
|
||||
mintPubkeyRef,
|
||||
mintUrlSpellings,
|
||||
mintUrlsFromEvent,
|
||||
normalizeMintUrl,
|
||||
parseRating,
|
||||
} from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { getDb, getStateNumber, setState } from './db.ts';
|
||||
import { log } from './log.ts';
|
||||
import { insertMintIfNew, mintUrlByHost, mintUrlByPubkey } from './mints.ts';
|
||||
|
||||
const QUERY_LIMIT = 500;
|
||||
const MAX_WAIT_MS = 12_000;
|
||||
/** Backfill page cap. 20 pages x 500 is far more than the network holds today. */
|
||||
const MAX_PAGES = 20;
|
||||
/** The old site's second filter: a recent window alongside the unbounded one, because
|
||||
* some relays answer an unbounded query with an arbitrary slice. See NOTES.md. */
|
||||
const RECENT_WINDOW_S = 30 * 24 * 60 * 60;
|
||||
|
||||
export interface DiscoveryResult {
|
||||
events: number;
|
||||
newMints: string[];
|
||||
newReviews: number;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
|
||||
function getPool(): SimplePool {
|
||||
pool ??= new SimplePool();
|
||||
return pool;
|
||||
}
|
||||
|
||||
export function closePool(): void {
|
||||
try {
|
||||
pool?.close(config.relays);
|
||||
} catch {
|
||||
// A relay that is already gone throws on close. Nothing to do about it.
|
||||
}
|
||||
pool = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query one kind, paging backwards with `until` until a page yields nothing new.
|
||||
* Relays cap `limit` independently, so paging is the only way a fresh database
|
||||
* converges to the complete history.
|
||||
*/
|
||||
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
|
||||
const seen = new Map<string, NostrEvent>();
|
||||
let until: number | undefined;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const filter: Filter = { kinds: [kind], limit: QUERY_LIMIT };
|
||||
if (since !== null) filter.since = since;
|
||||
if (until !== undefined) filter.until = until;
|
||||
|
||||
let batch: NostrEvent[];
|
||||
try {
|
||||
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
|
||||
} catch (err) {
|
||||
log.warn('relay query failed', {
|
||||
kind,
|
||||
page,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let oldest = Number.POSITIVE_INFINITY;
|
||||
for (const e of batch) {
|
||||
oldest = Math.min(oldest, e.created_at);
|
||||
if (!seen.has(e.id)) {
|
||||
seen.set(e.id, e);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing new, or the relays returned less than a full page: history is exhausted.
|
||||
if (added === 0 || batch.length < QUERY_LIMIT || !Number.isFinite(oldest)) break;
|
||||
until = oldest - 1;
|
||||
if (since !== null && until <= since) break;
|
||||
}
|
||||
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reviews for one specific mint, asked for by name.
|
||||
*
|
||||
* The global sweep above does NOT converge on its own: relays answer an unbounded
|
||||
* `{kinds:[38000]}` with an arbitrary capped slice, but answer a filtered query for one
|
||||
* mint in full. Measured against the live network, the global sweep found 43 reviews for
|
||||
* mint.minibits.cash/Bitcoin while this targeted query finds 80. Without this pass the
|
||||
* count in the API disagrees with the list the browser renders on the same page.
|
||||
*/
|
||||
async function fetchReviewsForMint(
|
||||
mintUrl: string,
|
||||
mintPubkey: string | null,
|
||||
since: number | null,
|
||||
): Promise<NostrEvent[]> {
|
||||
const filters: Filter[] = [];
|
||||
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
|
||||
if (since !== null) base.since = since;
|
||||
|
||||
if (mintPubkey) {
|
||||
filters.push({ ...base, '#d': [mintPubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
|
||||
}
|
||||
filters.push({ ...base, '#u': mintUrlSpellings(mintUrl) });
|
||||
|
||||
const batches = await Promise.all(
|
||||
filters.map((filter) =>
|
||||
getPool()
|
||||
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
|
||||
.catch(() => [] as NostrEvent[]),
|
||||
),
|
||||
);
|
||||
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
/** Every mint URL an event points at, normalized and inserted if new. */
|
||||
function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>): void {
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const created = insertMintIfNew(raw, now);
|
||||
if (created) newMints.add(created);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which mint a review is about.
|
||||
*
|
||||
* `u` tag first, resolved through the slug so that every spelling of a mint URL (with
|
||||
* or without a trailing slash, either path case) lands on the one canonical row. Then
|
||||
* the `d` tag pubkey against the pubkey a mint publishes in its own /v1/info, which
|
||||
* catches reviews whose `u` tag is missing or wrong.
|
||||
*/
|
||||
function resolveReviewTarget(event: NostrEvent): string | null {
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
// The row was inserted moments ago by ingestMintUrls, so this almost always hits.
|
||||
const canonical = mintUrlByHost(normalized.host);
|
||||
if (canonical) return canonical;
|
||||
}
|
||||
|
||||
const pubkey = mintPubkeyRef(event);
|
||||
return pubkey ? mintUrlByPubkey(pubkey) : null;
|
||||
}
|
||||
|
||||
export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult> {
|
||||
const started = Date.now();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastRun = backfill ? null : getStateNumber('discovery_since');
|
||||
// Overlap the window by an hour so an event that arrived late is not missed.
|
||||
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
|
||||
|
||||
const newMints = new Set<string>();
|
||||
let newReviews = 0;
|
||||
let events = 0;
|
||||
let ok = true;
|
||||
|
||||
try {
|
||||
const announcements = await fetchKind(KIND_MINT_ANNOUNCEMENT, since);
|
||||
events += announcements.length;
|
||||
for (const e of announcements) ingestMintUrls(e, now, newMints);
|
||||
|
||||
// Announcements alone miss mints that only ever appear in a review's `u` tag,
|
||||
// so reviews feed discovery too.
|
||||
const reviews = await fetchKind(KIND_REVIEW, since);
|
||||
// The recent window catches anything a relay dropped from the unbounded query.
|
||||
const recent =
|
||||
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
|
||||
|
||||
const byId = new Map<string, NostrEvent>();
|
||||
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
|
||||
events += byId.size;
|
||||
|
||||
for (const e of byId.values()) ingestMintUrls(e, now, newMints);
|
||||
|
||||
const insert = getDb().prepare(
|
||||
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(event_id) DO UPDATE SET
|
||||
mint_url = excluded.mint_url,
|
||||
rating = excluded.rating`,
|
||||
);
|
||||
// `changes` counts updates as well as inserts, so ask first. The log line is a
|
||||
// health signal (is discovery finding anything?) and an inflated number hides a
|
||||
// stalled crawl behind a busy-looking figure.
|
||||
const known = getDb().prepare('SELECT 1 FROM reviews WHERE event_id = ?');
|
||||
|
||||
const ingestReviews = getDb().transaction((list: NostrEvent[]) => {
|
||||
for (const e of list) {
|
||||
// The `k` tag is what marks a 38000 as being about a Cashu mint rather than
|
||||
// some other recommendable kind. Events without it but with a resolvable mint
|
||||
// URL are still accepted: the old publisher's own events are the reason.
|
||||
const target = resolveReviewTarget(e);
|
||||
if (!target) continue;
|
||||
if (!isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
|
||||
|
||||
if (!known.get(e.id)) newReviews++;
|
||||
insert.run(e.id, target, e.pubkey, parseRating(e), e.created_at);
|
||||
}
|
||||
});
|
||||
|
||||
ingestReviews([...byId.values()]);
|
||||
|
||||
// Second pass: ask each known mint's reviews by name, which is the only way the
|
||||
// counts converge (see fetchReviewsForMint). Runs after the global sweep so mints
|
||||
// discovered in this cycle are included.
|
||||
const targets = getDb()
|
||||
.prepare('SELECT url, pubkey FROM mints')
|
||||
.all() as { url: string; pubkey: string | null }[];
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
let cursor = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, targets.length) }, async () => {
|
||||
while (cursor < targets.length) {
|
||||
const target = targets[cursor++];
|
||||
if (!target) continue;
|
||||
const found = await fetchReviewsForMint(target.url, target.pubkey, since);
|
||||
if (found.length > 0) {
|
||||
events += found.length;
|
||||
ingestReviews(found);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setState('discovery_since', String(now));
|
||||
setState('last_discovery_at', String(now));
|
||||
setState('last_discovery_ok', '1');
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
setState('last_discovery_ok', '0');
|
||||
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
log.info('discovery cycle', {
|
||||
mode: backfill ? 'backfill' : 'incremental',
|
||||
events,
|
||||
new_mints: newMints.size,
|
||||
new_reviews: newReviews,
|
||||
ok,
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
|
||||
return { events, newMints: [...newMints], newReviews, ok };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.ts';
|
||||
import { log } from './log.ts';
|
||||
import type { MintRow } from './mints.ts';
|
||||
|
||||
const EXT_BY_TYPE: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/gif': '.gif',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
};
|
||||
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* Cache a mint's icon to disk so offline mints keep theirs.
|
||||
* Re-fetches only when the icon_url changed, per BACKEND.md.
|
||||
* Returns the stored filename, or the existing one if nothing needed doing.
|
||||
*/
|
||||
export async function cacheIcon(row: MintRow, iconUrl: string | null): Promise<string | null> {
|
||||
if (!iconUrl) return row.icon_file;
|
||||
if (iconUrl === row.icon_url && row.icon_file) return row.icon_file;
|
||||
|
||||
// Some mints inline the icon as a data URI. Nothing to fetch, and nothing worth storing.
|
||||
if (iconUrl.startsWith('data:')) return row.icon_file;
|
||||
|
||||
try {
|
||||
const absolute = new URL(iconUrl, `${row.url}/`).toString();
|
||||
if (!absolute.startsWith('https://') && !absolute.startsWith('http://')) return row.icon_file;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(absolute, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': config.userAgent },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!res.ok) return row.icon_file;
|
||||
|
||||
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
|
||||
const ext = EXT_BY_TYPE[type];
|
||||
if (!ext) return row.icon_file;
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.byteLength === 0 || buf.byteLength > MAX_ICON_BYTES) return row.icon_file;
|
||||
|
||||
const filename = `${row.host}${ext}`;
|
||||
await fs.writeFile(path.join(config.iconDir, filename), buf);
|
||||
return filename;
|
||||
} catch (err) {
|
||||
log.warn('icon fetch failed', {
|
||||
url: row.url,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return row.icon_file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { config } from './config.ts';
|
||||
import { closeDb, getDb, pruneProbes } from './db.ts';
|
||||
import { closePool, runDiscovery } from './discovery.ts';
|
||||
import { log } from './log.ts';
|
||||
import { probeAll, probeUrls } from './probe.ts';
|
||||
import { createApp } from './server.ts';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let shuttingDown = false;
|
||||
/** Set while a probe or discovery cycle is in flight, so shutdown can wait for it. */
|
||||
let inFlight: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function track<T>(work: Promise<T>): Promise<T> {
|
||||
inFlight = inFlight.then(() => work).catch(() => undefined);
|
||||
return work;
|
||||
}
|
||||
|
||||
async function probeCycle(): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
try {
|
||||
await track(probeAll());
|
||||
} catch (err) {
|
||||
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function discoveryCycle(backfill: boolean): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
try {
|
||||
const result = await track(runDiscovery(backfill));
|
||||
// New mints are probed straight away, not on the next cycle, so their page has
|
||||
// real metadata as soon as it is reachable.
|
||||
if (result.newMints.length > 0) await track(probeUrls(result.newMints));
|
||||
} catch (err) {
|
||||
log.error('discovery cycle failed', {
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
getDb();
|
||||
|
||||
const server = serve({ fetch: createApp().fetch, port: config.port }, (info) => {
|
||||
log.info('api listening', {
|
||||
port: info.port,
|
||||
db: config.dbPath,
|
||||
relays: config.relays.length,
|
||||
});
|
||||
});
|
||||
|
||||
const probeTimer = setInterval(() => void probeCycle(), config.probeIntervalMin * 60 * 1000);
|
||||
const discoveryTimer = setInterval(
|
||||
() => void discoveryCycle(false),
|
||||
config.discoveryIntervalMin * 60 * 1000,
|
||||
);
|
||||
const pruneTimer = setInterval(() => {
|
||||
const removed = pruneProbes();
|
||||
if (removed > 0) log.info('pruned probes', { rows: removed });
|
||||
}, DAY_MS);
|
||||
|
||||
// Startup: probe what we already know, then a full discovery backfill so a fresh
|
||||
// database converges to complete review counts (BACKEND.md).
|
||||
void probeCycle().then(() => discoveryCycle(true));
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log.info('shutting down', { signal });
|
||||
|
||||
clearInterval(probeTimer);
|
||||
clearInterval(discoveryTimer);
|
||||
clearInterval(pruneTimer);
|
||||
|
||||
void inFlight.finally(() => {
|
||||
closePool();
|
||||
server.close(() => {
|
||||
closeDb();
|
||||
process.exit(0);
|
||||
});
|
||||
// Do not hang forever on a relay socket that refuses to close.
|
||||
setTimeout(() => {
|
||||
closeDb();
|
||||
process.exit(0);
|
||||
}, 8000).unref();
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,21 @@
|
||||
/** One line per event, key=value after the message, so logs grep cleanly. */
|
||||
type Fields = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
function emit(level: 'info' | 'warn' | 'error', msg: string, fields?: Fields): void {
|
||||
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
|
||||
if (fields) {
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
|
||||
}
|
||||
}
|
||||
const line = parts.join(' ');
|
||||
if (level === 'error') console.error(line);
|
||||
else if (level === 'warn') console.warn(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info: (msg: string, fields?: Fields) => emit('info', msg, fields),
|
||||
warn: (msg: string, fields?: Fields) => emit('warn', msg, fields),
|
||||
error: (msg: string, fields?: Fields) => emit('error', msg, fields),
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { normalizeMintUrl } from '@cashumints/shared';
|
||||
import { getDb } from './db.ts';
|
||||
import { log } from './log.ts';
|
||||
|
||||
/**
|
||||
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
|
||||
* onion address) recurs in dozens of events per cycle, and logging each occurrence
|
||||
* buries the lines that matter.
|
||||
*/
|
||||
const reportedSkips = new Set<string>();
|
||||
|
||||
export interface MintRow {
|
||||
url: string;
|
||||
host: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
icon_url: string | null;
|
||||
icon_file: string | null;
|
||||
pubkey: string | null;
|
||||
info_json: string | null;
|
||||
nuts_json: string | null;
|
||||
version: string | null;
|
||||
status: string;
|
||||
consecutive_fails: number;
|
||||
last_online: number | null;
|
||||
last_probe: number | null;
|
||||
first_seen: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a mint if it is new. Never touches an existing row: cached metadata is the
|
||||
* whole point of the table, and discovery must not overwrite what a probe learned.
|
||||
* Returns the normalized URL when a row was created, null otherwise.
|
||||
*
|
||||
* Deduped on the slug, not the URL. Real data has `https://mint.minibits.cash/Bitcoin`
|
||||
* and `https://mint.minibits.cash/bitcoin` announced as separate mints; they are one
|
||||
* mint written two ways, and keying on the URL would split its reviews across two pages.
|
||||
* The URL keeps its original path case, because a mint's path is case sensitive over
|
||||
* HTTP and the lowercase spelling 404s when probed.
|
||||
*
|
||||
* ponytail: whichever casing is seen first wins and is the one probed. The seed list
|
||||
* pins the working spelling, so in practice the correct one is always inserted first.
|
||||
* If that ever stops holding, the upgrade is to probe both casings once and keep the
|
||||
* one that answers.
|
||||
*/
|
||||
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
|
||||
const normalized = normalizeMintUrl(rawUrl);
|
||||
if (!normalized) {
|
||||
if (!reportedSkips.has(rawUrl)) {
|
||||
reportedSkips.add(rawUrl);
|
||||
log.warn('skipped mint url', { url: rawUrl.slice(0, 80) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
// OR IGNORE covers both unique keys, url and host, in one clause.
|
||||
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
|
||||
VALUES (?, ?, 'unknown', ?, ?)`,
|
||||
)
|
||||
.run(normalized.url, normalized.host, now, now);
|
||||
|
||||
return result.changes > 0 ? normalized.url : null;
|
||||
}
|
||||
|
||||
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
|
||||
export function mintUrlByHost(host: string): string | null {
|
||||
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
|
||||
| { url: string }
|
||||
| undefined;
|
||||
return row?.url ?? null;
|
||||
}
|
||||
|
||||
export function allMintRows(): MintRow[] {
|
||||
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
|
||||
}
|
||||
|
||||
export function mintByHost(host: string): MintRow | undefined {
|
||||
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
|
||||
}
|
||||
|
||||
export function mintByUrl(url: string): MintRow | undefined {
|
||||
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
|
||||
}
|
||||
|
||||
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
|
||||
export function mintUrlByPubkey(pubkey: string): string | null {
|
||||
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
|
||||
| { url: string }
|
||||
| undefined;
|
||||
return row?.url ?? null;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { parseNuts, type MintInfo } from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { getDb, setState } from './db.ts';
|
||||
import { cacheIcon } from './icons.ts';
|
||||
import { log } from './log.ts';
|
||||
import { allMintRows, mintByUrl, type MintRow } from './mints.ts';
|
||||
|
||||
export interface ProbeResult {
|
||||
url: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
status: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** 1 to 2 consecutive failures is degraded, 3 or more is offline. */
|
||||
export function statusForFails(fails: number): 'online' | 'degraded' | 'offline' {
|
||||
if (fails === 0) return 'online';
|
||||
return fails < 3 ? 'degraded' : 'offline';
|
||||
}
|
||||
|
||||
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/v1/info`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const info = (await res.json()) as MintInfo;
|
||||
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
|
||||
|
||||
return { info, latencyMs: Date.now() - started };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe one mint and write the result.
|
||||
*
|
||||
* On failure the cached metadata columns are deliberately left untouched. That cache is
|
||||
* what lets an offline mint still render its full page, which is acceptance criterion #1.
|
||||
*/
|
||||
export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
const { info, latencyMs } = await fetchInfo(row.url);
|
||||
const nuts = parseNuts(info.nuts);
|
||||
|
||||
const iconFile = await cacheIcon(row, info.icon_url ?? null);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
icon_url = ?,
|
||||
icon_file = ?,
|
||||
pubkey = COALESCE(?, pubkey),
|
||||
info_json = ?,
|
||||
nuts_json = ?,
|
||||
version = COALESCE(?, version),
|
||||
status = 'online',
|
||||
consecutive_fails = 0,
|
||||
last_online = ?,
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
).run(
|
||||
info.name ?? null,
|
||||
info.description ?? null,
|
||||
info.icon_url ?? null,
|
||||
iconFile,
|
||||
info.pubkey ?? null,
|
||||
JSON.stringify(info),
|
||||
JSON.stringify(nuts),
|
||||
info.version ?? null,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)').run(
|
||||
row.url,
|
||||
now,
|
||||
latencyMs,
|
||||
);
|
||||
|
||||
const changed = row.status !== 'online';
|
||||
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: 'online' });
|
||||
|
||||
return { url: row.url, ok: true, latencyMs, status: 'online', changed };
|
||||
} catch (err) {
|
||||
const fails = row.consecutive_fails + 1;
|
||||
const status = statusForFails(fails);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
|
||||
WHERE url = ?`,
|
||||
).run(fails, status, now, now, row.url);
|
||||
|
||||
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)').run(
|
||||
row.url,
|
||||
now,
|
||||
);
|
||||
|
||||
const changed = row.status !== status;
|
||||
if (changed) {
|
||||
log.info('mint state change', {
|
||||
url: row.url,
|
||||
from: row.status,
|
||||
to: status,
|
||||
fails,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return { url: row.url, ok: false, latencyMs: null, status, changed };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `tasks` with at most `limit` in flight. */
|
||||
async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
if (item !== undefined) await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export async function probeAll(rows: MintRow[] = allMintRows()): Promise<ProbeResult[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const started = Date.now();
|
||||
const results: ProbeResult[] = [];
|
||||
await pooled(rows, config.probeConcurrency, async (row) => {
|
||||
results.push(await probeMint(row));
|
||||
});
|
||||
|
||||
const online = results.filter((r) => r.ok).length;
|
||||
const changed = results.filter((r) => r.changed).length;
|
||||
log.info('probe cycle', {
|
||||
mints: results.length,
|
||||
online,
|
||||
down: results.length - online,
|
||||
changed,
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
|
||||
setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Probe a set of URLs immediately, used when discovery finds new mints. */
|
||||
export async function probeUrls(urls: string[]): Promise<void> {
|
||||
const rows = urls.map((u) => mintByUrl(u)).filter((r): r is MintRow => r !== undefined);
|
||||
if (rows.length > 0) await probeAll(rows);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
bayesianScore,
|
||||
compareMints,
|
||||
NEUTRAL_PRIOR_MEAN,
|
||||
parseNuts,
|
||||
type Health,
|
||||
type MintDetail,
|
||||
type MintInfo,
|
||||
type MintListItem,
|
||||
type MintStatus,
|
||||
type ProbeSample,
|
||||
type RatingDistribution,
|
||||
type Stats,
|
||||
} from '@cashumints/shared';
|
||||
import { config, startedAt } from './config.ts';
|
||||
import { getDb, getStateNumber, getState } from './db.ts';
|
||||
import { mintByHost, type MintRow } from './mints.ts';
|
||||
|
||||
/**
|
||||
* One review per author per mint, newest wins.
|
||||
*
|
||||
* Kind 38000 is in the addressable range, so a later event from the same author
|
||||
* replaces the earlier one. The old site applied the same rule in memory
|
||||
* (aggregateReviews, keyed by pubkey); doing it in SQL keeps counts honest and stops
|
||||
* one npub from moving a mint's average by re-posting.
|
||||
*/
|
||||
const LATEST_REVIEWS = `
|
||||
SELECT mint_url, pubkey, rating, created_at FROM (
|
||||
SELECT mint_url, pubkey, rating, created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id
|
||||
) AS rn
|
||||
FROM reviews
|
||||
) WHERE rn = 1
|
||||
`;
|
||||
|
||||
interface AggRow {
|
||||
mint_url: string;
|
||||
review_count: number;
|
||||
rating_avg: number | null;
|
||||
last_review_at: number | null;
|
||||
}
|
||||
|
||||
function aggregates(): Map<string, AggRow> {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT mint_url,
|
||||
COUNT(*) AS review_count,
|
||||
AVG(rating) AS rating_avg,
|
||||
MAX(created_at) AS last_review_at
|
||||
FROM latest GROUP BY mint_url`,
|
||||
)
|
||||
.all() as AggRow[];
|
||||
|
||||
return new Map(rows.map((r) => [r.mint_url, r]));
|
||||
}
|
||||
|
||||
/**
|
||||
* C in the Bayesian formula.
|
||||
*
|
||||
* Defaults to a neutral 3, not the observed global mean: see the long comment in
|
||||
* shared/src/score.ts for why the observed mean (about 4.7 here) makes the formula
|
||||
* fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md
|
||||
* behaviour, or set any number to pin it.
|
||||
*/
|
||||
function priorMean(): number {
|
||||
const override = process.env['SCORE_PRIOR_MEAN'];
|
||||
|
||||
if (override && override !== 'global') {
|
||||
const n = Number.parseFloat(override);
|
||||
if (Number.isFinite(n) && n >= 1 && n <= 5) return n;
|
||||
}
|
||||
|
||||
if (override === 'global') {
|
||||
const row = getDb()
|
||||
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`)
|
||||
.get() as { avg: number | null };
|
||||
return row.avg ?? NEUTRAL_PRIOR_MEAN;
|
||||
}
|
||||
|
||||
return NEUTRAL_PRIOR_MEAN;
|
||||
}
|
||||
|
||||
function iconPath(row: MintRow): string | null {
|
||||
return row.icon_file ? `/icons/${row.icon_file}` : null;
|
||||
}
|
||||
|
||||
function round1(n: number | null): number | null {
|
||||
return n === null ? null : Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
|
||||
const base = {
|
||||
review_count: agg?.review_count ?? 0,
|
||||
rating_avg: agg?.rating_avg ?? null,
|
||||
status: row.status,
|
||||
last_review_at: agg?.last_review_at ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
url: row.url,
|
||||
host: row.host,
|
||||
name: row.name,
|
||||
icon: iconPath(row),
|
||||
status: row.status as MintStatus,
|
||||
last_online: row.last_online,
|
||||
review_count: base.review_count,
|
||||
rating_avg: round1(base.rating_avg),
|
||||
score: bayesianScore(base, mean, now),
|
||||
last_review_at: base.last_review_at,
|
||||
version: row.version,
|
||||
};
|
||||
}
|
||||
|
||||
export function listMints(limit?: number): MintListItem[] {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const agg = aggregates();
|
||||
const mean = priorMean();
|
||||
|
||||
const items = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
|
||||
.map((row) => toListItem(row, agg.get(row.url), mean, now))
|
||||
.sort(compareMints);
|
||||
|
||||
return limit && limit > 0 ? items.slice(0, limit) : items;
|
||||
}
|
||||
|
||||
function distribution(mintUrl: string): RatingDistribution {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT rating, COUNT(*) AS n FROM latest
|
||||
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
|
||||
)
|
||||
.all(mintUrl) as { rating: number; n: number }[];
|
||||
|
||||
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
|
||||
for (const r of rows) {
|
||||
const key = String(r.rating) as keyof RatingDistribution;
|
||||
if (key in dist) dist[key] = r.n;
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
function reviews90d(mintUrl: string): number {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
|
||||
)
|
||||
.get(mintUrl, cutoff) as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
|
||||
function uptime30d(mintUrl: string): number | null {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
|
||||
const row = getDb()
|
||||
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
|
||||
.get(mintUrl, cutoff) as { up: number | null; n: number };
|
||||
|
||||
if (!row.n || row.up === null) return null;
|
||||
return Math.round(row.up * 1000) / 1000;
|
||||
}
|
||||
|
||||
function recentProbes(mintUrl: string): ProbeSample[] {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
|
||||
return getDb()
|
||||
.prepare(
|
||||
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
|
||||
)
|
||||
.all(mintUrl, cutoff) as ProbeSample[];
|
||||
}
|
||||
|
||||
function parseInfo(json: string | null): MintInfo | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
return JSON.parse(json) as MintInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMintDetail(host: string): MintDetail | null {
|
||||
const row = mintByHost(host);
|
||||
if (!row) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const agg = aggregates().get(row.url);
|
||||
const item = toListItem(row, agg, priorMean(), now);
|
||||
const info = parseInfo(row.info_json);
|
||||
|
||||
let nuts: string[] = [];
|
||||
if (row.nuts_json) {
|
||||
try {
|
||||
nuts = JSON.parse(row.nuts_json) as string[];
|
||||
} catch {
|
||||
nuts = [];
|
||||
}
|
||||
}
|
||||
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
|
||||
|
||||
return {
|
||||
...item,
|
||||
description: row.description,
|
||||
pubkey: row.pubkey,
|
||||
info,
|
||||
nuts,
|
||||
first_seen: row.first_seen,
|
||||
last_probe: row.last_probe,
|
||||
updated_at: row.updated_at,
|
||||
rating_distribution: distribution(row.url),
|
||||
reviews_90d: reviews90d(row.url),
|
||||
uptime_30d: uptime30d(row.url),
|
||||
probes_recent: recentProbes(row.url),
|
||||
};
|
||||
}
|
||||
|
||||
let statsCache: { at: number; value: Stats } | null = null;
|
||||
const STATS_TTL_S = 60;
|
||||
|
||||
export function getStats(): Stats {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (statsCache && now - statsCache.at < STATS_TTL_S) return statsCache.value;
|
||||
|
||||
const db = getDb();
|
||||
const counts = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 'online') AS online,
|
||||
SUM(status = 'offline') AS offline,
|
||||
SUM(status = 'degraded') AS degraded
|
||||
FROM mints`,
|
||||
)
|
||||
.get() as { total: number; online: number | null; offline: number | null; degraded: number | null };
|
||||
|
||||
const reviews = db
|
||||
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`)
|
||||
.get() as { n: number; last: number | null };
|
||||
|
||||
const value: Stats = {
|
||||
mints_total: counts.total,
|
||||
mints_online: counts.online ?? 0,
|
||||
mints_offline: counts.offline ?? 0,
|
||||
mints_degraded: counts.degraded ?? 0,
|
||||
reviews_total: reviews.n,
|
||||
last_review_at: reviews.last,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
statsCache = { at: now, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Health bypasses the stats cache: it is the endpoint you page on. */
|
||||
export function getHealth(): Health {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastProbe = getStateNumber('last_probe_at');
|
||||
const lastDiscovery = getStateNumber('last_discovery_at');
|
||||
const discoveryOk = getState('last_discovery_ok') !== '0';
|
||||
|
||||
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
|
||||
const staleAfter = config.probeIntervalMin * 60 * 3;
|
||||
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
|
||||
|
||||
return {
|
||||
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
|
||||
uptime_s: now - startedAt,
|
||||
last_probe_at: lastProbe,
|
||||
last_discovery_at: lastDiscovery,
|
||||
mints_tracked: tracked,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* pnpm seed: ingest the initial mint list, run one probe cycle, run one discovery
|
||||
* backfill, print a summary table.
|
||||
*
|
||||
* The list below is every mint URL carried in a kind 38172 announcement on the old
|
||||
* site's relay pool at the time of the rebuild (see NOTES.md: the old project has no
|
||||
* hardcoded list of its own, its directory is built live from Nostr). Seeding is only
|
||||
* a warm start, discovery finds everything here on its own and keeps finding more.
|
||||
*/
|
||||
import { closeDb, getDb } from './db.ts';
|
||||
import { closePool, runDiscovery } from './discovery.ts';
|
||||
import { log } from './log.ts';
|
||||
import { insertMintIfNew } from './mints.ts';
|
||||
import { probeAll } from './probe.ts';
|
||||
import { listMints } from './queries.ts';
|
||||
|
||||
const SEED_MINTS = [
|
||||
'https://21mint.me',
|
||||
'https://bitcointxoko.com/cashu/api/v1/dMk78c5aR7uhHzcqH3Bwqp',
|
||||
'https://cashu.boats',
|
||||
'https://cashu.mineracks.com',
|
||||
'https://forge.flashapp.me',
|
||||
'https://lnb.bolverker.com/cashu/api/v1/GZjPXyQXsgnZEgKZsLRQyy',
|
||||
'https://lnwallet.app/cashu/api/v1/9tZLEVyknrtaGxeBjEecc4',
|
||||
'https://bb.mountainlake.io/cashu/api/v1/WpczZwvNES7wMa8ForKGmh',
|
||||
'https://mint.0xchat.com',
|
||||
'https://mint.28waves.com',
|
||||
'https://mint.belgianbitcoinembassy.org',
|
||||
'https://mint.btcforplebs.com',
|
||||
'https://mint.cashu.chat',
|
||||
'https://mint.coinos.io',
|
||||
'https://mint.endfiat.money',
|
||||
'https://mint.fredix.xyz',
|
||||
'https://mint.gitvid.net',
|
||||
'https://mint.gwoq.com',
|
||||
'https://mint.kashir.xyz',
|
||||
'https://mint.lnfast.com',
|
||||
'https://mint.lnserver.com',
|
||||
'https://mint.lnvoltz.com',
|
||||
'https://mint.lnw.cash',
|
||||
'https://mint.lnwallet.app',
|
||||
'https://mint.mikelcalvo.net',
|
||||
'https://mint.minibits.cash/Bitcoin',
|
||||
'https://mint.mineracks.com',
|
||||
'https://mint.mountainlake.io',
|
||||
'https://mint.nostrich.cc',
|
||||
'https://mint.quixote.cash/cashu/api/v1/TX6nZECe8r6hjDxtdA6N5b',
|
||||
'https://mint.sharegap.net',
|
||||
'https://mint.westernbtc.com',
|
||||
'https://mint2.nutmix.cash',
|
||||
'https://obsessedjerky7.lnbits.com/cashu/api/v1/L4bNfiBtTyhEpZWdt6QTb4',
|
||||
'https://smilemoji.cash:3338',
|
||||
'https://stpaulinternet.net/cashu/api/v1/TTcmiEDSWmkefef3XqERKv',
|
||||
'https://kashu.me',
|
||||
];
|
||||
|
||||
function pad(s: string, width: number): string {
|
||||
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
getDb();
|
||||
|
||||
let added = 0;
|
||||
for (const url of SEED_MINTS) if (insertMintIfNew(url)) added++;
|
||||
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
|
||||
|
||||
await probeAll();
|
||||
const discovery = await runDiscovery(true);
|
||||
|
||||
// Discovery almost always turns up mints the seed list did not have. Probe those too,
|
||||
// otherwise they sit at status 'unknown' with no metadata.
|
||||
if (discovery.newMints.length > 0) {
|
||||
log.info('probing newly discovered mints', { count: discovery.newMints.length });
|
||||
await probeAll();
|
||||
}
|
||||
|
||||
const mints = listMints();
|
||||
const width = { host: 44, status: 9, score: 7, reviews: 8, rating: 7 };
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
[
|
||||
pad('MINT', width.host),
|
||||
pad('STATUS', width.status),
|
||||
pad('SCORE', width.score),
|
||||
pad('REVIEWS', width.reviews),
|
||||
pad('RATING', width.rating),
|
||||
'VERSION',
|
||||
].join(' '),
|
||||
);
|
||||
console.log('-'.repeat(110));
|
||||
|
||||
for (const m of mints) {
|
||||
console.log(
|
||||
[
|
||||
pad(m.host, width.host),
|
||||
pad(m.status, width.status),
|
||||
pad(m.score.toFixed(3), width.score),
|
||||
pad(String(m.review_count), width.reviews),
|
||||
pad(m.rating_avg === null ? '-' : m.rating_avg.toFixed(1), width.rating),
|
||||
m.version ?? '-',
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
const reviews = mints.reduce((sum, m) => sum + m.review_count, 0);
|
||||
console.log('-'.repeat(110));
|
||||
console.log(
|
||||
`${mints.length} mints (${online} online, ${offline} offline), ${reviews} reviews indexed`,
|
||||
);
|
||||
|
||||
closePool();
|
||||
closeDb();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.ts';
|
||||
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
|
||||
|
||||
export function createApp(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
// Public read-only API. There is nothing to protect and every client is a browser.
|
||||
app.use('/api/*', cors());
|
||||
app.use('/icons/*', cors());
|
||||
|
||||
app.get('/api/health', (c) => {
|
||||
const health = getHealth();
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json(health, health.status === 'ok' ? 200 : 503);
|
||||
});
|
||||
|
||||
app.get('/api/stats', (c) => c.json(getStats()));
|
||||
|
||||
app.get('/api/mints', (c) => {
|
||||
const raw = c.req.query('limit');
|
||||
const limit = raw ? Number.parseInt(raw, 10) : undefined;
|
||||
return c.json(listMints(Number.isFinite(limit) ? limit : undefined));
|
||||
});
|
||||
|
||||
app.get('/api/mints/:host', (c) => {
|
||||
const detail = getMintDetail(c.req.param('host'));
|
||||
if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404);
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// Cached mint icons, so an offline mint keeps its icon.
|
||||
app.use(
|
||||
'/icons/*',
|
||||
serveStatic({
|
||||
root: path.relative(process.cwd(), config.iconDir) || '.',
|
||||
rewriteRequestPath: (p) => p.replace(/^\/icons/, ''),
|
||||
onFound: (_p, c) => c.header('Cache-Control', 'public, max-age=86400'),
|
||||
}),
|
||||
);
|
||||
|
||||
app.notFound((c) => c.json({ error: 'not_found' }, 404));
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user