Files
CashuMints.space/shared/src/types.ts
T
michilisandClaude Opus 5 14548179a0 Hydrate the mint lists from the live API after paint.
/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>
2026-08-25 16:27:33 +02:00

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;
}