/mints was a snapshot of whatever the API held when `astro build` ran, and stayed that until the next build: a mint indexed at noon was reviewable at once — the 404 resolver saw to that — and simply had no card until 03:30. Every card's rating, review count and status were as stale as the page. The three index pages and the home page's three top-six strips now refetch `GET /api/mints?type=…` once, after paint, and rebuild their grids. The prerendered cards stay: they are the first paint, what a crawler indexes, and the whole page without JavaScript. Hydration only ever replaces them with something newer, and never with nothing — neither a failed fetch nor a well-formed empty array touches a grid that has cards in it. To make that affordable, the list payload grew the facts a chip is drawn from: `nuts`, `capabilities`, and the two probed LNURL fields. /mints and /lnurl-mints were fetching `GET /api/mints/:host` once per mint at build time to read two booleans off each; that N+1 is gone from both, which takes the build from fifty-six requests to one and is what makes the same read possible in a browser. Additive: `MintDetail` already had all four. web/src/lib/mint-cards.ts is MintCard.astro's parallel renderer, the same relationship review-cards.ts has with the reviews panel. Same classes, same data-* attributes — the sort, the search, the rank chips and the shared-element view transitions all read the DOM — and the same i18n, through the page's own inlined catalog rather than a build-time one. Base.astro gained `clientNamespaces`, so the home page can inline the `home.` catalog its strips need to rewrite "All 60 mints →" without putting 2KB of marketing copy on 1,300 mint pages. check-i18n reads the prop off the page, so the two cannot disagree. Verified in Chromium against the built site: 60 prerendered cards become 61 including a mint inserted after the build; sort, search and hide-offline operate on the new cards; /es/mints renders "En línea", "54 reseñas", "4,9" and "Solo fundir"; JavaScript disabled still shows all 60; an aborted or empty API leaves the grid alone; and a navigation away and back re-hydrates. 2016 pages build, link and hreflang checks pass, 30 web tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
/** Shapes returned by the API. The web app builds against these. */
|
|
|
|
import type { MintCapabilities } from './warnings.js';
|
|
|
|
/**
|
|
* Statuses a listed thing can be in.
|
|
*
|
|
* `announced` is the Fedimint addition, and it is deliberately not a synonym for
|
|
* `unknown`. `unknown` means "we have not checked yet"; `announced` means "there is no
|
|
* check we can run" — the federation exists on Nostr, nothing has confirmed it since,
|
|
* and nothing here will claim otherwise. A Cashu mint never carries it.
|
|
*/
|
|
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
|
|
|
|
/**
|
|
* Which ecosystem a listing belongs to.
|
|
*
|
|
* Deliberately open rather than a closed union: `mints.type` is a stored TEXT column
|
|
* with no CHECK constraint, so an older build reading a database written by a newer one
|
|
* has to be able to hold a value it does not have a page for. Compare against
|
|
* `ANNOUNCEMENT_KINDS` rather than switching exhaustively on this.
|
|
*/
|
|
export type MintType = 'cashu' | 'fedimint' | 'lnurl' | (string & {});
|
|
|
|
/** One item of `GET /api/mints`. */
|
|
export interface MintListItem {
|
|
url: string;
|
|
host: string;
|
|
name: string | null;
|
|
icon: string | null;
|
|
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed read as 'cashu'. */
|
|
type: MintType;
|
|
status: MintStatus;
|
|
last_online: number | null;
|
|
review_count: number;
|
|
rating_avg: number | null;
|
|
score: number;
|
|
last_review_at: number | null;
|
|
version: string | null;
|
|
|
|
/* ---- card chips ----
|
|
*
|
|
* The three fields below exist so a card can be drawn from the list payload alone.
|
|
* Before them, /mints fetched `GET /api/mints/:host` once per mint at build time just
|
|
* to read two booleans off each one, which is fine for fifty-five mints on one build
|
|
* machine and is not fine for every visitor's browser once the list hydrates. They are
|
|
* facts, never rendered strings: the label a chip prints is decided by `mintChip` in
|
|
* the reader's own language, on whichever side is drawing the card.
|
|
*
|
|
* A federation has no counterpart and needs none — it publishes no capability list, so
|
|
* `mintChip` returns null for one and always will. See the Fedimint branch of
|
|
* `getMintWarnings`.
|
|
*/
|
|
|
|
/**
|
|
* NUT numbers this mint publishes, as strings: `["4", "5", "17"]`. Cashu only; empty
|
|
* for the other ecosystems and for a mint whose `/v1/info` has never been read.
|
|
*/
|
|
nuts: string[];
|
|
/**
|
|
* NUT-04 and NUT-05 switches, the Cashu chip's only input. null means nothing is
|
|
* cached for this mint, which is a different fact from "both are on" — see
|
|
* `readCapabilities`.
|
|
*/
|
|
capabilities: MintCapabilities | null;
|
|
/**
|
|
* LNURL: the advertised withdraw ceiling, millisatoshi. Optional rather than
|
|
* `| null`, so this stays exactly what `Partial<LnurlFields>` declares on `MintDetail`
|
|
* and the two do not have to be kept identical by hand.
|
|
*/
|
|
max_withdrawable_msat?: number | null;
|
|
/** LNURL: whether the last probe reached the mint's funding node. */
|
|
funding_available?: boolean | null;
|
|
}
|
|
|
|
export interface ProbeSample {
|
|
ts: number;
|
|
ok: 0 | 1;
|
|
latency_ms: number | null;
|
|
}
|
|
|
|
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
|
|
|
|
/**
|
|
* `GET /api/mints/:host`.
|
|
*
|
|
* The Fedimint and LNURL keys are optional and absent on a Cashu mint, which is what
|
|
* keeps the Cashu payload unchanged. Read them through `FedimintDetail` or
|
|
* `LnurlDetail` after checking `type`.
|
|
*/
|
|
export interface MintDetail
|
|
extends MintListItem,
|
|
Partial<FedimintFields>,
|
|
Partial<LnurlFields> {
|
|
description: string | null;
|
|
pubkey: string | null;
|
|
info: MintInfo | null;
|
|
nuts: string[];
|
|
first_seen: number;
|
|
last_probe: number | null;
|
|
updated_at: number;
|
|
rating_distribution: RatingDistribution;
|
|
/** Reviews in the last 90 days, for the verdict strip's activity cell. */
|
|
reviews_90d: number;
|
|
uptime_30d: number | null;
|
|
probes_recent: ProbeSample[];
|
|
}
|
|
|
|
/**
|
|
* What `ecosystem_json` holds for a Fedimint row, and what `GET /api/mints/:host`
|
|
* spreads across the detail payload for one.
|
|
*
|
|
* Flattened rather than nested so a Cashu payload is byte for byte what it always was:
|
|
* these keys are simply absent on one. A third ecosystem adds its own interface here
|
|
* and its own optional keys below; nothing existing has to move.
|
|
*/
|
|
export interface FedimintFields {
|
|
/** The `d` tag of the announcement: 64 hex characters. `host` is derived from it. */
|
|
federation_id: string;
|
|
/** Every `u` tag that was a usable invite code, in announcement order. */
|
|
invite_codes: string[];
|
|
/** The `modules` tag, split: `["ln", "mint", "wallet", "lnv2", "meta"]`. */
|
|
modules: string[];
|
|
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
|
|
network: string | null;
|
|
/** `created_at` of the newest announcement seen for this federation. */
|
|
announced_at: number | null;
|
|
/** Who published that announcement. The `a` tag of a review points back at them. */
|
|
announcer_pubkey: string | null;
|
|
/**
|
|
* Who confirmed the status, when anything did: `"fedimint.observer"` today.
|
|
*
|
|
* null means nothing has, and then `status` is `announced` and never `online`. The
|
|
* page prints this next to the status, because "someone else says it is up" is a
|
|
* different claim from "we checked".
|
|
*/
|
|
status_source: string | null;
|
|
}
|
|
|
|
/** `GET /api/mints/:host` for a Fedimint federation: the detail plus its own fields. */
|
|
export type FedimintDetail = MintDetail & FedimintFields;
|
|
|
|
/**
|
|
* What `ecosystem_json` holds for an LNURL row.
|
|
*
|
|
* Two sources, never mixed: `features` is what the operator *announced* on Nostr, and
|
|
* everything under "probed" is what the mint's own endpoints said when they were last
|
|
* reached. The kind document makes that separation normative — a prober never rewrites
|
|
* an operator's capability list — and keeping the two in different fields is what makes
|
|
* it impossible to do by accident.
|
|
*
|
|
* Every millisatoshi field is stored exactly as the wire gave it. Conversion to sats
|
|
* happens once, at render, through `msatToSat`.
|
|
*/
|
|
export interface LnurlFields {
|
|
/** The `d` tag: the mint pubkey when it has one, else the normalized host. */
|
|
lnurl_id: string;
|
|
/** The https base URL. `host` is the routing slug derived from it. */
|
|
base_url: string;
|
|
/** The `features` tag, split. The operator's claim, never edited by a probe. */
|
|
features: string[];
|
|
/**
|
|
* What the last probe actually observed this mint serving.
|
|
*
|
|
* Kept apart from `features` above rather than merged into it, because the two are
|
|
* different kinds of statement — a claim and an observation — and the kind document
|
|
* makes it normative that a prober never rewrites the first. The page renders their
|
|
* union through `displayFeatures`; the publisher signs only this one.
|
|
*/
|
|
observed_features: string[];
|
|
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
|
|
network: string | null;
|
|
/** `created_at` of the newest announcement seen. null for a seeded row. */
|
|
announced_at: number | null;
|
|
/** Who published that announcement. The `a` tag of a review points back at them. */
|
|
announcer_pubkey: string | null;
|
|
|
|
/* ---- probed: from the mint's own endpoints ---- */
|
|
|
|
/**
|
|
* The funding node's identity key, from the mint advertisement.
|
|
*
|
|
* Sticky once learned: a probe that finds none does not clear it, because a node
|
|
* being unreachable for one request is not a change of identity. Its *absence from
|
|
* the latest probe* is recorded separately, in `funding_available`.
|
|
*/
|
|
mint_pubkey: string | null;
|
|
/**
|
|
* Whether the last probe found a reachable funding source.
|
|
*
|
|
* null before anything has probed. false is the degraded-but-online state: the mint
|
|
* answers, its limits are real, and `rotate`/`split`/`merge` still work, but nothing
|
|
* moves in or out over Lightning. See NOTES-LNURL.md for why this one bit cannot
|
|
* distinguish "never configured" from "unreachable right now", and why that is fine.
|
|
*/
|
|
funding_available: boolean | null;
|
|
/** Which endpoint answered: the withdraw advertisement, or the payRequest fallback. */
|
|
probe_endpoint: string | null;
|
|
/**
|
|
* Set when the host answered but with something that is not a mint advertisement.
|
|
*
|
|
* A distinct outcome from both online and offline, and it has to be: these endpoints
|
|
* return HTTP 200 for their errors, so "responding" and "working" are different
|
|
* questions. Carries the short reason, for the banner.
|
|
*/
|
|
invalid_reason: string | null;
|
|
|
|
/** Withdraw bounds, millisatoshi: what a note's value can actually be. */
|
|
min_withdrawable_msat: number | null;
|
|
max_withdrawable_msat: number | null;
|
|
/** Pay bounds, millisatoshi: what a minter can actually send. Not the same numbers. */
|
|
min_sendable_msat: number | null;
|
|
max_sendable_msat: number | null;
|
|
/** `Mint fees: <base>,<ppm>` from the payRequest metadata. Absent means fee-free. */
|
|
fee_base_msat: number | null;
|
|
fee_ppm: number | null;
|
|
|
|
/** The LUD-16 address, derived from `payLink` rather than the echoed identifier. */
|
|
lightning_address: string | null;
|
|
/** The Tor address from the one-pager, when one is advertised. */
|
|
onion_url: string | null;
|
|
|
|
/** The funding node, as the mint chooses to describe it. All optional, all msat. */
|
|
node_alias: string | null;
|
|
node_uri: string | null;
|
|
node_capacity_msat: number | null;
|
|
node_channels: number | null;
|
|
node_peers: number | null;
|
|
}
|
|
|
|
/** `GET /api/mints/:host` for an LNURL mint: the detail plus its own fields. */
|
|
export type LnurlDetail = MintDetail & LnurlFields;
|
|
|
|
/**
|
|
* `GET /api/stats`.
|
|
*
|
|
* The four `mints_*` fields count Cashu mints and only Cashu mints, exactly as they did
|
|
* before federations were indexed: they are read by the pulse ticker, the /mints
|
|
* description and the home page, and a number that silently changed meaning would be
|
|
* worse than a new field. `cashu_total` is the same number under a name that says so,
|
|
* and every ecosystem added later gets its own `*_total` beside `fedimint_total`.
|
|
*/
|
|
export interface Stats {
|
|
mints_total: number;
|
|
mints_online: number;
|
|
mints_offline: number;
|
|
mints_degraded: number;
|
|
reviews_total: number;
|
|
last_review_at: number | null;
|
|
updated_at: number;
|
|
/** Same value as `mints_total`, named for the ecosystem it counts. */
|
|
cashu_total: number;
|
|
fedimint_total: number;
|
|
/** Federations a real check confirmed were up. Never inferred from an announcement. */
|
|
fedimint_online: number;
|
|
fedimint_offline: number;
|
|
/** Federations announced on Nostr that no check has ever confirmed. */
|
|
fedimint_announced: number;
|
|
/** Reviews of federations (`k` = 38173), included in `reviews_total`. */
|
|
fedimint_reviews: number;
|
|
lnurl_total: number;
|
|
/** LNURL mints a probe reached. Online includes the degraded-funding ones. */
|
|
lnurl_online: number;
|
|
lnurl_offline: number;
|
|
/**
|
|
* Online mints whose funding source was unreachable at the last probe: up and
|
|
* serving, but nothing moves in or out over Lightning. A subset of `lnurl_online`,
|
|
* never added to it.
|
|
*/
|
|
lnurl_degraded_funding: number;
|
|
/** Reviews of LNURL mints (`k` = 38174), included in `reviews_total`. */
|
|
lnurl_reviews: number;
|
|
}
|
|
|
|
/**
|
|
* How one configured relay answered during the last discovery cycle.
|
|
*
|
|
* The three facts are deliberately separate, because the failure this exists to catch
|
|
* had all three looking different from each other: the relays in `RELAYS` connected
|
|
* fine, reached EOSE fine, and simply did not carry the archive, so `events` was the
|
|
* only field that would have said anything. A relay that is down and a relay that is
|
|
* up and empty are different problems with different fixes.
|
|
*/
|
|
export interface RelayHealth {
|
|
url: string;
|
|
/** A socket was opened to it. false means the address is wrong or the relay is down. */
|
|
connected: boolean;
|
|
/**
|
|
* Events it sent, counted before cross-relay deduplication — so this is what *this*
|
|
* relay contributed, not what was new because of it. Zero on a connected relay is
|
|
* the interesting number.
|
|
*/
|
|
events: number;
|
|
/** Every query it was asked ended in a real EOSE rather than in a timeout. */
|
|
eose: boolean;
|
|
}
|
|
|
|
/** `GET /api/health`. */
|
|
export interface Health {
|
|
status: 'ok' | 'degraded';
|
|
uptime_s: number;
|
|
last_probe_at: number | null;
|
|
last_discovery_at: number | null;
|
|
mints_tracked: number;
|
|
updated_at: number;
|
|
/**
|
|
* Per-relay outcome of the last discovery cycle. Empty until one has run — including
|
|
* on a fresh database, which is why a brand new deployment reports degraded until its
|
|
* first backfill finishes.
|
|
*/
|
|
discovery_relays: RelayHealth[];
|
|
/** Unique events the last discovery cycle received. null before the first one. */
|
|
last_discovery_events: number | null;
|
|
/** Which kind of cycle those numbers describe. */
|
|
last_discovery_mode: 'backfill' | 'incremental' | null;
|
|
/**
|
|
* The last backfill came back under `backfill_min_events`, or none has run yet.
|
|
*
|
|
* This is the flag that would have caught a year of ~31-event backfills against a
|
|
* relay list missing the archive. It forces `status` to degraded, and /api/health to
|
|
* 503, which is what the build gate and the site's own health checks read.
|
|
*/
|
|
discovery_starved: boolean;
|
|
/** `BACKFILL_MIN_EVENTS`, echoed so a reader of this payload can see the threshold. */
|
|
backfill_min_events: number;
|
|
}
|
|
|
|
/**
|
|
* A mint's `/v1/info` response (NUT-06). Every field is optional: mints in the wild
|
|
* omit most of it, and the site must render whatever is there.
|
|
*/
|
|
export interface MintInfo {
|
|
name?: string;
|
|
pubkey?: string;
|
|
version?: string;
|
|
description?: string;
|
|
description_long?: string;
|
|
contact?: MintContact[];
|
|
motd?: string;
|
|
icon_url?: string;
|
|
urls?: string[];
|
|
time?: number;
|
|
tos_url?: string;
|
|
nuts?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface MintContact {
|
|
method?: string;
|
|
info?: string;
|
|
}
|
|
|
|
/** A review as rendered in the browser, parsed from a Nostr event. */
|
|
export interface Review {
|
|
id: string;
|
|
pubkey: string;
|
|
created_at: number;
|
|
mint_url: string;
|
|
/** null when the event carries no parseable rating. Never defaulted to 5. */
|
|
rating: number | null;
|
|
content: string;
|
|
}
|
|
|
|
/** Resolved kind-0 metadata for a reviewer. */
|
|
export interface Profile {
|
|
pubkey: string;
|
|
name?: string;
|
|
display_name?: string;
|
|
picture?: string;
|
|
nip05?: string;
|
|
/** false when the kind-0 lookup returned nothing, used for the provenance note. */
|
|
found: boolean;
|
|
}
|