18 Commits
Author SHA1 Message Date
Michilis 4c1156f766 Merge pull request 'Stop a listing page from rewriting the next one's grid after navigation.' (#11) from dev into main
Reviewed-on: #11
2026-09-10 18:29:13 +00:00
michilisandClaude Fable 5.1 ae7664fbe0 Stop a listing page from rewriting the next one's grid after navigation.
Switching between /mints, /fedimints and /lnurl-mints showed the right list
for a moment and then flashed back to the previous ecosystem's mints. The
client router keeps every page's script alive, and onReady re-runs each
setup on every arrival, so a visited page's setup also ran on the next page.
All three grids were marked with the same bare data-mint-grid, so the stale
setup found the new grid, fetched its own type and overwrote it.

Mark and query each grid by ecosystem (data-mint-grid="cashu" etc.), the way
the home page already scopes data-home-grid, so a stale setup finds nothing
and bails. Skip the render in hydrateMintGrid when the grid has already been
detached by the router. Document the re-run-everywhere contract on onReady,
and add a static wiring test so a bare marker cannot come back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:36:26 +02:00
Michilis d40f8b3d0f Merge pull request 'Show a brief publishing note that fades out after a few seconds.' (#10) from dev into main
Reviewed-on: #10
2026-09-01 03:48:29 +00:00
michilisandCursor 70b35f4ccc Show a brief publishing note that fades out after a few seconds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:42:00 +02:00
Michilis e79d54ba34 Merge pull request 'Order the latest-reviews strip by created_at alone.' (#9) from dev into main
Reviewed-on: #9
2026-08-25 15:45:39 +00:00
michilisandClaude Opus 5 1eade490c8 Order the latest-reviews strip by created_at alone.
The home page carousel floated reviews whose author had a kind 0 ahead of
everything else, capped at 90 days old, so a named review from two months ago
sat between two reviews from this week. Under a heading that says "Latest
reviews", with each card's foot printing the very timestamp being overruled,
that reads as broken rather than as curation.

Strict recency now, newest first, as the last thing that happens to the list,
with the event id breaking ties so two builds of the same events agree. Names
and avatars are still resolved and still shown; they no longer decide the
order.

The candidate scan stops at the limit rather than gathering twelve times it:
the scan already runs newest first, so nothing further down can outrank what
it has, and the build resolves ten profiles per locale instead of a hundred
and twenty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:23:22 +02:00
Michilis 81901a862e Merge pull request 'Dev' (#8) from dev into main
Reviewed-on: #8
2026-08-25 14:40:01 +00:00
michilisandClaude Opus 5 860a4de009 Check in the dynamic-mint-data analysis it was already sitting on.
It was untracked in the working tree. web/src/lib/mint-cards.ts cites it for the
reasoning behind hydrating rather than moving the list to SSR, and a comment
pointing at a file nobody else has is worse than no comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:34:54 +02:00
michilisandClaude Opus 5 24fe2003b6 Drop the nightly rebuild timer.
The timer was load-bearing while the mint list was a build-time snapshot: a
rebuild was the only way a new mint, a new review count or a changed status ever
reached /mints. The list hydrates now, so all three arrive within a second of
load, in every language, and rebuilding 2,000 pages at 03:30 to refresh numbers
that refresh themselves is twenty minutes of CPU for nothing.

cashumints-web.service stays exactly as it is — it is the deploy-time publish
step, and now the only thing that starts it is a deploy. A build still produces
what only a build can: the prerendered HTML a crawler reads, a social card per
mint, the sitemap and hreflang set, and a /mint/{host} page for every mint known
at build time.

The one thing that gets staler is that last item. A mint indexed since the last
deploy has no prerendered page: /mint/newhost is a 404, whose resolver looks the
address up against the live API and renders it — readable, reviewable, noindex
until a deploy gives it a real page. That was already true between nightly
builds; this only lengthens the window.

README documents the one-time host commands to remove the installed timer, and
gains a "Live lists" section describing what replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:29:09 +02:00
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
michilisandClaude Opus 5 060c7f1a59 Refuse to publish a site built from a hollow index.
Health answering 200 and the index being complete are different claims. A year of
~31-event backfills left a perfectly healthy API serving a real, correct, complete
list of eight mints. A build against that succeeds — it prerenders eight cards —
and rsync --delete-after then replaces fifty-five with eight.

cashumints-web.service gains a second ExecStartPre after the health wait: count
/api/mints, and exit non-zero below MIN_MINTS_FOR_BUILD (default 20, overridable
with `systemctl edit`). A refusal aborts the unit before `pnpm build`, and
publishing is ExecStartPost, so the previously published site is untouched; the
OnFailure alert added in the last commit says why.

Counted by the "host": key rather than by counting braces, because the list
payload is about to carry a nested object per mint. A curl that fails at all
counts as zero, which is below every floor — so an API that fell over between the
health check and this line refuses the build instead of sailing through it.

Verified against three live APIs: 73 mints passes, a doctored 8-mint database
fails with the reason, and a dead port fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:13:16 +02:00
michilisandClaude Opus 5 0ebc8ada54 Make a crash loop reach somebody instead of scrolling past.
`Restart=on-failure` with no start limit is an infinite loop by definition: the
unit never reaches `failed`, `systemctl status` stays active (auto-restart), and
the only evidence is a journal moving at four lines a second. That is how 464
restarts over fifteen hours went unnoticed.

All three units now stop after five failures in 120s and run
OnFailure=cashumints-alert@%n.service. The window is 120s and not 60s because
RestartSec=5s plus a process that takes a few seconds to die can spread five
failures past a sixty second window, reset the counter, and loop forever anyway.

cashumints-alert@.service is a oneshot that takes the failed unit's name as its
instance. Configuration is /etc/cashumints/alert.env: NTFY_URL gets a plain-text
body, WEBHOOK_URL gets JSON carrying `content` so one payload fits Discord and
Slack-compatible endpoints. With neither set — or the file absent — it still
writes to the journal at ERROR via a `<3>` syslog prefix, so `journalctl -p err -t
cashumints-alert` is a complete history on a host nobody configured.

It cannot become a second thing to debug: each curl is bounded at 10s, each
failure falls back to a journal line, and the shell ends in `true`, so the alerter
always exits 0. Verified with systemd-analyze verify and by running the ExecStart
body against a local sink — the JSON parses, and every branch exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:10:01 +02:00
michilisandClaude Opus 5 9ffa53094d Make a starved discovery cycle say so, in the log and on /api/health.
For about a year the production RELAYS list did not include the relay carrying
the kind 38000/38172 archive. Every backfill read about thirty events, wrote them
faithfully, reported ok=true, and the nightly build republished an index of eight
mints. Nothing measured the difference between "the cycle completed" and "the
cycle read anything", so nothing went red.

Three signals now do:

  - Per-relay attribution. queryRelays() replaces pool.querySync(), which merges
    every relay into one deduplicated array and throws away who sent what. It
    keeps one subscription per relay over the pool's existing sockets and shares
    a single alreadyHaveEvent across them, so an event five relays carry is still
    verified once; receivedEvent fires before that check, which is what makes the
    per-relay count mean "what this relay contributed". The deadline moved out of
    each Subscription's own EOSE timer so `eose` means a frame arrived rather than
    something timed out.

  - A WARN naming any relay that will not connect, on every cycle, and any relay
    that connected and sent nothing, on backfills only. An incremental cycle is
    supposed to come back empty.

  - BACKFILL_MIN_EVENTS, default 200. Under it, ERROR discovery starvation
    suspected and a flag health reports as discovery_starved, forcing 503. Sticky
    across incremental cycles so an hourly cycle finding four events cannot clear
    what a backfill diagnosed; stored in the database so a restart cannot either.

A fresh database is starved until its first backfill lands. That is intended: it
holds the build's health gate rather than publishing a site made from nothing.

Verified against the live relay set — 1528 events, five relays connected, EOSE on
all five, health 200 — and against an unreachable list, which produces the two
WARN lines, the ERROR, and 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:07:01 +02:00
michilisandClaude Opus 5 65307ba278 Compile the API instead of running its TypeScript in production.
The unit's ExecStart named src/index.ts, so every start depended on the host
having Node 22.18 or newer for native type stripping. A deploy onto a host with
Node 20 met ERR_UNKNOWN_FILE_EXTENSION, exited in under a second, and was
restarted 464 times over fifteen hours with nothing anywhere going red.

api/tsconfig.json now emits to api/dist. The source keeps its explicit .ts import
specifiers, which is what makes `node --watch src/index.ts` work in development;
rewriteRelativeImportExtensions turns them into .js on the way out, so what runs
in production is ordinary ESM that any Node from 20.18 up will start.

`pnpm build` builds shared, then api, then web. `pnpm dev` is unchanged.

deploy/ is tracked rather than ignored: the unit files are the thing an operator
copies to /etc/systemd/system, and the alert unit added next has to live
somewhere a deploy can find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:58:53 +02:00
Michilis 987d7b2d84 Merge pull request 'Clarify the empty-WEB_ROOT hint to point at cashumints-web.' (#7) from dev into main
Reviewed-on: #7
2026-08-25 04:46:47 +00:00
michilisandCursor 06ba3d35e7 Clarify the empty-WEB_ROOT hint to point at cashumints-web.
A bare pnpm build only fills dist; production needs the publish unit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:46:23 +02:00
Michilis 4e22db5261 Merge pull request 'Serve the prerendered site from Node instead of nginx root.' (#6) from dev into main
Reviewed-on: #6
2026-08-25 04:28:30 +00:00
michilisandCursor 79a115be38 Serve the prerendered site from Node instead of nginx root.
Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:27:27 +02:00
58 changed files with 3444 additions and 308 deletions
+13
View File
@@ -96,6 +96,19 @@ SEO_PRODUCT_JSONLD=1
# so reviews the old site published to snort/primal were invisible to it. # so reviews the old site published to snort/primal were invisible to it.
RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net
# How many events a backfill has to read before it counts as having read anything.
#
# A backfill asks every relay above for the whole history of four kinds; on a working
# relay list that is thousands of events. Under this floor, discovery logs
# `ERROR discovery starvation suspected` and /api/health answers 503 with
# `discovery_starved: true` until the next backfill clears it.
#
# This exists because a RELAYS list missing the relay that carries the announcement
# archive returned about thirty events per backfill for a year, reported ok=true every
# time, and left the index at eight mints with every health signal green. Lower it only
# for a private or test relay that genuinely holds less; 1 disables the check.
#BACKFILL_MIN_EVENTS=200
# Profile relays for the BUILD (kind 0, prerendered reviewer names on the home # Profile relays for the BUILD (kind 0, prerendered reviewer names on the home
# page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind # page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind
# 0 at all and snort/primal hold almost none, so the two aggregators below are what # 0 at all and snort/primal hold almost none, so the two aggregators below are what
+748 -137
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,8 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json",
"dev": "node --env-file-if-exists=../.env --watch src/index.ts", "dev": "node --env-file-if-exists=../.env --watch src/index.ts",
"start": "node --env-file-if-exists=../.env src/index.ts", "start": "node --env-file-if-exists=../.env dist/index.js",
"seed": "node --env-file-if-exists=../.env src/seed.ts", "seed": "node --env-file-if-exists=../.env src/seed.ts",
"migrate": "node --env-file-if-exists=../.env src/migrate.ts", "migrate": "node --env-file-if-exists=../.env src/migrate.ts",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
+14
View File
@@ -112,6 +112,20 @@ export const config = {
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'), iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ?? relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[], [...DEFAULT_RELAYS]) as string[],
/**
* The floor a backfill cycle has to clear before it counts as a real read.
*
* For about a year this deployment's RELAYS list did not include the relay carrying
* the kind 38000/38172 archive. Every backfill returned about thirty events, wrote
* them, reported ok=true, and the index sat at eight mints while every health signal
* stayed green. A backfill asks five relays for the whole history of four kinds; on a
* working relay set it comes back with thousands. Anything under this is not a quiet
* network, it is a misconfigured one, and it says so in the log and on /api/health.
*
* Raise it on a deployment that genuinely has more history, lower it for a local
* test relay. It is deliberately not zero-able: set it to 1 if you mean "off".
*/
backfillMinEvents: int('BACKFILL_MIN_EVENTS', 200),
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10), probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60), discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
probeConcurrency: int('PROBE_CONCURRENCY', 8), probeConcurrency: int('PROBE_CONCURRENCY', 8),
+276 -14
View File
@@ -19,9 +19,10 @@ import {
type FedimintAnnouncement, type FedimintAnnouncement,
type LnurlAnnouncement, type LnurlAnnouncement,
type LnurlFields, type LnurlFields,
type RelayHealth,
} from '@cashumints/shared'; } from '@cashumints/shared';
import { config } from './config.ts'; import { config } from './config.ts';
import { getDb, setState, getStateNumber } from './db.ts'; import { getDb, setState, getState, getStateNumber } from './db.ts';
import type { Sql } from './db-driver.ts'; import type { Sql } from './db-driver.ts';
import { log } from './log.ts'; import { log } from './log.ts';
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts'; import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
@@ -39,8 +40,118 @@ export interface DiscoveryResult {
newMints: string[]; newMints: string[];
newReviews: number; newReviews: number;
ok: boolean; ok: boolean;
/** What each configured relay actually did, in `config.relays` order. */
relays: RelayHealth[];
/** A backfill that came in under `config.backfillMinEvents`. */
starved: boolean;
} }
/**
* What the last cycle did, kept so /api/health can answer for it.
*
* Written to the `state` table rather than held in memory, because the question it
* answers — "is discovery actually reading anything?" — has to survive the restart that
* would otherwise reset it to "no cycle yet, nothing to report". A process that crash
* loops would clear an in-memory flag on every attempt.
*/
export interface DiscoveryReport {
at: number;
mode: 'backfill' | 'incremental';
events: number;
ok: boolean;
relays: RelayHealth[];
starved: boolean;
}
/** `state` key holding the JSON of the above. */
const REPORT_KEY = 'last_discovery_report';
/**
* The last cycle's report, or null before any cycle has run.
*
* A row that will not parse reads as null — the same as no cycle — because the caller
* is a health endpoint and "I cannot tell you" must not be dressed up as "fine".
*/
export async function lastDiscoveryReport(): Promise<DiscoveryReport | null> {
const raw = await getState(REPORT_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as DiscoveryReport;
return Array.isArray(parsed.relays) ? parsed : null;
} catch {
return null;
}
}
/**
* Per-relay bookkeeping for one cycle.
*
* Every relay in `config.relays` gets a row up front, including the ones that are never
* reached, because a relay that produced no row at all is exactly the one worth naming:
* the year-long starvation was a relay list that connected cleanly and simply did not
* hold the archive, and the only field that would have shown it is a zero here.
*
* `events` counts what a relay sent *before* cross-relay deduplication, so five relays
* carrying the same 400 events report 400 each rather than 400 once and 0 four times.
* Attribution is the whole point; the deduplicated total is reported separately.
*/
class RelayTally {
private readonly rows = new Map<string, { events: number; subs: number; eoses: number; connected: boolean }>();
constructor(urls: readonly string[]) {
for (const url of urls) {
this.rows.set(url, { events: 0, subs: 0, eoses: 0, connected: false });
}
}
private row(url: string) {
let found = this.rows.get(url);
if (!found) {
found = { events: 0, subs: 0, eoses: 0, connected: false };
this.rows.set(url, found);
}
return found;
}
connected(url: string): void {
this.row(url).connected = true;
}
subscribed(url: string): void {
this.row(url).subs++;
}
event(url: string): void {
this.row(url).events++;
}
eose(url: string): void {
this.row(url).eoses++;
}
/** One row per configured relay, in configuration order. */
list(): RelayHealth[] {
return [...this.rows.entries()].map(([url, row]) => ({
url,
connected: row.connected,
events: row.events,
// A cycle asks a relay many questions. It only counts as having reached the end
// of the stream if it reached the end of every one of them.
eose: row.subs > 0 && row.eoses === row.subs,
}));
}
}
/**
* Long enough that the relay's own EOSE timer never wins.
*
* `Subscription` fires `oneose` both when an EOSE frame arrives and when its internal
* timer expires, so the two are indistinguishable from the callback. Pushing that timer
* out of reach and running the deadline here instead is what makes `eose` in the report
* mean "the relay said it was done" rather than "something gave up".
*/
const NEVER_EOSE_MS = 24 * 60 * 60 * 1000;
let pool: SimplePool | null = null; let pool: SimplePool | null = null;
/** /**
@@ -66,12 +177,100 @@ export function closePool(): void {
pool = null; pool = null;
} }
/**
* Ask every configured relay one filter, and record what each of them did.
*
* This replaces `pool.querySync(config.relays, …)`, which answers the same question and
* throws the attribution away: it merges five relays into one deduplicated array, so a
* relay list where four relays are empty and one carries everything is indistinguishable
* from five healthy ones. That indistinguishability is the bug this whole file is being
* changed for — a year of ~31-event backfills, `ok=true` every time.
*
* What it keeps from `querySync`, deliberately:
*
* - One subscription per relay over the pool's existing sockets, so this is the same
* number of connections as before.
* - A single `alreadyHaveEvent` shared across all five. `AbstractRelay._onmessage`
* consults it *before* `JSON.parse` and signature verification, so an event five
* relays all carry is still verified once. Per-relay `querySync` calls would have
* verified it five times, which at 500 events a page is real CPU.
* - `receivedEvent`, which fires on the way past that check, so the per-relay count is
* what the relay sent rather than what was new because of it.
*
* What it changes: the deadline is run here rather than by each `Subscription`'s own
* EOSE timer, so `oneose` firing means an EOSE frame actually arrived. See NEVER_EOSE_MS.
*
* Never throws. A relay that will not connect is a fact to record, not a reason to
* abandon the four that did.
*/
async function queryRelays(filter: Filter, tally: RelayTally | null): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
const known = new Set<string>();
const alreadyHaveEvent = (id: string): boolean => {
if (known.has(id)) return true;
known.add(id);
return false;
};
await Promise.all(
config.relays.map(async (url) => {
let relay;
try {
// The same connection budget subscribeMap would have used for this maxWait.
relay = await getPool().ensureRelay(url, {
connectionTimeout: Math.max(MAX_WAIT_MS * 0.8, MAX_WAIT_MS - 1000),
});
} catch {
// Left as connected=false in the tally, which is the whole report this needs.
return;
}
tally?.connected(url);
await new Promise<void>((resolve) => {
let settled = false;
let deadline: ReturnType<typeof setTimeout> | undefined;
const finish = (): void => {
if (settled) return;
settled = true;
if (deadline !== undefined) clearTimeout(deadline);
resolve();
};
try {
const sub = relay.subscribe([filter], {
onevent: (event) => events.push(event),
alreadyHaveEvent,
receivedEvent: () => tally?.event(url),
oneose: () => {
tally?.eose(url);
sub.close('closed automatically on eose');
},
onclose: finish,
eoseTimeout: NEVER_EOSE_MS,
});
tally?.subscribed(url);
deadline = setTimeout(() => sub.close('closed on maxWait'), MAX_WAIT_MS);
} catch {
// The socket went away between ensureRelay and the REQ.
finish();
}
});
}),
);
return events;
}
/** /**
* Query one kind, paging backwards with `until` until a page yields nothing new. * 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 * Relays cap `limit` independently, so paging is the only way a fresh database
* converges to the complete history. * converges to the complete history.
*/ */
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> { async function fetchKind(
kind: number,
since: number | null,
tally: RelayTally | null,
): Promise<NostrEvent[]> {
const seen = new Map<string, NostrEvent>(); const seen = new Map<string, NostrEvent>();
let until: number | undefined; let until: number | undefined;
@@ -82,7 +281,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
let batch: NostrEvent[]; let batch: NostrEvent[];
try { try {
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS }); batch = await queryRelays(filter, tally);
} catch (err) { } catch (err) {
log.warn('relay query failed', { log.warn('relay query failed', {
kind, kind,
@@ -123,6 +322,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
async function fetchReviewsForMint( async function fetchReviewsForMint(
target: ReviewTarget, target: ReviewTarget,
since: number | null, since: number | null,
tally: RelayTally | null,
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const filters: Filter[] = []; const filters: Filter[] = [];
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT }; const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
@@ -178,11 +378,7 @@ async function fetchReviewsForMint(
} }
const batches = await Promise.all( const batches = await Promise.all(
filters.map((filter) => filters.map((filter) => queryRelays(filter, tally).catch(() => [] as NostrEvent[])),
getPool()
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
.catch(() => [] as NostrEvent[]),
),
); );
return batches.flat(); return batches.flat();
@@ -596,6 +792,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
const since = lastRun === null ? null : Math.max(0, lastRun - 3600); const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
const newMints = new Set<string>(); const newMints = new Set<string>();
const tally = new RelayTally(config.relays);
let newReviews = 0; let newReviews = 0;
let events = 0; let events = 0;
let ok = true; let ok = true;
@@ -610,7 +807,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
const announcementsByType = new Map<string, NostrEvent[]>(); const announcementsByType = new Map<string, NostrEvent[]>();
await Promise.all( await Promise.all(
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => { Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
announcementsByType.set(type, await fetchKind(kind, since)); announcementsByType.set(type, await fetchKind(kind, since, tally));
}), }),
); );
@@ -625,10 +822,10 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
// Announcements alone miss mints that only ever appear in a review's `u` tag, // Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too. // so reviews feed discovery too.
const reviews = await fetchKind(KIND_REVIEW, since); const reviews = await fetchKind(KIND_REVIEW, since, tally);
// The recent window catches anything a relay dropped from the unbounded query. // The recent window catches anything a relay dropped from the unbounded query.
const recent = const recent =
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : []; since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S, tally) : [];
const byId = new Map<string, NostrEvent>(); const byId = new Map<string, NostrEvent>();
for (const e of [...reviews, ...recent]) byId.set(e.id, e); for (const e of [...reviews, ...recent]) byId.set(e.id, e);
@@ -689,7 +886,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
while (cursor < targets.length) { while (cursor < targets.length) {
const target = targets[cursor++]; const target = targets[cursor++];
if (!target) continue; if (!target) continue;
const found = await fetchReviewsForMint(target, since); const found = await fetchReviewsForMint(target, since, tally);
if (found.length > 0) { if (found.length > 0) {
events += found.length; events += found.length;
newReviews += await ingestReviews(found, index); newReviews += await ingestReviews(found, index);
@@ -707,14 +904,79 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) }); log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
} }
const mode = backfill ? 'backfill' : 'incremental';
const relays = tally.list();
/*
* Name the relay, every time, one line each.
*
* A relay that would not connect is worth saying on any cycle: the address is wrong,
* or it is down, and neither gets better by itself. A relay that connected and sent
* nothing is only news on a backfill — an incremental cycle asking for the last hour
* of four kinds legitimately comes back empty, and warning about that hourly would
* train everyone to skip the line that eventually matters.
*/
for (const relay of relays) {
if (!relay.connected) {
log.warn('discovery relay unreachable', { relay: relay.url, mode });
continue;
}
if (backfill && relay.events === 0) {
log.warn('discovery relay returned no events', { relay: relay.url, mode });
} else if (!relay.eose) {
log.warn('discovery relay never reached EOSE', {
relay: relay.url,
mode,
events: relay.events,
});
}
}
/*
* The floor, and the flag the health endpoint reads.
*
* Only a backfill is measured against it. A backfill asks for the entire history of
* every announcement kind and every review, so on a working relay set it is thousands
* of events; an incremental cycle asks for one interval and is supposed to be small.
*
* The flag is sticky across incremental cycles: an hourly cycle that finds four
* events must not clear a starvation a backfill diagnosed, so a non-backfill carries
* forward whatever the last backfill concluded.
*/
let starved: boolean;
if (backfill) {
starved = events < config.backfillMinEvents;
if (starved) {
log.error('discovery starvation suspected', {
events,
floor: config.backfillMinEvents,
relays: relays.length,
silent: relays.filter((r) => r.events === 0).length,
unreachable: relays.filter((r) => !r.connected).length,
hint: 'check RELAYS: a relay list missing the announcement archive looks exactly like this',
});
}
} else {
// No backfill has ever run in this deployment: nothing has confirmed the relay set
// reads anything, and saying "fine" would be the whole original bug.
starved = (await lastDiscoveryReport())?.starved ?? true;
}
const report: DiscoveryReport = { at: now, mode, events, ok, relays, starved };
// A report that cannot be written is not worth failing a cycle over; the cycle's own
// work is already committed, and health degrades on the stale timestamp instead.
await setState(REPORT_KEY, JSON.stringify(report)).catch(() => undefined);
log.info('discovery cycle', { log.info('discovery cycle', {
mode: backfill ? 'backfill' : 'incremental', mode,
events, events,
new_mints: newMints.size, new_mints: newMints.size,
new_reviews: newReviews, new_reviews: newReviews,
ok, ok,
starved,
relays: relays.map((r) => `${r.url}=${r.connected ? r.events : 'down'}`).join(' '),
ms: Date.now() - started, ms: Date.now() - started,
}); });
return { events, newMints: [...newMints], newReviews, ok }; return { events, newMints: [...newMints], newReviews, ok, relays, starved };
} }
+85 -13
View File
@@ -3,6 +3,7 @@ import {
compareMints, compareMints,
NEUTRAL_PRIOR_MEAN, NEUTRAL_PRIOR_MEAN,
parseNuts, parseNuts,
readCapabilities,
type Health, type Health,
type MintDetail, type MintDetail,
type MintInfo, type MintInfo,
@@ -16,6 +17,7 @@ import {
} from '@cashumints/shared'; } from '@cashumints/shared';
import { config, startedAt } from './config.ts'; import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts'; import { getDb, getStateNumber, getState } from './db.ts';
import { lastDiscoveryReport } from './discovery.ts';
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts'; import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
/** /**
@@ -105,6 +107,39 @@ function round1(n: number | null): number | null {
return n === null ? null : Math.round(n * 10) / 10; return n === null ? null : Math.round(n * 10) / 10;
} }
/**
* The NUT numbers a row publishes.
*
* `nuts_json` is what the prober wrote and wins; `info.nuts` is the raw NUT-06 object it
* was derived from, kept as a fallback for rows written before that column existed. One
* function so a list card and a detail page can never read a different answer off the
* same row.
*/
function rowNuts(row: MintRow, info: MintInfo | null): string[] {
if (row.nuts_json) {
try {
const parsed = JSON.parse(row.nuts_json) as string[];
if (parsed.length > 0) return parsed;
} catch {
// Fall through to the info object below.
}
}
return info ? parseNuts(info.nuts) : [];
}
/**
* One list item.
*
* The chip fields at the bottom are why this now parses `info_json`. The alternative was
* what /mints and /lnurl-mints used to do: fetch `GET /api/mints/:host` once per mint to
* read two booleans off each one. That is an acceptable price for a build machine
* rendering fifty-five cards once a night and an unacceptable one for every browser that
* opens the page, which is what the list has to survive now that it hydrates.
*
* Facts, not sentences. `capabilities` is two booleans and `mintChip` turns them into
* "Melt only" in the reader's language, wherever the card is being drawn. Rendering the
* label here would ship one language to twenty-four locales.
*/
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem { function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
const base = { const base = {
review_count: agg?.review_count ?? 0, review_count: agg?.review_count ?? 0,
@@ -113,6 +148,15 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
last_review_at: agg?.last_review_at ?? null, last_review_at: agg?.last_review_at ?? null,
}; };
const info = parseInfo(row.info_json);
// A federation and an LNURL mint have no `info_json` and so get null, which is the
// honest value: not "both NUTs are enabled", but "there is nothing here to read".
const capabilities = info ? readCapabilities(info.nuts) : null;
// Only the two facts the LNURL chip is drawn from, not the whole ecosystem blob: this
// payload is fetched by every visitor on three pages.
const lnurl = row.type === 'lnurl' ? parseEcosystem<LnurlFields>(row) : null;
return { return {
url: row.url, url: row.url,
host: row.host, host: row.host,
@@ -126,6 +170,14 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
score: bayesianScore(base, mean, now), score: bayesianScore(base, mean, now),
last_review_at: base.last_review_at, last_review_at: base.last_review_at,
version: row.version, version: row.version,
nuts: rowNuts(row, info),
capabilities,
...(lnurl
? {
max_withdrawable_msat: lnurl.max_withdrawable_msat ?? null,
funding_available: lnurl.funding_available ?? null,
}
: {}),
}; };
} }
@@ -232,16 +284,9 @@ export async function getMintDetail(host: string): Promise<MintDetail | null> {
const item = toListItem(row, agg.get(row.url), mean, now); const item = toListItem(row, agg.get(row.url), mean, now);
const info = parseInfo(row.info_json); const info = parseInfo(row.info_json);
// `item.nuts` is the same read, through `rowNuts`. It used to be computed a second
let nuts: string[] = []; // time here with a subtly different fallback rule; one function now answers for both.
if (row.nuts_json) { const nuts = item.nuts;
try {
nuts = JSON.parse(row.nuts_json) as string[];
} catch {
nuts = [];
}
}
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
/* /*
* Type-specific columns are spread across the payload rather than nested under a key. * Type-specific columns are spread across the payload rather than nested under a key.
@@ -379,28 +424,55 @@ export function resetStatsCache(): void {
statsCache = null; statsCache = null;
} }
/** Health bypasses the stats cache: it is the endpoint you page on. */ /**
* Health bypasses the stats cache: it is the endpoint you page on.
*
* Three things can degrade it, and they are three different failures:
*
* probeStale nothing has checked a mint in three intervals
* !discoveryOk the last discovery cycle threw
* report.starved the last backfill read less than BACKFILL_MIN_EVENTS
*
* The third is the one added after the postmortem, and it is the only one that would
* have caught a year of the index sitting at eight mints: the cycles were completing,
* `ok` was true, the probes were fresh, and the relay list simply did not contain the
* relay holding the archive. "Ran without throwing" is not the same claim as "read
* anything", and only the second one is worth a green light.
*
* A deployment with an empty database reports degraded until its first backfill lands,
* because until then nothing has confirmed the relay set reads anything at all. That is
* intended: it holds `cashumints-web.service` at its health gate rather than letting it
* publish a site built from nothing.
*/
export async function getHealth(): Promise<Health> { export async function getHealth(): Promise<Health> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const db = await getDb(); const db = await getDb();
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([ const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = await Promise.all([
getStateNumber('last_probe_at'), getStateNumber('last_probe_at'),
getStateNumber('last_discovery_at'), getStateNumber('last_discovery_at'),
getState('last_discovery_ok'), getState('last_discovery_ok'),
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'), db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
lastDiscoveryReport(),
]); ]);
const discoveryOk = discoveryOkRaw !== '0'; const discoveryOk = discoveryOkRaw !== '0';
const staleAfter = config.probeIntervalMin * 60 * 3; const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter; const probeStale = lastProbe === null || now - lastProbe > staleAfter;
// No report at all is starvation by default: see the note above.
const starved = report?.starved ?? true;
return { return {
status: probeStale || !discoveryOk ? 'degraded' : 'ok', status: probeStale || !discoveryOk || starved ? 'degraded' : 'ok',
uptime_s: now - startedAt, uptime_s: now - startedAt,
last_probe_at: lastProbe, last_probe_at: lastProbe,
last_discovery_at: lastDiscovery, last_discovery_at: lastDiscovery,
mints_tracked: tracked?.n ?? 0, mints_tracked: tracked?.n ?? 0,
updated_at: now, updated_at: now,
discovery_relays: report?.relays ?? [],
last_discovery_events: report?.events ?? null,
last_discovery_mode: report?.mode ?? null,
discovery_starved: starved,
backfill_min_events: config.backfillMinEvents,
}; };
} }
+16 -1
View File
@@ -7,7 +7,22 @@
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noEmit": true, /*
* This project is compiled now, rather than run straight off `src/*.ts`.
*
* The reason is a fifteen-hour crash loop: the unit's ExecStart named `src/index.ts`,
* the host's `/usr/bin/node` was 20, and native type stripping is 22.18 and newer, so
* every start died on ERR_UNKNOWN_FILE_EXTENSION and systemd restarted it 464 times
* without anything going red. Emitting plain `.js` removes the host's Node version
* from the set of things that can break a deploy.
*
* `rewriteRelativeImportExtensions` is what lets the source keep its explicit `.ts`
* specifiers — which is what makes `node --watch src/index.ts` work in development —
* while the emitted files import `./config.js` and run anywhere.
*/
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
+23
View File
@@ -0,0 +1,23 @@
# /etc/cashumints/alert.env
#
# Read by cashumints-alert@.service, which systemd starts when any of the three units
# fails. Everything here is optional: with the file absent or both values empty, an
# alert is still written to the journal at ERROR priority and is readable with
#
# journalctl -p err -t cashumints-alert
#
# Set one or both to have failures leave the machine.
#
# Install it root-owned and not world-readable — a webhook URL is a capability:
# sudo install -d -m 0755 /etc/cashumints
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
# sudo systemctl daemon-reload
# An ntfy topic URL. Free and public at ntfy.sh; pick a topic name nobody will guess,
# because anyone who knows it can read and post to it.
#NTFY_URL=https://ntfy.sh/cashumints-alerts-CHANGE-ME
# Anything that accepts a JSON POST. The body carries `unit`, `host`, `at`, `text` and
# `content` — the last of which is what Discord and most Slack-compatible endpoints read,
# so one payload fits all three.
#WEBHOOK_URL=https://discord.com/api/webhooks/…
+101
View File
@@ -0,0 +1,101 @@
# /etc/systemd/system/cashumints-alert@.service
#
# The unit that makes a failure audible.
#
# The other three units each carry `OnFailure=cashumints-alert@%n.service`, so systemd
# starts one of these with the failed unit's name as the instance — `%i` below is
# literally `cashumints.service`, `cashumints-web.service` or `cashumints-site.service`.
#
# Why it exists: the API once crash looped 464 times over fifteen hours and nothing said
# so. `Restart=on-failure` with no start limit is an infinite loop that never reaches a
# `failed` state, so the journal filled with identical lines nobody was reading and
# every signal stayed green. The other half of the fix is StartLimitBurst= in each unit,
# which turns the loop into a failure; this is what carries that failure off the machine.
#
# Install:
# sudo install -m 0644 deploy/cashumints-alert@.service /etc/systemd/system/
# sudo install -d -m 0755 /etc/cashumints
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
# sudo systemctl daemon-reload
#
# No [Install] section and never enabled: OnFailure= starts it, and a unit that also
# started at boot would page on every reboot.
[Unit]
Description=Notify that %i failed
# No OnFailure= here. An alerter that alerts about its own failure is a loop, and this
# one is written so its worst case is a journal line rather than a retry.
[Service]
Type=oneshot
# The one file an operator edits, and the only reason this unit is configurable at all.
# Absent is a supported state — the leading `-` says so — and then the ExecStart below
# still writes to the journal at ERROR, which is what `systemctl status` and
# `journalctl -p err` read. See alert.env.example.
EnvironmentFile=-/etc/cashumints/alert.env
# So `journalctl -t cashumints-alert` finds every alert, whichever unit triggered it.
SyslogIdentifier=cashumints-alert
# Everything is inside one shell so the "nothing configured" branch is reachable without
# a second unit. The pieces, in order:
#
# - `printf '<3>…'` on stdout. systemd reads that syslog prefix off a journal stream
# and files the line at priority 3, ERROR, so `journalctl -p err` is a complete
# history of failures on a host with no webhook configured at all. `<4>` is warning.
# A prefix rather than systemd-cat, so the unit needs nothing from the filesystem it
# has just sandboxed itself away from.
# - NTFY_URL is a topic URL (https://ntfy.sh/your-topic). It gets a plain-text body
# naming the failed unit, plus the header names ntfy understands.
# - WEBHOOK_URL gets a JSON POST instead, for Discord, Slack or anything that speaks
# `{"content": …}` — every key is sent, so one payload fits all of them.
# - `--max-time 10` and a `||` fallback on each: an alert that hangs would hold the
# failed unit's job open, and an alert that fails must not itself become a second
# failed unit for somebody to notice. The shell ends in `true` for the same reason.
#
# `%i` is the failed unit's name, passed as an argument rather than interpolated into
# the shell text: systemd expands specifiers before /bin/sh ever sees the line, and a
# unit name is not a thing to trust to quoting.
ExecStart=/bin/sh -c '\
UNIT="$1"; \
HOST="$(hostname)"; \
WHEN="$(date -Is)"; \
TEXT="$UNIT failed on $HOST at $WHEN"; \
printf "<3>%s\\n" "$TEXT"; \
if [ -n "$NTFY_URL" ]; then \
/usr/bin/curl -fsS --max-time 10 \
-H "Title: cashumints: $UNIT failed" \
-H "Priority: high" \
-H "Tags: rotating_light" \
-d "$TEXT" "$NTFY_URL" >/dev/null \
|| printf "<3>%s\\n" "alert: POST to NTFY_URL failed"; \
fi; \
if [ -n "$WEBHOOK_URL" ]; then \
/usr/bin/curl -fsS --max-time 10 \
-H "Content-Type: application/json" \
-d "{\\"unit\\":\\"$UNIT\\",\\"host\\":\\"$HOST\\",\\"at\\":\\"$WHEN\\",\\"text\\":\\"$TEXT\\",\\"content\\":\\"$TEXT\\"}" \
"$WEBHOOK_URL" >/dev/null \
|| printf "<3>%s\\n" "alert: POST to WEBHOOK_URL failed"; \
fi; \
if [ -z "$NTFY_URL" ] && [ -z "$WEBHOOK_URL" ]; then \
printf "<4>%s\\n" "alert: no NTFY_URL or WEBHOOK_URL in /etc/cashumints/alert.env, journal only"; \
fi; \
true' _ %i
# It sends one HTTP request and writes one line. It needs no identity of its own, and
# DynamicUser gives it a throwaway one rather than sharing `nobody` with everything else
# on the host that also could not be bothered to make a user.
DynamicUser=yes
NoNewPrivileges=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
# An alert that cannot reach the network in ten seconds is not worth a stuck job.
TimeoutStartSec=30
+67
View File
@@ -0,0 +1,67 @@
# /etc/systemd/system/cashumints-site.service
#
# Serves the built site on loopback. nginx proxies to it and never opens a file itself,
# which is the point: when nginx held a `root` inside /home/cashumints, every directory
# down to dist had to be traversable by www-data, and the one that was not took the
# whole site down as a blanket 404 with nothing in the error log naming the cause.
#
# This is a long-running daemon, unlike cashumints-web.service next to it — that one is
# the oneshot that produces what this one serves.
[Unit]
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
# Give up after five failures in two minutes instead of restarting forever. A process
# that cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a far louder signal than a journal scrolling past. The window
# matches cashumints.service; see the note there for why it is 120s and not 60s. These
# two are [Unit] keys; systemd ignores them under [Service] with only a warning.
StartLimitIntervalSec=120
StartLimitBurst=5
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/web
# The tree comes from cashumints-web.service, which rsyncs it here after a build.
# Serving web/dist directly would mean a rebuild empties the site for the length of it.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=SITE_PORT=8789
Environment=SITE_HOST=127.0.0.1
Environment=WEB_ROOT=/var/lib/cashumints/web
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
# In-flight responses finish; idle keep-alive connections are closed at once.
TimeoutStopSec=15s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
+141
View File
@@ -0,0 +1,141 @@
# /etc/systemd/system/cashumints-web.service
#
# The frontend is static: `output: 'static'` in astro.config.mjs, and the whole site is
# produced ahead of time. There is no frontend build to keep alive, so this unit is a
# build rather than a daemon — one shot of `pnpm build`, which compiles shared/, renders
# a social card per mint and prerenders every page from the live API. The daemon that
# hands the result out is cashumints-site.service.
#
# Run it after a deploy, and only after a deploy:
# sudo systemctl start cashumints-web
#
# There used to be a cashumints-web.timer firing this at 03:30 every night, because the
# mint list was a snapshot of whatever the API held when the build ran and a nightly
# rebuild was the only way it ever changed. The list hydrates from the API after paint
# now, so a new mint, a new review count and a changed status all reach the page within
# a second of load, and rebuilding 2,000 pages at 03:30 to refresh numbers that refresh
# themselves is 20 minutes of CPU for nothing.
#
# What a build still produces, and therefore what a deploy is still for: the prerendered
# HTML a crawler reads, the social card per mint, the sitemap, and a `/mint/{host}` page
# for every mint known at build time. A mint indexed since the last deploy has no page of
# its own until the next one; the 404 fallback resolves it against the live API, so it is
# readable and reviewable in the meantime. That was already true between nightly builds.
#
# There is deliberately no [Install] section — this belongs to a deploy, not to a boot.
[Unit]
Description=Rebuild the cashumints.space static site
# Every page's data comes from the API over loopback, so the API has to be up.
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
# site with an empty one.
Requires=cashumints.service
After=cashumints.service network-online.target
Wants=network-online.target
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
[Service]
Type=oneshot
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space
# Where the published copy lands. Shared with the API and the site server, and created
# by systemd with this unit's ownership if it is not there yet.
StateDirectory=cashumints
Environment=NODE_ENV=production
# Where the build reaches the API. Must match PORT= in cashumints.service.
Environment=API_URL=http://127.0.0.1:8788
Environment=SITE_URL=https://cashumints.space
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
# forwards it. Set this only if the API ever moves to its own hostname. Declared here
# even though it is empty, because systemd's environment wins over .env — so what a
# production build emits cannot drift with an edit to that file.
Environment=PUBLIC_API_URL=
# After= orders the start; it does not wait for the port to accept connections. At boot
# the API is still opening its database and probing, so block until it reports healthy
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
# Then: does the API actually have an index to build a site out of?
#
# Health answering 200 says the process is up and its last backfill read something. It
# does not say how many mints are in the table, and those are different questions — the
# year of ~31-event backfills had a healthy API serving a real, complete, correct list of
# eight mints. A build against that succeeds, prerenders eight cards, and rsync happily
# replaces fifty-five with eight.
#
# So count the list before spending twenty minutes building from it. Below the floor
# this exits non-zero, systemd abandons the unit at ExecStartPre, and — because publishing
# is ExecStartPost, after the build — the previously published site is never touched. The
# site stays exactly as it was and the OnFailure alert says why.
#
# Counted by the `"host":` key, one per item, rather than by counting `{`: the list
# payload carries a nested object per mint (its NUT capability switches), so brace
# counting would report roughly double. No jq: it is not installed on this host and a
# build gate should not add a dependency to run.
#
# `Q` is a double-quote character, built with printf rather than written literally,
# because this whole command is already inside systemd's single quotes and a quote of
# either kind in the grep pattern would end the argument early.
#
# A curl that fails for any reason leaves `n` empty, `$${n:-0}` reads that as zero, and
# zero is below every floor — so an API that fell over between the health check above and
# this line refuses the build rather than sailing through it.
Environment=MIN_MINTS_FOR_BUILD=20
ExecStartPre=/bin/sh -c 'Q=$$(printf "\\042"); \
n=$$(curl -sf --max-time 30 http://127.0.0.1:8788/api/mints | grep -o "$${Q}host$${Q}:" | wc -l); \
if [ "$${n:-0}" -lt "$$MIN_MINTS_FOR_BUILD" ]; then \
printf "<3>%s\\n" "refusing to build: /api/mints returned $${n:-0} mints, floor is $$MIN_MINTS_FOR_BUILD. Previous site left untouched."; \
exit 1; \
fi; \
printf "%s\\n" "build gate: $$n mints, floor $$MIN_MINTS_FOR_BUILD"'
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
# and systemd's PATH does not include it.
ExecStart=/usr/bin/pnpm build
# Publish, as a separate step from building.
#
# `astro build` empties dist before it writes, so the site server cannot read dist
# directly — a rebuild would be a minute of 404s. It serves this copy instead, and the
# copy is only touched once a build has succeeded: a failed build leaves the previous
# site up rather than replacing it with a half-written one, which is the same reason
# Requires=cashumints.service is above and the same reason the mint-count gate is an
# ExecStartPre rather than a check after the fact.
#
# --delay-updates stages the changed files and renames them in at the end, so the window
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
# --delete-after keeps removals from landing before their replacements. Unchanged files
# — every hashed asset and card, which is nearly all of it — are not touched at all.
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
# ~200 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
# TimeoutStartSec is what bounds a Type=oneshot.
TimeoutStartSec=1800
# A build should not starve the API it is reading from.
Nice=10
# The site server runs as cashumints and reads its own files, so this no longer has to
# be world-readable — it was 0022 for nginx, back when nginx opened the files as
# www-data. Kept at 0022 anyway: rsync preserves these modes into the published copy,
# and a readable static site is easier to inspect than one that needs sudo.
UMask=0022
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
# ProtectHome is deliberately absent, unlike in cashumints.service: this unit writes
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
# store are all under it.
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+70
View File
@@ -0,0 +1,70 @@
# /etc/systemd/system/cashumints.service
[Unit]
Description=cashumints.space indexer and API
Wants=network-online.target
After=network-online.target
# Stop after five failures in two minutes rather than restarting forever.
#
# The window is 120s and not 60s because RestartSec=5s below means five attempts take
# a little over twenty seconds of restarts plus however long each attempt lives before
# it dies. A process that fails *slowly* — a database that times out, a port that takes
# four seconds to refuse — can spread five failures past a sixty second window and reset
# the counter forever, which is the loop this is supposed to stop. 120s covers that.
#
# The failure this exists for: ExecStart named a .ts file, /usr/bin/node was 20, and
# every start died in under a second. 464 restarts over fifteen hours, and because
# Restart=on-failure without a start limit never reaches a `failed` state, nothing
# anywhere went red. Both keys belong to [Unit] — under [Service] systemd only warns and
# ignores them.
StartLimitIntervalSec=120
StartLimitBurst=5
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/api
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=PORT=8788
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# Compiled JavaScript, run by the distribution's own node.
#
# This line used to read `src/index.ts`, which made every start depend on the host
# having Node 22.18 or newer for native type stripping. A host with Node 20 answered
# that with ERR_UNKNOWN_FILE_EXTENSION in under a second, 464 times over fifteen hours,
# and nothing anywhere went red. `pnpm build` now emits api/dist, so what runs here is
# ordinary ESM and any Node from 20.18 up will start it.
#
# Deliberately /usr/bin/node and nothing else: an nvm or fnm path is invisible to this
# unit's ProtectHome and breaks silently at the next version bump.
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=30s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+160
View File
@@ -0,0 +1,160 @@
# /etc/nginx/sites-available/cashumints.space
#
# nginx terminates TLS and proxies. It opens no file belonging to this project — not the
# built site, not an icon — and that is deliberate.
#
# It used to point a `root` at web/dist. Because nginx runs as www-data and everything
# this project owns runs as cashumints, that arrangement required every directory from /
# down to dist to be traversable by a user with no other business in the tree. A home
# directory at its default 0700 anywhere in that chain broke the entire site, and it
# broke it invisibly: `try_files` treats a permission error as a plain miss, so the
# symptom was a blanket 404, or an internal-redirect loop that ended in a 500 with the
# real cause named nowhere.
#
# Two upstreams now, both on loopback, both owned by the same user that built what they
# serve:
#
# 127.0.0.1:8789 cashumints-site.service the prerendered site
# 127.0.0.1:8788 cashumints.service /api/* and /icons/*
#
# Routing that used to live here lives with the thing that owns it. The locale 404 rule
# in particular was a hand-maintained alternation of 23 codes in a file that is not in
# the repository; adding a language meant remembering to edit it, and forgetting was
# silent. web/server.mjs resolves those from the built tree.
proxy_cache_path /var/cache/nginx/cashumints
levels=1:2
keys_zone=cashumints:10m
max_size=256m
inactive=10m
use_temp_path=off;
# Keep a few connections open to each upstream rather than reconnecting per request.
# Both processes hold idle sockets longer than nginx does, so nginx is always the side
# that closes and there is no window where it reuses a socket the upstream just dropped
# — that race is what produces sporadic 502s under load.
upstream cashumints_site {
server 127.0.0.1:8789;
keepalive 16;
}
upstream cashumints_api {
server 127.0.0.1:8788;
keepalive 8;
}
server {
listen 80;
listen [::]:80;
server_name cashumints.space;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://cashumints.space$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name cashumints.space;
# nginx 1.25 and later want `http2 on;` on its own line and warn about the form above.
# Left as is because it is the form that works on both, and Debian 12 ships 1.22.
ssl_certificate /etc/letsencrypt/live/cashumints.space/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cashumints.space/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Set here rather than upstream: this is the only part of the stack that knows a
# request arrived over TLS. Add `preload` only once you are content never to serve
# this name over plain HTTP again.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# The upstreams send no Content-Encoding, so compression is nginx's to do. gzip_proxied
# any is required — without it nginx refuses to compress a proxied response at all.
gzip on;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json
application/manifest+json application/xml image/svg+xml;
# Nothing here accepts an upload. The API's largest body is an 8 KB JSON submission,
# and rejecting the oversized ones at the edge keeps them off the Node process.
client_max_body_size 16k;
# Both upstreams are a process on this machine. A slow response is a bug, not a
# network condition, and failing fast beats holding a worker for a minute.
proxy_connect_timeout 2s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# HTTP/1.1 with an empty Connection header is what makes the keepalive pools above
# work; the default 1.0 opens a new socket per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
# Every proxied location includes Debian's /etc/nginx/proxy_params, which sets Host,
# X-Real-IP, X-Forwarded-For and X-Forwarded-Proto. That include is load-bearing, not
# decorative: the API's rate limiter reads the last hop of X-Forwarded-For to tell two
# visitors apart, and without it every request arrives from the loopback peer and
# shares one bucket. On a distro that ships no proxy_params, set those four by hand.
# Do not also set X-Forwarded-For alongside the include — declaring it twice is what
# produces nginx's "could not build optimal proxy_headers_hash" warning.
# The prerendered site. Cache-Control comes from server.mjs — a year and immutable for
# anything with a content hash in its name, revalidate-every-time for markup — so
# there is nothing to restate here.
location / {
proxy_pass http://cashumints_site;
include proxy_params;
}
# Health must always reflect the live process.
location = /api/health {
proxy_pass http://cashumints_api;
proxy_cache off;
# add_header replaces rather than merges: declaring one here drops every add_header
# inherited from the server block, so HSTS has to be restated alongside it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "no-store" always;
include proxy_params;
}
# Briefly cache the read-heavy endpoints.
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Restated for the same reason as in /api/health above.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Cache-Status $upstream_cache_status always;
include proxy_params;
}
location /api/ {
proxy_pass http://cashumints_api;
include proxy_params;
}
location /icons/ {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
include proxy_params;
}
# No error_page and no try_files. A miss is the site server's 404 page, in the right
# language and with a 404 status; an nginx error page here would replace it with a
# blank one and hide which upstream failed.
}
+361
View File
@@ -0,0 +1,361 @@
# Dynamic mint data: analysis
**Status:** analysis only. No code or config was changed.
**Goal:** new and updated mints, stats, and reviews should appear on the list (and related list surfaces) immediately, without waiting for the nightly static rebuild.
**Current architecture (important correction):** nginx does **not** serve `web/dist` as files. Production is:
```
browser → nginx :443 → cashumints-site (web/server.mjs :8789) → published copy of dist
→ cashumints_api (Hono :8788) → /api/*, /icons/*
```
`cashumints-web.timer` rebuilds at **03:30** daily, then `rsync`s `web/dist/` to `WEB_ROOT` (`/var/lib/cashumints/web`). Markup is a snapshot of whatever the API returned at that build.
---
## 1. Data flow audit
There are **no Astro content collections**. Every dynamic page either fetches the Hono API at **build time** (`web/src/lib/api.ts`, `API_URL`, default `http://127.0.0.1:8787` / prod `8788`) or hydrates in the browser from `/api/…` and/or Nostr.
Astro “islands” here are **not** React/Vue/Svelte with `client:load`. There is **zero** `client:*` usage. Runtime behaviour is vanilla `<script>` modules in `.astro` files, bundled by Vite, re-run on view transitions via `onReady()`.
### 1.1 Every route under `web/src/pages`
| Route | `getStaticPaths` | Build-time data | Client-side at runtime |
|---|---|---|---|
| `[...locale]/index.astro` (`/`, `/es`, …) | `localePaths` (one HTML page per locale) | `fetchMints`, `fetchFedimints`, `fetchLnurlMints`, `fetchStats`, `fetchHealth`; N+1 `fetchMint` / `fetchFedimint` / `fetchLnurlMint` for the top 6 of each; `fetchLatestReviews()` from relays via `nostr-build.ts` | **PulseTicker** refreshes `/api/stats` + `/api/health`. Search form is GET to `/mints`. Review carousel is **not** re-queried. ColorBends is WebGL only. |
| `[...locale]/mints.astro` | `localePaths` | `fetchMints()` then **one `fetchMint(host)` per mint** for NUT chips | Search / sort / hide-offline only rearrange **already-rendered** cards. **No live refetch.** |
| `[...locale]/fedimints.astro` | `localePaths` | `fetchFedimints()` (no N+1; no NUT chips) | Same client filter/sort as `/mints`. **No live refetch.** |
| `[...locale]/lnurl-mints.astro` | `localePaths` | `fetchLnurlMints()` + N+1 `fetchLnurlMint` for chips | Same. **No live refetch.** |
| `[...locale]/mint/[host].astro` | `localePathsFor` over **every cashu mint known at build** | `fetchMints()` + `fetchMint(host)` per mint, shared across locales | **MintLive** → `GET /api/mints/:host`. **Reviews** → Nostr via `nostr-tools` `SimplePool`. |
| `[...locale]/fedimint/[id].astro` | same pattern, `fetchFedimints` | federation detail | Same two islands. |
| `[...locale]/lnurl-mint/[host].astro` | same pattern, `fetchLnurlMints` | LNURL detail | Same two islands. |
| `[...locale]/reviews.astro` | `localePaths` | `fetchAllListings()`, `fetchStats()`, `fetchReviewFeed(…, 30)` from relays | **Re-queries relays** (`feed-client.ts` `loadFeed`), paginates, filters. This is the working “live list” pattern. |
| `[...locale]/wallets.astro` | `localePaths` | Hardcoded wallet array in the page | None |
| `[...locale]/about.astro` | `localePaths` | `fetchStats()` for counts in copy | None |
| `[...locale]/privacy.astro`, `terms.astro`, `disclaimer.astro` | `localePaths` | i18n catalogs only | None |
| `[...locale]/404.astro` + `pages/404.astro` | prefixed locales / English `/404` | None (static 404 shell) | **NotFound resolver:** `GET /api/mints/:host`, on miss `POST /api/index`, then reviews panel |
| `sitemap.xml.ts` | n/a (endpoint at build) | `fetchMints` / `fetchFedimints` / `fetchLnurlMints`; `isIndexableMint` filter | n/a |
| `robots.txt.ts` | n/a | `SITE_URL` only; `Disallow: /api/`, `/icons/` | n/a |
24 locales (`en` plus 23 prefixes). List pages are ~24 HTML files each. Mint detail pages are `mints × locales` (README ballpark: ~55 cashu mints × 24 ≈ 1,300+ pages, plus federations and LNURL).
### 1.2 Why `/mints` shows only a subset, with stale stats and reviews
**Not pagination.** `fetchMints()` calls `GET /api/mints?type=cashu` with **no `limit`**. `listMints()` in the API returns every matching row, sorted by score. The optional `?limit=` query is unused by the site.
**Not an `isIndexableMint` filter on the grid.** Unreachable, unreviewed mints still render as cards. That predicate only drops them from JSON-LD `ItemList` and the sitemap.
**The list is a frozen HTML snapshot.** `mints.astro` does:
```ts
const mints = await fetchMints();
const capabilities = await Promise.all(
mints.map((mint) => fetchMint(mint.host).then(…).catch(() => null)),
);
```
then maps every item to `<MintCard>`. After publish, those cards do not change until the next `cashumints-web.timer` run.
What that freezes:
| Card field | Source at build | Live counterpart |
|---|---|---|
| Presence of the mint | API `mints` table at 03:30 | Discovery + `POST /api/index` write new rows immediately |
| `review_count`, `rating_avg`, `score`, `last_review_at` | SQL aggregates of **ingested** Nostr reviews | Relays (and the API, once discovery has ingested) |
| `status`, `last_online` | last probe stored in the DB | `GET /api/mints/:host` (what MintLive already uses) |
| Melt-only / frozen chip | N+1 detail fetch of cached `/v1/info` | same detail payload |
**Why a mint can exist “on the site” but not on the list:** the 404 resolver (`NotFound.astro`) looks up `/mint/{host}` against the **live** API and, if missing, calls `POST /api/index`. That mint becomes reviewable at once. It does **not** get a card on `/mints` until rebuild. Same for Review-by-URL. The README states this as intended:
> Mint pages are prerendered, so new mints and new review counts appear at the next build. … an unbuilt mint still resolves through the client-side fallback on the 404 page.
**Why reviews look wrong on the list and right on the detail page:** `/mints` never talks to Nostr. It prints `MintListItem.review_count` / `rating_avg` from the API snapshot. The detail page’s `Reviews.astro` calls `loadReviews(subject)` against relays and replaces the panel. A review written today is on the detail page as soon as relays answer; the list card still shows yesterday’s count.
**Home page extra subset:** `mints.slice(0, 6)` (and the same for federations / LNURL). “Latest reviews” is a **build-time** relay read (`fetchLatestReviews(…, 10)`), unlike `/reviews`, which re-queries on load.
### 1.3 How the mint detail page fetches Nostr (the pattern that works)
**Component:** `web/src/components/Reviews.astro`
Root: `[data-reviews-panel]` with `data-subject={JSON.stringify(subject)}`.
Script imports `loadReviews` / `loadProfiles` from `web/src/lib/reviews-client.ts`.
**Library:** [`nostr-tools`](https://github.com/nbd-wtf/nostr-tools) **v2.10.4** — `SimplePool` from `nostr-tools/pool`, plus `nip19`. **Not NDK.**
**Relays** (`DEFAULT_RELAYS` in `shared/src/nostr.ts`, overridable with `PUBLIC_REVIEW_RELAYS`):
- `wss://relay.cashumints.space`
- `wss://nos.lol`
- `wss://relay.azzamo.net`
- `wss://relay.snort.social`
- `wss://relay.primal.net`
Profiles add `wss://purplepag.es` and `wss://relay.nostr.net`.
**Query:** kind `38000` (addressable reviews), two OR’d filters, `limit: 500`, `maxWait: 8000` ms:
1. `#d` = mint pubkey (plus LNURL alternate ids) and `#k` = announcement kind
2. `#u` = URL spellings (legacy Cashu reviews; federations skip this)
Newest event per author wins. In-tab cache 5 minutes. Empty result is treated as a relay error if the API had counted reviews.
**Live status (API, not Nostr):** `MintLive.astro` is a hidden `[data-mint-live={host}]` marker. Its script `fetch(`${apiBase}/api/mints/${host}`)` and patches the status chip, banner, limits, and NUT/module/feature rows. `apiBase` is `import.meta.env.PUBLIC_API_URL`, empty in production → same-origin `/api/…`.
**Unbuilt mint URLs:** `server.mjs` serves the locale 404. `NotFound.astro` derives the address from the path, `GET /api/mints/:host`, then `POST /api/index` on miss, then dispatches `cashumints:subject` so the same Reviews island starts.
---
## 2. API coverage
All routes are in `api/src/server.ts`. CORS is enabled on `/api/*` and `/icons/*` (`hono/cors`, default allow-all). Production uses **same origin** (`PUBLIC_API_URL=` empty; nginx proxies `/api/` and `/icons/`), so browsers never hit a cross-origin API unless that env is set.
### 2.1 Existing endpoints
#### `GET /api/mints`
- Query: `?type=cashu|fedimint|lnurl` (unknown type → empty array); optional `?limit=N`
- No type filter → **entire index**, each item tagged `type`
- **No default limit.** Full list.
- Response: `MintListItem[]`
```ts
{
url: string;
host: string;
name: string | null;
icon: string | null; // "/icons/…" or null
type: 'cashu' | 'fedimint' | 'lnurl' | string;
status: 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
last_online: number | null; // unix
review_count: number; // ingested, one-per-author
rating_avg: number | null; // 1 decimal
score: number; // Bayesian, from those aggregates
last_review_at: number | null;
version: string | null;
}
```
**This is enough to rebuild the `/mints` grid** (name, icon, rating, count, status, sort keys). It is **not** enough for melt-only / frozen chips (`nuts` / capabilities) or for true sentiment bars (`rating_distribution`). Those currently require N+1 `GET /api/mints/:host`.
#### `GET /api/mints/:host`
- 404 `{ error, message }` if unknown
- Response: `MintDetail` = list item plus:
```ts
{
description, pubkey, info /* NUT-06 */, nuts: string[],
first_seen, last_probe, updated_at,
rating_distribution: { '1'..'5': number },
reviews_90d, uptime_30d, probes_recent: { ts, ok, latency_ms }[],
// fedimint extras when type=fedimint: federation_id, invite_codes, modules, …
// lnurl extras when type=lnurl: features, funding_available, withdraw bounds, …
}
```
Used by MintLive and the 404 resolver. **No review bodies.**
#### `GET /api/stats`
In-process memo **60s**. Cashu `mints_*` counts are cashu-only.
```ts
{
mints_total, mints_online, mints_offline, mints_degraded,
reviews_total, last_review_at, updated_at,
cashu_total, // same as mints_total
fedimint_total, fedimint_online, fedimint_offline, fedimint_announced, fedimint_reviews,
lnurl_total, lnurl_online, lnurl_offline, lnurl_degraded_funding, lnurl_reviews
}
```
PulseTicker already consumes this live.
#### `GET /api/health`
`Cache-Control: no-store`. `{ status, uptime_s, last_probe_at, last_discovery_at, mints_tracked, updated_at }`. 503 when degraded.
#### `POST /api/index`
The only write. Body `{ type, input }`. Rate limit **10/hour/IP** (`INDEX_RATE_LIMIT`). Success 200/201: `MintDetail & { existing, indexed_from? }`. Failures: `bad_input`, `blocked_host`, `wrong_type`, `unverifiable`, `rate_limited`, etc.
#### `GET /icons/:file`
Static files, `Cache-Control: public, max-age=86400`.
### 2.2 Gaps
| Need | Exists? | Notes |
|---|---|---|
| Full mint / federation / LNURL lists | **Yes** | `/api/mints?type=…` |
| Per-mint stats (status, uptime, distribution, probes) | **Yes** | `/api/mints/:host` |
| Review **bodies** / recent review text | **No HTTP endpoint** | Live only via Nostr (`reviews-client.ts`, `feed-client.ts`). API stores ratings for aggregates, not content for the UI. |
| List payload with NUT chips / LNURL chips | **No** | List item has no `nuts`, no `rating_distribution`. Today: N+1 at **build**. Client-side N+1 per visitor would be wasteful. |
| Global review feed JSON | **No** | `/reviews` talks to relays, not the API. |
| Snapshot JSON in `dist` | **None** | No checked-in mint dump. Stale data **is** the prerendered HTML. |
Discovery itself is not the list cap (`QUERY_LIMIT` 500 × `MAX_PAGES` 20). The visible cap is **whatever was in the DB when `astro build` ran**.
---
## 3. Option analysis
### 3a. Client-side hydration (keep static build)
Keep `output: 'static'`, `server.mjs`, nginx, and the timer. On `/mints` (and twins), fetch the live API after load and rebuild the grid — the same idea as Reviews / PulseTicker / MintLive.
**What already exists to copy**
- PulseTicker: prerender numbers, then `fetch(`${apiBase}/api/stats`)`.
- `/reviews`: prerender ~30 cards, then `loadFeed()` replaces them.
- MintLive: patch in place from `/api/mints/:host`.
- `/mints` script already sorts/filters on `data-*` (`data-score`, `data-rating`, `data-reviews`, `data-last-review`, `data-status`, …). New cards only need those attributes.
**There are no `client:load` islands to add.** Work is extra `<script>` in the list pages (or a shared module) plus an HTML builder for a card, because `MintCard.astro` cannot run in the browser.
Concrete changes:
| File | Change |
|---|---|
| `web/src/pages/[...locale]/mints.astro` | After paint, `fetch('/api/mints?type=cashu')`, rebuild `[data-mint-grid]`, update `[data-result-count]`. Keep prerendered cards as noscript / first-paint fallback. |
| `web/src/pages/[...locale]/fedimints.astro` | Same, `?type=fedimint`. |
| `web/src/pages/[...locale]/lnurl-mints.astro` | Same, `?type=lnurl`. |
| `web/src/lib/mint-cards.ts` (**new**) | Browser HTML for one card, mirroring `MintCard.astro` (same pattern as `review-cards.ts`). |
| `web/src/lib/api.ts` | Optional: a tiny browser `fetch` helper using `apiBase` from `client.ts` (build-time `API_URL` must **not** ship to the browser). |
| `web/src/pages/[...locale]/index.astro` | Optional but needed for “immediately”: refresh top-6 grids from the same list endpoint; optionally reuse `/reviews`’s `loadFeed` for the carousel (today that strip is build-only). |
| `web/src/components/MintCard.astro` | Untouched if the JS builder is a parallel renderer; or extract shared markup helpers. |
**Chips:** either (i) leave prerendered chips stale, (ii) add `nuts` / chip flags to `MintListItem` + `toListItem()` in `api/src/queries.ts` (small, one-time API change), or (iii) N+1 detail fetches from every browser (do not do this).
**New mint click path:** hydrated list → `/mint/{newhost}` → 404 HTML → NotFound resolver → live page. Already designed. No SSR required for that URL to work.
**Dependencies:** none new (`nostr-tools` already in `web`). No adapter. No nginx / systemd change for the site server.
**Config:** none if `PUBLIC_API_URL` stays empty.
### 3b. Hybrid SSR (`@astrojs/node`, `prerender = false`)
Astro 5 dropped `output: 'hybrid'`. Pattern: install an adapter, keep `output: 'static'`, set `export const prerender = false` on routes that must run per request.
Concrete changes:
| File / unit | Change |
|---|---|
| `web/package.json` | Add `@astrojs/node`. `start` would become something like `node dist/server/entry.mjs` instead of `node server.mjs`. |
| `web/astro.config.mjs` | `import node from '@astrojs/node'`; `adapter: node({ mode: 'standalone' })`. `output` can stay `'static'`. |
| `web/src/pages/[...locale]/mints.astro` (and fedimints / lnurl-mints; maybe `index.astro`) | `export const prerender = false`. Drop `getStaticPaths` **or** keep it only if those routes stay static. SSR pages fetch `fetchMints()` **per request**. |
| `web/server.mjs` | **Cannot stay as the only server.** Node adapter emits `dist/server/entry.mjs` plus `dist/client/` for assets. Locale 404s, ETag/cache policy, path jail, and “refuse empty root” all live in `server.mjs` today and would need reimplementation or a wrapper. |
| `cashumints-site.service` | `ExecStart` → adapter entry; `WEB_ROOT` layout changes (`client/` vs flat dist). |
| `cashumints-web.service` | `rsync` of `web/dist/` is wrong for `client/` + `server/`. Publish + restart model changes. A failed SSR process takes down **HTML**, not only islands. |
| nginx | Still reverse-proxy; upstream may stay `:8789` if the adapter listens there. `proxy_read_timeout` 30s is fine for API-backed SSR. |
SSR **does not** by itself refresh review **bodies** on the list (there are none). It **does** make list HTML match the API on every request, including for crawlers, and can emit real 200s for new `/mint/{host}` if that route is also `prerender = false` (today new hosts are **404** until rebuild).
Per-request cost: `/mints` already does N+1 detail fetches for chips × 24 locales if you SSR all locales naively. Needs a cache or list-payload chips.
### 3c. Caching layers
**In-repo**
| Layer | TTL | Affects |
|---|---|---|
| `getStats()` memo (`api/src/queries.ts`) | 60s | `/api/stats` |
| Review / feed `SimplePool` caches in the tab | 5 min | detail reviews, `/reviews` |
| Icons | 1 day (`max-age=86400`) | `/icons/*` |
| `GET /api/mints`, `/api/stats`, `/api/mints/:host` | **no** `Cache-Control` from Hono (except health `no-store`) | nginx fills the gap |
**nginx** (`README.md` sample; not in this repo)
```nginx
proxy_cache_path … keys_zone=cashumints:10m max_size=256m inactive=10m;
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
}
location /icons/ { proxy_cache …; proxy_cache_valid 200 1d; }
location = /api/health { proxy_cache off; }
```
**HTML** from `server.mjs`: `Cache-Control: public, max-age=0, must-revalidate` + ETag. Browsers revalidate; they do not keep a 24h stale `/mints`.
**Implications**
- Client-side list fetch: **30s** nginx micro-cache is desirable (thundering herd). Not the 24h bug.
- After `POST /api/index`, a new mint can be missing from `GET /api/mints` for up to 30s (404 cache 10s on unknown host). Acceptable; optional `Cache-Control: s-maxage=30` on the API to make it explicit.
- SSR `/mints` would still sit behind that 30s API cache unless the SSR process talks to the API on loopback **bypassing nginx** (`API_URL=http://127.0.0.1:8788`), which the **build** already does. An SSR Node process on the same machine should keep using loopback, not `https://cashumints.space/api`.
- No purge/invalidation API exists. Not needed for 30s TTL.
**Rate limits:** only `POST /api/index`. `GET /api/mints` is unbounded. A list page per visitor is one small JSON payload (~tens of mints), not a risk.
---
## 4. Recommendation
**Do 3a (client-side hydration of the list from `GET /api/mints`).** Least infrastructure change that fixes: full live mint list, live card stats, live review **counts** on the list.
Do **not** move `/mints` to SSR for this goal. That replaces `server.mjs`, the publish path, and systemd, to solve a problem the API + existing island style already solve. Use SSR later only if you need **200 + HTML** for mint URLs that do not exist at build time (SEO for brand-new hosts). Those URLs already **work** for humans via the 404 resolver.
### Why 3a is enough
1. The API already returns the full cashu list with scores and review aggregates.
2. Same-origin `/api` is already proxied; PulseTicker proves browser → API works in production.
3. New mints already resolve on click (404 → `POST /api/index`). Hydrating the list is the missing half.
4. Review **text** on `/mints` was never a list feature; card counts come from the API. Once the list refetches, counts match what MintLive/stats use (discovery-ingested). Bodies stay on the detail page via Nostr, as now.
5. `/reviews` already has live bodies.
### Effort
**Small–medium, ~1–2 days** for `/mints` + fedimints + lnurl-mints + a shared card renderer. Another half day if the home top grids and “latest reviews” strip should match.
Optional extra (half day): add chip fields to `GET /api/mints` so the client does not N+1.
### Files that would be touched (3a)
**Required for the list pages**
- `web/src/pages/[...locale]/mints.astro`
- `web/src/pages/[...locale]/fedimints.astro`
- `web/src/pages/[...locale]/lnurl-mints.astro`
- `web/src/lib/mint-cards.ts` (new; browser card HTML + `fetch` against `apiBase`)
**Likely**
- `web/src/pages/[...locale]/index.astro` (top-6 + “all N mints” count; otherwise home still lies)
- `web/src/components/MintCard.astro` only if extracting shared helpers rather than duplicating markup
**Optional API (chips)**
- `shared/src/types.ts` (`MintListItem`)
- `api/src/queries.ts` (`toListItem`)
**Not required:** `astro.config.mjs`, `web/package.json` (unless you add a test), `web/server.mjs`, nginx, systemd units, `@astrojs/node`.
### Risks
| Risk | Severity | Mitigation |
|---|---|---|
| **SEO:** crawlers see the prerendered subset | Medium if you **replace** HTML with an empty grid. Low if you **keep** build-time cards and enhance. Google does not run the same JS as users. | Keep prerendered cards. Hydration only adds/updates. `ItemList` JSON-LD stays a snapshot; acceptable. |
| **New mint URLs return HTTP 404** until rebuild | Low for UX (resolver fills in). Medium for SEO of a mint indexed today. | Out of scope for list freshness. SSR on `mint/[host].astro` is the fix if needed. |
| **Nostr relay latency** | Does not apply to the **list** if you use the API. Applies to `/reviews` and detail panels already (8–9s timeout, bones, retry). | Do not query relays on `/mints`. |
| **API rate limits** | None on GET. | — |
| **CORS** | None while `PUBLIC_API_URL` is empty (same origin). Hono already sends CORS if the API is split later. | Do not bake `API_URL` (`127.0.0.1`) into browser code. |
| **nginx 30s cache** | Cards can lag indexing by 30s | Fine. Loopback SSR would skip it; client fetch will not. |
| **Card markup drift** | JS builder vs `MintCard.astro` | One HTML helper, or accept a short duplication like `review-cards.ts` vs the Astro card. |
| **View transitions** | Cards use `data-vt-*` names applied on click | Preserve those attributes in the builder. |
| **i18n** | Card strings must use `useI18n()` in the island, not build-time `t` | Same as Reviews / PulseTicker. |
| **Home “latest reviews”** | Still nightly unless you reuse `loadFeed` | Separate follow-up; `/reviews` is already live. |
### Suggested sequence (when implementing)
1. Hydrate `/mints` from `GET /api/mints?type=cashu`; keep prerendered fallback.
2. Repeat for `/fedimints` and `/lnurl-mints`.
3. Hydrate the home top grids (and the “all N” link) from the same endpoints.
4. Only if chips go stale in a way users notice: extend the list payload.
5. Revisit SSR only for mint-detail **status codes** / OG / sitemap freshness — not for the list.
+3 -2
View File
@@ -4,7 +4,7 @@
"version": "2.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.18" "node": ">=20.18"
}, },
"scripts": { "scripts": {
"dev": "pnpm --parallel --filter ./api --filter ./web dev", "dev": "pnpm --parallel --filter ./api --filter ./web dev",
@@ -12,7 +12,8 @@
"dev:web": "pnpm --filter ./web dev", "dev:web": "pnpm --filter ./web dev",
"seed": "pnpm --filter ./api seed", "seed": "pnpm --filter ./api seed",
"bones": "pnpm --filter ./web bones", "bones": "pnpm --filter ./web bones",
"build": "pnpm --filter ./shared build && pnpm --filter ./web build", "build": "pnpm --filter ./shared build && pnpm --filter ./api build && pnpm --filter ./web build",
"start": "pnpm --filter ./web start",
"check:links": "pnpm --filter ./web check:links", "check:links": "pnpm --filter ./web check:links",
"typecheck": "pnpm -r typecheck", "typecheck": "pnpm -r typecheck",
"check:i18n": "pnpm --filter ./web check:i18n", "check:i18n": "pnpm --filter ./web check:i18n",
+79
View File
@@ -1,5 +1,7 @@
/** Shapes returned by the API. The web app builds against these. */ /** Shapes returned by the API. The web app builds against these. */
import type { MintCapabilities } from './warnings.js';
/** /**
* Statuses a listed thing can be in. * Statuses a listed thing can be in.
* *
@@ -35,6 +37,40 @@ export interface MintListItem {
score: number; score: number;
last_review_at: number | null; last_review_at: number | null;
version: string | 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 { export interface ProbeSample {
@@ -236,6 +272,29 @@ export interface Stats {
lnurl_reviews: number; 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`. */ /** `GET /api/health`. */
export interface Health { export interface Health {
status: 'ok' | 'degraded'; status: 'ok' | 'degraded';
@@ -244,6 +303,26 @@ export interface Health {
last_discovery_at: number | null; last_discovery_at: number | null;
mints_tracked: number; mints_tracked: number;
updated_at: 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;
} }
/** /**
+1
View File
@@ -8,6 +8,7 @@
"og": "node scripts/og/build-og.mjs", "og": "node scripts/og/build-og.mjs",
"og:fixtures": "node scripts/og/build-og.mjs --fixtures", "og:fixtures": "node scripts/og/build-og.mjs --fixtures",
"build": "node scripts/og/build-og.mjs && astro build", "build": "node scripts/og/build-og.mjs && astro build",
"start": "node server.mjs",
"preview": "astro preview", "preview": "astro preview",
"typecheck": "astro check", "typecheck": "astro check",
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs", "bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
+15 -1
View File
@@ -268,6 +268,20 @@ for (const file of files) {
// A .ts file under lib/ or scripts/ is island code wholesale. // A .ts file under lib/ or scripts/ is island code wholesale.
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts'); const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
/*
* A page can inline one extra namespace for its own islands, through Base.astro's
* `clientNamespaces` prop. The home page does: its grids hydrate from the API and
* rewrite their own "All 56 mints" links, whose strings live under `home.` — a
* namespace not worth inlining on 1,300 mint pages that never read it.
*
* Read out of the page rather than listed here, so the prop and this check cannot
* disagree. A namespace a page does not actually pass is still a leak.
*/
const extraNamespaces = new Set(
[...(/clientNamespaces=\{\[([^\]]*)\]\}/.exec(source)?.[1] ?? '').matchAll(/'([\w-]+)'/g)]
.map((m) => m[1]),
);
for (const match of source.matchAll(T_CALL)) { for (const match of source.matchAll(T_CALL)) {
const key = match[2]; const key = match[2];
used.add(key); used.add(key);
@@ -280,7 +294,7 @@ for (const file of files) {
for (const match of clientSource.matchAll(T_CALL)) { for (const match of clientSource.matchAll(T_CALL)) {
const key = match[2]; const key = match[2];
const namespace = key.split('.')[0]; const namespace = key.split('.')[0];
if (!clientNamespaces.has(namespace)) { if (!clientNamespaces.has(namespace) && !extraNamespaces.has(namespace)) {
clientLeaks.push({ key, file: relative, namespace }); clientLeaks.push({ key, file: relative, namespace });
} }
} }
+386
View File
@@ -0,0 +1,386 @@
/**
* The production web server.
*
* The site is `output: 'static'`: `pnpm build` prerenders every page and this process
* only hands the result out over HTTP. nginx sits in front of it and proxies, rather
* than pointing a `root` at the built tree, so that nothing outside this file decides
* what is readable. That is not a stylistic preference — it removes two whole classes
* of failure the file-serving arrangement kept producing:
*
* traversal permissions nginx runs as www-data and the build runs as cashumints,
* so every directory from / down to dist had to be traversable
* by a user with no other business in it. One 0700 home
* directory anywhere in the chain took the site down, and
* `try_files` reports a permission error as a plain miss, so
* the symptom was a blanket 404 or an internal-redirect loop
* ending in 500 — never the actual cause.
*
* duplicated routing the locale 404 rule lived in nginx as a hand-maintained
* alternation of 23 codes. Adding a language meant editing a
* file that is not in this repository, and forgetting to was
* silent. Locale 404s are resolved by looking in the built
* tree now, so the list cannot drift.
*
* Reading files is all it does. There is no template, no database handle and no route
* table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards
* them there directly.
*
* Env:
* SITE_PORT 8789 port to listen on
* SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS
* WEB_ROOT ./dist the tree to serve
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function int(raw, fallback) {
const n = Number.parseInt(raw ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/**
* The tree to serve, absolute.
*
* Defaults to the build output next to this file, which is what `pnpm start` and the
* tests use. In production it points at a copy outside the checkout: `pnpm build`
* empties dist before it writes, so serving dist directly means a rebuild takes the
* whole site down for the length of the build. See cashumints-web.service.
*/
export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist'));
const PORT = int(process.env.SITE_PORT, 8789);
const HOST = process.env.SITE_HOST ?? '127.0.0.1';
/** One line per event, key=value after the message. Matches the API's log format. */
function log(level, msg, fields = {}) {
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
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 console.log(line);
}
const TYPES = new Map(Object.entries({
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.wasm': 'application/wasm',
}));
const IMMUTABLE = 'public, max-age=31536000, immutable';
const WEEK = 'public, max-age=604800';
const HOUR = 'public, max-age=3600';
/** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */
const REVALIDATE = 'public, max-age=0, must-revalidate';
/**
* How long a response may be reused.
*
* Everything Vite and the card builder emit carries a content hash in its filename, so
* those are immutable for a year — a changed file is a changed URL. Markup is the
* opposite: the URLs are permanent and the bytes change on every rebuild, so it
* revalidates every time and the ETag below turns that into a 304 in the usual case.
*
* `/og/default.png` is the one unhashed card, served for pages that have no mint of
* their own, so it gets an hour rather than a year.
*/
export function cacheControl(urlPath, ext) {
if (ext === '.html') return REVALIDATE;
if (urlPath === '/og/default.png') return HOUR;
if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE;
if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR;
return WEEK;
}
/**
* Turn a request path into a path inside ROOT, or null if it escapes or is malformed.
*
* Returns the *relative* path so the caller can join it against ROOT; every segment is
* checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are
* refused outright: a static build emits none, so a request for one is either a probe
* or a mistake, and neither should be answered with bytes.
*/
export function safePath(urlPath) {
if (urlPath.includes('\0')) return null;
const normalized = path.posix.normalize(urlPath);
if (!normalized.startsWith('/')) return null;
const segments = normalized.split('/').filter(Boolean);
for (const segment of segments) {
if (segment === '..' || segment.startsWith('.')) return null;
}
return segments;
}
async function statFile(file) {
try {
const stats = await fsp.stat(file);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
/**
* The candidates for one request path, in order.
*
* Astro emits directory-style routes — /mints is dist/mints/index.html — and
* `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the
* literal path first keeps assets a single stat; the `.html` candidate covers the flat
* files at the root, /404.html among them.
*/
export function candidates(segments) {
const rel = segments.join('/');
if (rel === '') return ['index.html'];
return [rel, `${rel}/index.html`, `${rel}.html`];
}
/**
* The 404 body for a path, and it is not always the English one.
*
* A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into
* English, which is the same reason `redirectToDefaultLocale` is false in the Astro
* config. Which prefixes count is decided by what the build actually emitted: if
* <prefix>/404/index.html exists, the prefix is a locale. Nothing to keep in sync.
*/
async function notFoundBody(segments) {
const first = segments[0];
if (first) {
const localized = path.join(ROOT, first, '404', 'index.html');
const stats = await statFile(localized);
if (stats) return { file: localized, stats };
}
const fallback = path.join(ROOT, '404.html');
const stats = await statFile(fallback);
return stats ? { file: fallback, stats } : null;
}
/** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */
function etagFor(stats) {
return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`;
}
/** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */
function etagMatches(header, etag) {
if (!header) return false;
if (header.trim() === '*') return true;
const bare = etag.replace(/^W\//, '');
return header
.split(',')
.map((candidate) => candidate.trim().replace(/^W\//, ''))
.includes(bare);
}
function notModified(req, etag, lastModified) {
if (etagMatches(req.headers['if-none-match'], etag)) return true;
// Only consulted when the client sent no ETag, per RFC 9110 §13.1.3.
if (req.headers['if-none-match']) return false;
const since = Date.parse(req.headers['if-modified-since'] ?? '');
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
}
/**
* Headers every response carries.
*
* No CSP here on purpose. The site loads an analytics script from another origin, the
* review islands open websockets to whatever relays are configured and the status
* islands fetch whatever mint URLs the index holds, so a policy tight enough to be
* worth having has to be derived from those lists rather than guessed at — and a wrong
* one fails as a silently broken island. HSTS belongs to nginx, which is what actually
* terminates TLS.
*/
function baseHeaders() {
return {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
};
}
function send(res, status, headers, body) {
res.writeHead(status, { ...baseHeaders(), ...headers });
res.end(body);
}
/** Stream a file, or just its headers for HEAD. */
function sendFile(req, res, status, file, stats, urlPath) {
const ext = path.extname(file).toLowerCase();
const etag = etagFor(stats);
const headers = {
'Content-Type': TYPES.get(ext) ?? 'application/octet-stream',
'Content-Length': stats.size,
'Last-Modified': new Date(stats.mtimeMs).toUTCString(),
ETag: etag,
'Cache-Control': cacheControl(urlPath, ext),
...baseHeaders(),
};
// A 304 must not carry a body or a Content-Length describing one.
if (status === 200 && notModified(req, etag, stats.mtimeMs)) {
delete headers['Content-Length'];
delete headers['Content-Type'];
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(status, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = fs.createReadStream(file);
stream.on('error', (err) => {
log('error', 'read failed', { file, err: err.message });
res.destroy();
});
// Kill the read when the client hangs up mid-transfer rather than draining the file.
res.on('close', () => stream.destroy());
stream.pipe(res);
}
async function handle(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n');
return;
}
let urlPath;
try {
urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
const segments = safePath(urlPath);
if (!segments) {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
for (const candidate of candidates(segments)) {
const file = path.join(ROOT, candidate);
// Belt and braces: safePath already refused `..`, this refuses anything that still
// resolved outside the tree, a symlink in the build output included.
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break;
const stats = await statFile(file);
if (stats) {
sendFile(req, res, 200, file, stats, urlPath);
return;
}
}
const miss = await notFoundBody(segments);
if (!miss) {
send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n');
return;
}
// Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd
// URL indexed as a real page.
sendFile(req, res, 404, miss.file, miss.stats, urlPath);
}
export function createServer() {
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
log('error', 'request failed', { path: req.url, err: err.message });
if (!res.headersSent) {
send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n');
} else {
res.destroy();
}
});
});
/*
* Longer than nginx's upstream keepalive, and headersTimeout longer still.
*
* If this end closes an idle connection at the same moment nginx reuses it, nginx has
* nothing to retry and reports 502. Outlasting the proxy makes the proxy always the
* one to close, which is the race-free direction.
*/
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
// A malformed request line should not take the process with it.
server.on('clientError', (err, socket) => {
if (err.code === 'ECONNRESET' || !socket.writable) return;
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
return server;
}
/**
* Refuse to start on an empty or unreadable root.
*
* The failure this prevents is the one that is hardest to see: a process that starts
* cleanly, answers every request with a 404 and looks healthy to anything watching the
* port. Exiting non-zero puts the reason in `systemctl status` instead.
*/
async function checkRoot() {
const index = path.join(ROOT, 'index.html');
if (await statFile(index)) return;
log('error', 'web root has no index.html', {
root: ROOT,
hint: 'a manual `pnpm build` only writes web/dist; `systemctl start cashumints-web` builds and publishes to WEB_ROOT',
});
process.exit(1);
}
/** Only when run directly, so the tests can import the pieces above. */
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await checkRoot();
const server = createServer();
server.listen(PORT, HOST, () => {
log('info', 'site listening', { host: HOST, port: PORT, root: ROOT });
});
let shuttingDown = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (shuttingDown) return;
shuttingDown = true;
log('info', 'shutting down', { signal });
server.close(() => process.exit(0));
// Idle keep-alive connections would otherwise hold the close open for a minute.
server.closeIdleConnections();
setTimeout(() => {
server.closeAllConnections();
process.exit(0);
}, 10_000).unref();
});
}
}
+24 -14
View File
@@ -394,14 +394,28 @@ function escapeText(value: string): string {
let pendingPaint = 0; let pendingPaint = 0;
/** Set for one paint after publishing, to mark the new card as it goes in. */ /** Set for one paint after publishing, to mark the new card as it goes in. */
let markNewest = false; let markNewest = false;
/** /** Reviews published in this session, shown at the top until relays echo them back. */
* Reviews published in this session, shown at the top until relays echo them back.
*
* Keyed by event id, holding how many relays took it: a review that only two of
* four relays accepted says so on its card, because that is a thing the author
* may want to act on.
*/
const optimistic = new Map<string, { accepted: number; total: number }>(); const optimistic = new Map<string, { accepted: number; total: number }>();
/** Optimistic cards whose brief publishing note is still visible. */
const publishingNotes = new Set<string>();
const PUBLISHING_NOTE_MS = 4_000;
const PUBLISHING_NOTE_FADE_MS = 220;
function dismissPublishingNote(id: string): void {
publishingNotes.delete(id);
const note = document.querySelector<HTMLElement>(
`[id="review-${id.toLowerCase()}"] .propagating`,
);
if (!note) return;
note.classList.add('is-out');
window.setTimeout(() => note.remove(), PUBLISHING_NOTE_FADE_MS);
}
function markOptimistic(id: string, accepted: number, total: number): void {
optimistic.set(id, { accepted, total });
publishingNotes.add(id);
window.setTimeout(() => dismissPublishingNote(id), PUBLISHING_NOTE_MS);
}
/* ---------- rendering ---------- */ /* ---------- rendering ---------- */
@@ -476,8 +490,7 @@ function escapeText(value: string): string {
target.innerHTML = slice target.innerHTML = slice
.map((review) => .map((review) =>
reviewHtml(review, f, { reviewHtml(review, f, {
propagating: optimistic.has(review.id), propagating: publishingNotes.has(review.id),
publishedTo: optimistic.get(review.id) ?? null,
permalink, permalink,
}), }),
) )
@@ -667,10 +680,7 @@ function escapeText(value: string): string {
* that they would have seen had they written it on this page. * that they would have seen had they written it on this page.
*/ */
if (handedForward && !all.some((review) => review.id === handedForward.id)) { if (handedForward && !all.some((review) => review.id === handedForward.id)) {
optimistic.set(handedForward.id, { markOptimistic(handedForward.id, handedForward.accepted, handedForward.total);
accepted: handedForward.accepted,
total: handedForward.total,
});
all.unshift({ all.unshift({
id: handedForward.id, id: handedForward.id,
pubkey: handedForward.pubkey, pubkey: handedForward.pubkey,
@@ -810,7 +820,7 @@ function escapeText(value: string): string {
}, },
onPublished: (published) => { onPublished: (published) => {
// Optimistic insert at the top. The relays will echo it back on next load. // Optimistic insert at the top. The relays will echo it back on next load.
optimistic.set(published.id, { accepted: published.accepted, total: published.total }); markOptimistic(published.id, published.accepted, published.total);
all.unshift({ all.unshift({
id: published.id, id: published.id,
pubkey: published.pubkey, pubkey: published.pubkey,
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "دي أول مراجعة من الـnpub ده، ومفيش نشاط تاني ليه", "reviews.anonNote": "دي أول مراجعة من الـnpub ده، ومفيش نشاط تاني ليه",
"reviews.published": "نُشرت", "reviews.published": "نُشرت",
"reviews.publishedPartial": "نُشرت إلى {accepted} من {total} إعادة شحن.", "reviews.publishedPartial": "نُشرت إلى {accepted} من {total} إعادة شحن.",
"reviews.propagating": "البروغات، قد يستغرق الأمر لحظة للظهور في مكان آخر.", "reviews.propagating": "بننشر على موزّعات Nostr",
"reviews.summary.line.one": "{count} تصنيف دون تعليق: {breakdown}", "reviews.summary.line.one": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تصنيف دون تعليق: {breakdown}", "reviews.summary.line.other": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "{count} تصنيف دون تعليق على {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} تصنيف دون تعليق على {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "أول تقييم من هذا npub، لم يتم العثور على أي نشاط آخر", "reviews.anonNote": "أول تقييم من هذا npub، لم يتم العثور على أي نشاط آخر",
"reviews.published": "تم النشر.", "reviews.published": "تم النشر.",
"reviews.publishedPartial": "تم النشر إلى {accepted} من أصل {total} مرحل.", "reviews.publishedPartial": "تم النشر إلى {accepted} من أصل {total} مرحل.",
"reviews.propagating": "أثناء النشر، قد يستغرق الأمر لحظة حتى يظهر في مكان آخر.", "reviews.propagating": "جارٍ النشر إلى مرحلات Nostr",
"reviews.summary.line.one": "{count} تقييم بدون تعليق: {breakdown}", "reviews.summary.line.one": "{count} تقييم بدون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تقييمًا بدون تعليق: {breakdown}", "reviews.summary.line.other": "{count} تقييمًا بدون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "تقييم {count} بدون تعليق على {when}: {breakdown}", "reviews.summary.lineDay.one": "تقييم {count} بدون تعليق على {when}: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "První přezkum z tohoto npub, žádná jiná činnost nenalezena", "reviews.anonNote": "První přezkum z tohoto npub, žádná jiná činnost nenalezena",
"reviews.published": "Publikováno.", "reviews.published": "Publikováno.",
"reviews.publishedPartial": "Zveřejněno{accepted}z{total}relé.", "reviews.publishedPartial": "Zveřejněno{accepted}z{total}relé.",
"reviews.propagating": "Propagatování může chvíli trvat, než se objeví jinde.", "reviews.propagating": "Publikování na Nostr relayích",
"reviews.summary.line.one": "{count}hodnocení bez komentáře:{breakdown}", "reviews.summary.line.one": "{count}hodnocení bez komentáře:{breakdown}",
"reviews.summary.line.other": "{count}ratingy bez komentáře:{breakdown}", "reviews.summary.line.other": "{count}ratingy bez komentáře:{breakdown}",
"reviews.summary.line.few": "{count}ratingy bez komentáře:{breakdown}", "reviews.summary.line.few": "{count}ratingy bez komentáře:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Første anmeldelse af denne npub; der er ikke fundet andre aktiviteter", "reviews.anonNote": "Første anmeldelse af denne npub; der er ikke fundet andre aktiviteter",
"reviews.published": "Udgivet.", "reviews.published": "Udgivet.",
"reviews.publishedPartial": "Udgivet til {accepted} blandt {total}-relæerne.", "reviews.publishedPartial": "Udgivet til {accepted} blandt {total}-relæerne.",
"reviews.propagating": "Når indholdet opdateres, kan det tage et øjeblik, før det vises andre steder.", "reviews.propagating": "Udgiver til Nostr-relays",
"reviews.summary.line.one": "{count}-vurdering uden kommentar: {breakdown}", "reviews.summary.line.one": "{count}-vurdering uden kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-vurderinger uden kommentar: {breakdown}", "reviews.summary.line.other": "{count}-vurderinger uden kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-vurdering uden kommentar til {when}: {breakdown}", "reviews.summary.lineDay.one": "{count}-vurdering uden kommentar til {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Erste Rezension von diesem Verlag, keine weiteren Aktivitäten gefunden", "reviews.anonNote": "Erste Rezension von diesem Verlag, keine weiteren Aktivitäten gefunden",
"reviews.published": "Veröffentlicht.", "reviews.published": "Veröffentlicht.",
"reviews.publishedPartial": "Veröffentlicht auf {accepted} der {total}-Relais.", "reviews.publishedPartial": "Veröffentlicht auf {accepted} der {total}-Relais.",
"reviews.propagating": "Die Übertragung kann einen Moment dauern, bis sie an anderer Stelle angezeigt wird.", "reviews.propagating": "Veröffentlichen auf Nostr-Relays",
"reviews.summary.line.one": "{count}-Bewertung ohne Kommentar: {breakdown}", "reviews.summary.line.one": "{count}-Bewertung ohne Kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-Bewertungen ohne Kommentar: {breakdown}", "reviews.summary.line.other": "{count}-Bewertungen ohne Kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-Bewertung ohne Kommentar auf {when}: {breakdown}", "reviews.summary.lineDay.one": "{count}-Bewertung ohne Kommentar auf {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Πρώτη κριτική από αυτό το npub, δεν βρέθηκε άλλη δραστηριότητα", "reviews.anonNote": "Πρώτη κριτική από αυτό το npub, δεν βρέθηκε άλλη δραστηριότητα",
"reviews.published": "Δημοσίευση.", "reviews.published": "Δημοσίευση.",
"reviews.publishedPartial": "Δημοσίευση στο{accepted} του{total} ⁇ λεμάν.", "reviews.publishedPartial": "Δημοσίευση στο{accepted} του{total} ⁇ λεμάν.",
"reviews.propagating": "Διαδοχικά, μπορεί να πάρει μια στιγμή για να εμφανιστεί αλλού.", "reviews.propagating": "Δημοσίευση στα relays του Nostr",
"reviews.summary.line.one": "{count} αξιολόγηση χωρίς σχόλιο:{breakdown}", "reviews.summary.line.one": "{count} αξιολόγηση χωρίς σχόλιο:{breakdown}",
"reviews.summary.line.other": "{count} αξιολογήσεις χωρίς σχόλιο:{breakdown}", "reviews.summary.line.other": "{count} αξιολογήσεις χωρίς σχόλιο:{breakdown}",
"reviews.summary.lineDay.one": "{count} αξιολόγηση χωρίς σχόλιο σχετικά με{when}:{breakdown}", "reviews.summary.lineDay.one": "{count} αξιολόγηση χωρίς σχόλιο σχετικά με{when}:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "First review from this npub, no other activity found", "reviews.anonNote": "First review from this npub, no other activity found",
"reviews.published": "Published.", "reviews.published": "Published.",
"reviews.publishedPartial": "Published to {accepted} of {total} relays.", "reviews.publishedPartial": "Published to {accepted} of {total} relays.",
"reviews.propagating": "Propagating, it may take a moment to appear elsewhere.", "reviews.propagating": "Publishing to Nostr relays",
"reviews.summary.line.one": "{count} rating without a comment: {breakdown}", "reviews.summary.line.one": "{count} rating without a comment: {breakdown}",
"reviews.summary.line.other": "{count} ratings without a comment: {breakdown}", "reviews.summary.line.other": "{count} ratings without a comment: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}",
+1 -1
View File
@@ -286,7 +286,7 @@
"reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad", "reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad",
"reviews.published": "Publicada.", "reviews.published": "Publicada.",
"reviews.publishedPartial": "Publicada en {accepted} de {total} relays.", "reviews.publishedPartial": "Publicada en {accepted} de {total} relays.",
"reviews.propagating": "Propagándose, puede tardar un momento en aparecer en otros sitios.", "reviews.propagating": "Publicando en relés Nostr",
"reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}", "reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}",
"reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}", "reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}",
"reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Premier avis sur ce npub, aucune autre activité trouvée", "reviews.anonNote": "Premier avis sur ce npub, aucune autre activité trouvée",
"reviews.published": "Publié.", "reviews.published": "Publié.",
"reviews.publishedPartial": "Publié sur {accepted} des relais {total}.", "reviews.publishedPartial": "Publié sur {accepted} des relais {total}.",
"reviews.propagating": "En se propageant, cela peut prendre un moment pour apparaître ailleurs.", "reviews.propagating": "Publication sur les relais Nostr",
"reviews.summary.line.one": "Note {count} sans commentaire : {breakdown}", "reviews.summary.line.one": "Note {count} sans commentaire : {breakdown}",
"reviews.summary.line.other": "Notes {count} sans commentaire : {breakdown}", "reviews.summary.line.other": "Notes {count} sans commentaire : {breakdown}",
"reviews.summary.lineDay.one": "Note {count} sans commentaire sur {when} : {breakdown}", "reviews.summary.lineDay.one": "Note {count} sans commentaire sur {when} : {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "इस npub से पहली समीक्षा, कोई अन्य गतिविधि नहीं मिली।", "reviews.anonNote": "इस npub से पहली समीक्षा, कोई अन्य गतिविधि नहीं मिली।",
"reviews.published": "प्रकाशित", "reviews.published": "प्रकाशित",
"reviews.publishedPartial": "{total} रिलेज़ में से {accepted} पर प्रकाशित।", "reviews.publishedPartial": "{total} रिलेज़ में से {accepted} पर प्रकाशित।",
"reviews.propagating": "प्रसारित करते समय, कहीं और प्रकट होने में थोड़ा समय लग सकता है।", "reviews.propagating": "Nostr रिले पर प्रकाशित किया जा रहा है",
"reviews.summary.line.one": "बिना टिप्पणी के {count} रेटिंग: {breakdown}", "reviews.summary.line.one": "बिना टिप्पणी के {count} रेटिंग: {breakdown}",
"reviews.summary.line.other": "टिप्पणी के बिना रेटिंग: {count}; रेटिंग: {breakdown}", "reviews.summary.line.other": "टिप्पणी के बिना रेटिंग: {count}; रेटिंग: {breakdown}",
"reviews.summary.lineDay.one": "{count} रेटिंग, {when} पर बिना टिप्पणी के: {breakdown}", "reviews.summary.lineDay.one": "{count} रेटिंग, {when} पर बिना टिप्पणी के: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Ulasan pertama dari npub ini, tidak ada aktivitas lain ditemukan", "reviews.anonNote": "Ulasan pertama dari npub ini, tidak ada aktivitas lain ditemukan",
"reviews.published": "Diterbitkan.", "reviews.published": "Diterbitkan.",
"reviews.publishedPartial": "Diterbitkan ke {accepted} dari {total} relay.", "reviews.publishedPartial": "Diterbitkan ke {accepted} dari {total} relay.",
"reviews.propagating": "Sedang disebarkan; mungkin perlu waktu untuk muncul di tempat lain.", "reviews.propagating": "Mempublikasikan ke relay Nostr",
"reviews.summary.line.one": "Rating {count} tanpa komentar: {breakdown}", "reviews.summary.line.one": "Rating {count} tanpa komentar: {breakdown}",
"reviews.summary.line.other": "Rating {count} tanpa komentar: {breakdown}", "reviews.summary.line.other": "Rating {count} tanpa komentar: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating tanpa komentar di {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} rating tanpa komentar di {when}: {breakdown}",
+11 -2
View File
@@ -121,13 +121,22 @@ export function missingKeys(): Record<string, string[]> {
* key that survives is resolved: a key this locale is missing arrives already filled * key that survives is resolved: a key this locale is missing arrives already filled
* with the English string, so the browser needs no fallback catalog and ships exactly * with the English string, so the browser needs no fallback catalog and ships exactly
* one language. * one language.
*
* `extra` is for a namespace exactly one page's islands need. The home page's grids
* hydrate and have to rewrite their own "All 56 mints →" links, which live under
* `home.` — a namespace worth about 2KB that every other page, including 1,300 mint
* pages, has no use for. Passed per page through `Base.astro`'s `clientNamespaces`
* prop, it is inlined where it is read and nowhere else. `check-i18n.mjs` does not know
* about this, so a key reached this way must still be in a namespace the checker
* accepts, or listed in `CLIENT_NAMESPACES` — see the note there.
*/ */
export function clientCatalog(locale: Locale): Catalog { export function clientCatalog(locale: Locale, extra: readonly string[] = []): Catalog {
const catalog = catalogFor(locale); const catalog = catalogFor(locale);
const allowed = new Set<string>([...CLIENT_NAMESPACES, ...extra]);
const out: Catalog = {}; const out: Catalog = {};
for (const key of Object.keys(BASE_CATALOG)) { for (const key of Object.keys(BASE_CATALOG)) {
const namespace = key.split('.')[0] ?? ''; const namespace = key.split('.')[0] ?? '';
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue; if (!allowed.has(namespace)) continue;
out[key] = catalog[key] ?? BASE_CATALOG[key]!; out[key] = catalog[key] ?? BASE_CATALOG[key]!;
} }
return out; return out;
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Prima recensione da questo npub, nessun'altra attività trovata", "reviews.anonNote": "Prima recensione da questo npub, nessun'altra attività trovata",
"reviews.published": "Pubblicato.", "reviews.published": "Pubblicato.",
"reviews.publishedPartial": "Pubblicato in {accepted} di {total} relè.", "reviews.publishedPartial": "Pubblicato in {accepted} di {total} relè.",
"reviews.propagating": "Propagando, può richiedere un momento per apparire altrove.", "reviews.propagating": "Pubblicazione sui relay Nostr",
"reviews.summary.line.one": "{count} valutazione senza un commento: {breakdown}", "reviews.summary.line.one": "{count} valutazione senza un commento: {breakdown}",
"reviews.summary.line.other": "{count} valutazioni senza un commento: {breakdown}", "reviews.summary.line.other": "{count} valutazioni senza un commento: {breakdown}",
"reviews.summary.lineDay.one": "{count} valutazione senza un commento su {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} valutazione senza un commento su {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "このnpubからの最初のレビュー、他の活動が見つかりません", "reviews.anonNote": "このnpubからの最初のレビュー、他の活動が見つかりません",
"reviews.published": "掲載情報", "reviews.published": "掲載情報",
"reviews.publishedPartial": "に公開 {accepted} の {total} リレー.", "reviews.publishedPartial": "に公開 {accepted} の {total} リレー.",
"reviews.propagating": "伝播は、他の場所で出現する瞬間を取るかもしれません。", "reviews.propagating": "Nostrリレーに公開中",
"reviews.summary.line.one": "{count} コメントのない評価: {breakdown}", "reviews.summary.line.one": "{count} コメントのない評価: {breakdown}",
"reviews.summary.line.other": "{count} コメントのない評価: {breakdown}", "reviews.summary.line.other": "{count} コメントのない評価: {breakdown}",
"reviews.summary.lineDay.one": "{count} 評価なし にコメント {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} 評価なし にコメント {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden", "reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden",
"reviews.published": "Gepubliceerd.", "reviews.published": "Gepubliceerd.",
"reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.", "reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.",
"reviews.propagating": "Wordt verspreid, het kan even duren voordat hij elders verschijnt.", "reviews.propagating": "Publiceren naar Nostr-relays",
"reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}", "reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}",
"reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}", "reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}",
"reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Pierwsza recenzja z tego npubu, nie znaleziono żadnej innej aktywności", "reviews.anonNote": "Pierwsza recenzja z tego npubu, nie znaleziono żadnej innej aktywności",
"reviews.published": "Opublikowano.", "reviews.published": "Opublikowano.",
"reviews.publishedPartial": "Opublikowano dla {accepted} sztafetyi {total}.", "reviews.publishedPartial": "Opublikowano dla {accepted} sztafetyi {total}.",
"reviews.propagating": "Rozmnażając, może potrzebować chwili, by pojawić się gdzie indziej.", "reviews.propagating": "Publikowanie na relayach Nostr",
"reviews.summary.line.one": "{count} ocena bez komentarza: {breakdown}", "reviews.summary.line.one": "{count} ocena bez komentarza: {breakdown}",
"reviews.summary.line.few": "{count} oceny bez komentarza: {breakdown}", "reviews.summary.line.few": "{count} oceny bez komentarza: {breakdown}",
"reviews.summary.line.many": "{count} ocen bez komentarza: {breakdown}", "reviews.summary.line.many": "{count} ocen bez komentarza: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Primeira revisão deste npub, nenhuma outra atividade encontrada", "reviews.anonNote": "Primeira revisão deste npub, nenhuma outra atividade encontrada",
"reviews.published": "Publicado.", "reviews.published": "Publicado.",
"reviews.publishedPartial": "Publicado em {accepted} dos relés {total}.", "reviews.publishedPartial": "Publicado em {accepted} dos relés {total}.",
"reviews.propagating": "Propagando, pode demorar um pouco para aparecer em outro lugar.", "reviews.propagating": "Publicando nos relays Nostr",
"reviews.summary.line.one": "Classificação {count} sem comentários: {breakdown}", "reviews.summary.line.one": "Classificação {count} sem comentários: {breakdown}",
"reviews.summary.line.other": "Avaliações {count} sem comentários: {breakdown}", "reviews.summary.line.other": "Avaliações {count} sem comentários: {breakdown}",
"reviews.summary.lineDay.one": "Classificação {count} sem comentários em {when}: {breakdown}", "reviews.summary.lineDay.one": "Classificação {count} sem comentários em {when}: {breakdown}",
+1 -1
View File
@@ -290,7 +290,7 @@
"reviews.anonNote": "Prima revizuire a acestei npub-uri, nicio altă activitate nu a fost găsită", "reviews.anonNote": "Prima revizuire a acestei npub-uri, nicio altă activitate nu a fost găsită",
"reviews.published": "Publicat.", "reviews.published": "Publicat.",
"reviews.publishedPartial": "Publicată în{accepted} din{total} relee.", "reviews.publishedPartial": "Publicată în{accepted} din{total} relee.",
"reviews.propagating": "Propaganda, poate dura un moment să apară în altă parte.", "reviews.propagating": "Se publică pe relayurile Nostr",
"reviews.summary.line.one": "{count}rating fără comentarii:{breakdown}", "reviews.summary.line.one": "{count}rating fără comentarii:{breakdown}",
"reviews.summary.line.other": "{count} ratinguri fără comentarii:{breakdown}", "reviews.summary.line.other": "{count} ratinguri fără comentarii:{breakdown}",
"reviews.summary.line.few": "{count} ratinguri fără comentarii:{breakdown}", "reviews.summary.line.few": "{count} ratinguri fără comentarii:{breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Первый отзыв с этого npub, другой активности не найдено", "reviews.anonNote": "Первый отзыв с этого npub, другой активности не найдено",
"reviews.published": "Опубликовано.", "reviews.published": "Опубликовано.",
"reviews.publishedPartial": "Опубликовано на {accepted} из {total} relays.", "reviews.publishedPartial": "Опубликовано на {accepted} из {total} relays.",
"reviews.propagating": "Распространяется по сети, в других местах отзыв может появиться не сразу.", "reviews.propagating": "Публикация на relays Nostr",
"reviews.summary.line.one": "Рейтинг «{count}» без комментариев: {breakdown}", "reviews.summary.line.one": "Рейтинг «{count}» без комментариев: {breakdown}",
"reviews.summary.line.other": "Рейтинги «{count}» без комментариев: {breakdown}", "reviews.summary.line.other": "Рейтинги «{count}» без комментариев: {breakdown}",
"reviews.summary.line.few": "Рейтинги «{count}» без комментариев: {breakdown}", "reviews.summary.line.few": "Рейтинги «{count}» без комментариев: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Första recensionen av denna npub, inga andra aktiviteter har hittats", "reviews.anonNote": "Första recensionen av denna npub, inga andra aktiviteter har hittats",
"reviews.published": "Publicerad.", "reviews.published": "Publicerad.",
"reviews.publishedPartial": "Publicerad till reläet {accepted} i serien {total}.", "reviews.publishedPartial": "Publicerad till reläet {accepted} i serien {total}.",
"reviews.propagating": "När innehållet sprids kan det ta en stund innan det visas på andra ställen.", "reviews.propagating": "Publicerar till Nostr-relayer",
"reviews.summary.line.one": "{count}-betyg utan kommentar: {breakdown}", "reviews.summary.line.one": "{count}-betyg utan kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-betyg utan kommentar: {breakdown}", "reviews.summary.line.other": "{count}-betyg utan kommentar: {breakdown}",
"reviews.summary.lineDay.one": "Betyg för {count} utan kommentar om {when}: {breakdown}", "reviews.summary.lineDay.one": "Betyg för {count} utan kommentar om {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Bu npub'dan ilk inceleme, başka bir faaliyet bulunmadı", "reviews.anonNote": "Bu npub'dan ilk inceleme, başka bir faaliyet bulunmadı",
"reviews.published": "Yayınlandı.", "reviews.published": "Yayınlandı.",
"reviews.publishedPartial": "{accepted} {total} rölesine yayımlandı.", "reviews.publishedPartial": "{accepted} {total} rölesine yayımlandı.",
"reviews.propagating": "Yayılırken başka bir yerde ortaya çıkması biraz zaman alabilir.", "reviews.propagating": "Nostr relay'lerine yayınlanıyor",
"reviews.summary.line.one": "{count} yorumsuz puan: {breakdown}", "reviews.summary.line.one": "{count} yorumsuz puan: {breakdown}",
"reviews.summary.line.other": "{count} yorumsuz puanlar: {breakdown}", "reviews.summary.line.other": "{count} yorumsuz puanlar: {breakdown}",
"reviews.summary.lineDay.one": "{when} yorumsuz {count} puanı: {breakdown}", "reviews.summary.lineDay.one": "{when} yorumsuz {count} puanı: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Перший відгук від цього npub, іншої активності не знайдено", "reviews.anonNote": "Перший відгук від цього npub, іншої активності не знайдено",
"reviews.published": "Опубліковано.", "reviews.published": "Опубліковано.",
"reviews.publishedPartial": "Опубліковано на {accepted} із {total} relays.", "reviews.publishedPartial": "Опубліковано на {accepted} із {total} relays.",
"reviews.propagating": "Поширюється мережею, поява в інших місцях може тривати деякий час.", "reviews.propagating": "Публікація на relays Nostr",
"reviews.summary.line.one": "{count} рейтинг без коментаря: {breakdown}", "reviews.summary.line.one": "{count} рейтинг без коментаря: {breakdown}",
"reviews.summary.line.few": "{count} оцінки без коментарів: {breakdown}", "reviews.summary.line.few": "{count} оцінки без коментарів: {breakdown}",
"reviews.summary.line.many": "{count} оцінок без коментарів: {breakdown}", "reviews.summary.line.many": "{count} оцінок без коментарів: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "اس ناول سے پہلا جائزہ، کوئی اور سرگرمی نہیں پائی گئی", "reviews.anonNote": "اس ناول سے پہلا جائزہ، کوئی اور سرگرمی نہیں پائی گئی",
"reviews.published": "شائع ہوا۔", "reviews.published": "شائع ہوا۔",
"reviews.publishedPartial": "شائع شدہ{accepted}کا مطلب{total}ریلویز.", "reviews.publishedPartial": "شائع شدہ{accepted}کا مطلب{total}ریلویز.",
"reviews.propagating": "اِس لئے شاید آپ کسی اَور ملک میں جا کر اُس کے بارے میں بات کریں ۔", "reviews.propagating": "Nostr ریلے پر شائع ہو رہا ہے",
"reviews.summary.line.one": "{count}بغیر تبصرے کے شرحیں:{breakdown}", "reviews.summary.line.one": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
"reviews.summary.line.other": "{count}بغیر تبصرے کے شرحیں:{breakdown}", "reviews.summary.line.other": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
"reviews.summary.lineDay.one": "{count}بغیر تبصرے کے خواندگی{when}:{breakdown}", "reviews.summary.lineDay.one": "{count}بغیر تبصرے کے خواندگی{when}:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Xem lại lần đầu từ npb này, không tìm thấy hoạt động nào khác", "reviews.anonNote": "Xem lại lần đầu từ npb này, không tìm thấy hoạt động nào khác",
"reviews.published": "Xuất bản.", "reviews.published": "Xuất bản.",
"reviews.publishedPartial": "Do {accepted} của {total} rơle.", "reviews.publishedPartial": "Do {accepted} của {total} rơle.",
"reviews.propagating": "Tuyên truyền, có thể phải mất một lúc mới xuất hiện.", "reviews.propagating": "Đang đăng lên các relay Nostr",
"reviews.summary.line.one": "{count} đánh giá mà không bình luận: {breakdown}", "reviews.summary.line.one": "{count} đánh giá mà không bình luận: {breakdown}",
"reviews.summary.line.other": "{breakdown} {count} đánh giá mà không bình luận: 918274242", "reviews.summary.line.other": "{breakdown} {count} đánh giá mà không bình luận: 918274242",
"reviews.summary.lineDay.one": "{count} đánh giá mà không bình luận gì về {when}: {breakdown}", "reviews.summary.lineDay.one": "{count} đánh giá mà không bình luận gì về {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "这是关于这款npub的首条评论,未发现其他相关活动", "reviews.anonNote": "这是关于这款npub的首条评论,未发现其他相关活动",
"reviews.published": "已发布。", "reviews.published": "已发布。",
"reviews.publishedPartial": "已发布至 {total} 继电器中的 {accepted}。", "reviews.publishedPartial": "已发布至 {total} 继电器中的 {accepted}。",
"reviews.propagating": "正在同步,内容可能需要片刻时间才会显示在其他地方。", "reviews.propagating": "正在发布到 Nostr 中继",
"reviews.summary.line.one": "{count} 的评分(无评论):{breakdown}", "reviews.summary.line.one": "{count} 的评分(无评论):{breakdown}",
"reviews.summary.line.other": "{count} 的评分(无评论):{breakdown}", "reviews.summary.line.other": "{count} 的评分(无评论):{breakdown}",
"reviews.summary.lineDay.one": "{count}的评分,未对{when}发表评论:{breakdown}", "reviews.summary.lineDay.one": "{count}的评分,未对{when}发表评论:{breakdown}",
+10 -2
View File
@@ -25,6 +25,14 @@ interface Props {
description: string; description: string;
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about'; current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
mintCount?: number; mintCount?: number;
/**
* Catalog namespaces this page's islands need on top of `CLIENT_NAMESPACES`.
*
* The home page passes `['home']`: its three grids refresh from the API and rewrite
* their own "All 56 mints →" links, so those strings have to reach the browser. They
* are inlined on the one page that reads them rather than on all 1,300.
*/
clientNamespaces?: readonly string[];
ogType?: string; ogType?: string;
/** /**
* A real page that should not be in the index. * A real page that should not be in the index.
@@ -70,7 +78,7 @@ interface Props {
const { const {
title, description, current, mintCount, ogType = 'website', title, description, current, mintCount, ogType = 'website',
noindex = false, offGraph = false, schema = [], image = OG_IMAGE, noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
imageAlt, imageAlt, clientNamespaces = [],
} = Astro.props; } = Astro.props;
/* /*
@@ -118,7 +126,7 @@ const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
* travels in the HTML the page was sending anyway, costs no extra request, and is on * travels in the HTML the page was sending anyway, costs no extra request, and is on
* screen before the first island has finished downloading. * screen before the first island has finished downloading.
*/ */
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c'); const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale, clientNamespaces) }).replace(/</g, '\\u003c');
/** /**
* The social card, absolute. * The social card, absolute.
+316
View File
@@ -0,0 +1,316 @@
/**
* The mint card, as HTML strings, and the hydration that puts them on a page.
*
* `MintCard.astro` renders the same card at build time and cannot run in the browser, so
* this is its parallel renderer — the same relationship `review-cards.ts` has with the
* reviews panel. The two must agree on every class name and every `data-*` attribute,
* because the sort, the filter, the rank chips and the shared-element view transitions
* on the three index pages all read the DOM rather than any model:
*
* data-mint-card what the sort collects and the transition arms
* data-name / data-domain what the search box matches
* data-status "hide offline", and the online/offline counts
* data-score / -rating / -reviews the sort keys
* data-last-review / -last-online the other two sort keys
* data-vt-icon / data-vt-name shared-element names, applied on click
*
* Why this exists at all: /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
* immediately — the 404 resolver saw to that — and simply had no card until the nightly
* rebuild. The list now refetches after paint. The prerendered cards stay exactly as they
* were: they are the first paint, they are what a crawler and a reader with no JavaScript
* get, and this only ever replaces them with something newer.
*
* Nothing here talks to a relay. Card counts come from the API's ingested aggregates,
* which is what they always were; review *bodies* remain a detail-page and /reviews
* concern. See docs/dynamic-mint-data.md.
*
* Everything interpolated goes through `escapeHtml`. A mint's name comes from its own
* `/v1/info` — a string an operator controls — and this builds markup with strings.
*/
import {
baseUrlFromKey,
federationIdFromSlug,
getMintWarnings,
mintChip,
type MintListItem,
} from '@cashumints/shared';
import { apiBase, escapeHtml, iconGradient } from './client';
import { targetPath } from './feed-resolve';
import { displayDomain, initials, transitionName } from './format';
import { localePath, splitLocale } from '../i18n/routing';
import type { Locale } from '../i18n/config';
import { useI18n } from '../i18n/client';
import { formatters, starString, type Formatters } from '../i18n/format';
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
import { initReveal } from '../scripts/reveal';
export interface CardOptions {
/** 1-based position in the default order. Omitted, the card has no rank chip. */
rank?: number;
/** Entrance delay in ms, for a grid of known size that arrives as one gesture. */
revealDelay?: number;
/**
* Render already revealed, with no entrance.
*
* Set for a card replacing one the reader is already looking at. `[data-reveal]` is
* `opacity: 0` until `.in` lands, so without this a hydration would fade the whole
* visible grid back in a second after load — an animation that says "something
* changed" about forty cards where at most one did.
*/
revealed?: boolean;
}
/**
* One card's markup.
*
* `f` carries both the translator and the locale's number and date formatting, and
* `locale` is what prefixes the href. Both are passed in rather than read here, because
* a grid renders dozens of these and rebuilding the formatter per card would be the
* expensive part of the whole hydration.
*/
export function mintCardHtml(
mint: MintListItem,
locale: Locale,
f: Formatters,
options: CardOptions = {},
): string {
const t = f.t;
const { rank, revealDelay, revealed } = options;
/*
* One card, all three ecosystems — the same reasoning as MintCard.astro.
*
* A federation has no URL, so its second line is a shortened federation id rather than
* `fedimint:aeca6c…`, which is a database key. An LNURL mint's row key carries an
* `lnurl:` scheme in front of its URL, so the domain comes out of the key.
*/
const fedimint = mint.type === 'fedimint';
const lnurlBase = baseUrlFromKey(mint.url);
const domain = fedimint
? federationIdFromSlug(mint.host)
: displayDomain(lnurlBase ?? mint.url);
const name = fedimint
? mint.name ?? t('card.unnamedFederation')
: mint.name ?? domain.split('/')[0] ?? domain;
// Never `API_URL`: that is a build-machine address and a visitor's browser cannot
// reach it. `apiBase` is PUBLIC_API_URL, empty in production, which resolves /icons
// against whatever origin is serving the page.
const icon = mint.icon ? `${apiBase}${mint.icon}` : null;
const offline = mint.status === 'offline';
const announced = mint.status === 'announced';
const href = localePath(targetPath(mint), locale);
/*
* The chip, from facts the list payload now carries.
*
* It used to take one `GET /api/mints/:host` per mint to read these — fine for a build
* machine rendering the grid once a night, ruinous as an N+1 in every visitor's
* browser. `capabilities` and the two LNURL fields were added to `MintListItem` for
* exactly this. A federation has no chip and never will: `mintChip` returns null for
* one, because a federation publishes no capability list to draw a claim from.
*/
const chipSource =
mint.capabilities || mint.max_withdrawable_msat !== undefined || mint.funding_available !== undefined
? {
type: mint.type,
status: mint.status,
last_online: mint.last_online,
capabilities: mint.capabilities ?? null,
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}
: null;
const chip = chipSource
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
: null;
// Falls back to the rating split when there is no distribution, exactly as the build
// does for /mints: a 4.6 average is roughly 92% positive, which is what the bar says.
const pos =
mint.rating_avg === null ? 0 : Math.round(((mint.rating_avg - 1) / 4) * 100);
const neg = mint.rating_avg === null ? 0 : 100 - pos;
const vtIcon = escapeHtml(transitionName('icon', mint.host));
const vtName = escapeHtml(transitionName('name', mint.host));
const iconHtml = icon
? `<img class="mc-icon" src="${escapeHtml(icon)}" alt="" width="42" height="42" ` +
`loading="lazy" decoding="async" data-vt-icon="${vtIcon}">`
: `<span class="mc-icon" style="background:${escapeHtml(iconGradient(domain))}" ` +
`aria-hidden="true" data-vt-icon="${vtIcon}">${escapeHtml(initials(name))}</span>`;
const statsHtml =
mint.rating_avg === null
? `<span class="mc-none">${escapeHtml(t('card.noRatings'))}</span>`
: `<span class="mc-rating">` +
`<span class="mc-score">${escapeHtml(f.decimal(mint.rating_avg))}</span>` +
`<span class="mc-stars" aria-hidden="true">${starString(mint.rating_avg)}</span>` +
`</span>`;
/*
* "Announced" has no "last seen" to print, and printing one anyway is exactly the fake
* status this site refuses to show. What it has instead is the fact that nothing has
* checked it, said in as many words.
*/
const last = announced
? t('card.notChecked')
: offline
? mint.last_online
? t('card.lastSeen', { when: f.relative(mint.last_online) })
: t('card.neverSeen')
: mint.last_review_at
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
: t('card.noReviews');
const classes = ['mint-card'];
if (offline) classes.push('is-offline');
if (revealed) classes.push('in');
return (
`<a class="${classes.join(' ')}" href="${escapeHtml(href)}" data-reveal` +
(revealDelay === undefined ? '' : ` data-reveal-delay="${revealDelay}"`) +
` data-mint-card` +
` data-name="${escapeHtml(name.toLowerCase())}"` +
` data-domain="${escapeHtml(domain.toLowerCase())}"` +
` data-status="${escapeHtml(mint.status)}"` +
` data-score="${mint.score}"` +
` data-rating="${mint.rating_avg ?? 0}"` +
` data-reviews="${mint.review_count}"` +
` data-last-review="${mint.last_review_at ?? 0}"` +
` data-last-online="${mint.last_online ?? 0}">` +
`<div class="mc-top">` +
iconHtml +
`<span class="mc-id">` +
`<span class="mc-name" data-vt-name="${vtName}">${escapeHtml(name)}</span>` +
`<span class="mc-domain${fedimint ? ' mono' : ''}">${escapeHtml(domain)}</span>` +
`</span>` +
(rank === undefined
? ''
: `<span class="mc-rank${rank === 1 ? ' gold' : ''}">#${rank}</span>`) +
`</div>` +
`<div class="mc-stats">` +
statsHtml +
`<span class="mc-reviews">${escapeHtml(t('card.reviews', { n: mint.review_count }))}</span>` +
`</div>` +
`<div class="mc-bar" aria-hidden="true"><span class="mc-bar-fill">` +
(pos > 0 ? `<span class="pos" style="width:${pos}%"></span>` : '') +
(neg > 0 ? `<span class="neg" style="width:${neg}%"></span>` : '') +
`</span></div>` +
`<div class="mc-foot">` +
`<span class="mc-dot ${escapeHtml(mint.status)}"></span>` +
`<span class="mc-status">${escapeHtml(statusLabel(mint.status, t))}</span>` +
(chip ? `<span class="mc-chip ${escapeHtml(chip.severity)}">${escapeHtml(chip.label)}</span>` : '') +
`<span class="last">${escapeHtml(last)}</span>` +
`</div>` +
`</a>`
);
}
/**
* One ecosystem's full listing, or null.
*
* Null on anything at all going wrong, and the caller's job is then to do nothing: the
* prerendered grid is already on screen and correct as of the last build, so a failed
* refresh should be invisible rather than an error message about a list the reader can
* see. This is the same rule the reviews panel and the pulse ticker follow.
*/
export async function fetchListing(type: string): Promise<MintListItem[] | null> {
try {
const res = await fetch(`${apiBase}/api/mints?type=${encodeURIComponent(type)}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const items = (await res.json()) as MintListItem[];
// A well-formed empty answer is still not a reason to empty a grid that has cards in
// it. An API serving nothing is the failure the build gate exists to catch, and a
// page that renders it as "no mints" would be this bug wearing a different hat.
return Array.isArray(items) && items.length > 0 ? items : null;
} catch {
return null;
}
}
/**
* Replace a grid's cards with freshly rendered ones.
*
* Cards whose host was already on screen and revealed are rendered revealed, so the
* common case — the list is the same list, with newer numbers — is a silent swap rather
* than forty cards fading in again. Genuinely new hosts get the ordinary entrance from
* `initReveal`, which is also what reveals anything below the fold on scroll.
*
* Returns the new card elements, in DOM order, for the caller to re-apply its sort and
* filter to.
*/
export function renderMintGrid(
grid: HTMLElement,
items: MintListItem[],
options: { ranked?: boolean; revealDelayStep?: number } = {},
): HTMLElement[] {
const { ranked = true, revealDelayStep } = options;
const t = useI18n();
const f = formatters(t);
const { locale } = splitLocale(window.location.pathname);
// Which hosts the reader can already see. Keyed by host rather than by index: the list
// may have grown, shrunk or reordered, and the question is per mint.
const revealed = new Set<string>();
for (const card of grid.querySelectorAll<HTMLAnchorElement>('[data-mint-card]')) {
if (card.classList.contains('in')) {
const host = card.getAttribute('href')?.split('/').pop();
if (host) revealed.add(decodeURIComponent(host));
}
}
grid.innerHTML = items
.map((mint, i) =>
mintCardHtml(mint, locale, f, {
...(ranked ? { rank: i + 1 } : {}),
...(revealDelayStep === undefined ? {} : { revealDelay: i * revealDelayStep }),
revealed: revealed.has(mint.host),
}),
)
.join('');
initReveal(grid);
return [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
}
export interface HydrateOptions {
/** `cashu`, `fedimint` or `lnurl`. */
type: string;
/** The grid to rebuild. */
grid: HTMLElement;
/** Keep only the first N, for the home page's top-six strips. */
limit?: number;
ranked?: boolean;
revealDelayStep?: number;
/** Called with the new cards and the payload they were built from, on success only. */
onReplaced?: (cards: HTMLElement[], items: MintListItem[]) => void;
}
/**
* Fetch one ecosystem and rebuild its grid, after paint.
*
* Deliberately silent on failure — see `fetchListing`. Deliberately unconditional on
* success: the API is the newer of the two by construction, since the prerendered grid
* is a copy of what this same endpoint said at build time.
*/
export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
const { type, grid, limit, ranked, revealDelayStep, onReplaced } = options;
const all = await fetchListing(type);
if (!all) return;
// The reader may have navigated away while the fetch was in flight. The router has
// already swapped this grid out of the document, so rendering into it (and telling the
// caller to re-apply filters against cards nobody can see) is wasted work.
if (!grid.isConnected) return;
const items = limit === undefined ? all : all.slice(0, limit);
const cards = renderMintGrid(grid, items, {
...(ranked === undefined ? {} : { ranked }),
...(revealDelayStep === undefined ? {} : { revealDelayStep }),
});
onReplaced?.(cards, all);
}
+16 -28
View File
@@ -55,19 +55,6 @@ export interface BuildReview {
* characters or matching a common test string do not qualify. They still appear in * characters or matching a common test string do not qualify. They still appear in
* full on the mint page, nothing is hidden, this strip is just a shop window. * full on the mint page, nothing is hidden, this strip is just a shop window.
*/ */
/** How many candidates to gather per card shown, so the ordering has a real choice. */
const POOL_FACTOR = 12;
/**
* How recent a review must still be to be promoted for having a named author.
*
* The ordering below prefers reviews whose author has a kind 0, and this is the leash
* on that preference. Without it the strip would headline a named review from two
* years ago under a heading that says "Latest reviews"; with it, a named review from
* within the season can lead and anything older cannot.
*/
const PROMOTE_MAX_AGE_S = 90 * 24 * 60 * 60;
const TEST_STRINGS = new Set([ const TEST_STRINGS = new Set([
'test', 'testing', 'test test', 'hello', 'hi', 'ok', 'okay', 'good', 'nice', 'test', 'testing', 'test test', 'hello', 'hi', 'ok', 'okay', 'good', 'nice',
'great', 'cool', 'gm', 'a', '.', '..', '...', 'asdf', 'qwerty', '123', 'great', 'cool', 'gm', 'a', '.', '..', '...', 'asdf', 'qwerty', '123',
@@ -227,29 +214,30 @@ export async function fetchLatestReviews(
profile: null, profile: null,
}); });
if (candidates.length >= limit * POOL_FACTOR) break; // The scan runs newest first, so the first `limit` survivors are the latest
// `limit` reviews. Nothing further down the list can outrank them.
if (candidates.length >= limit) break;
} }
const profiles = await fetchProfiles(candidates.map((c) => c.pubkey)); const profiles = await fetchProfiles(candidates.map((c) => c.pubkey));
for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null; for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null;
/* /*
* Recent reviews with a named author come first; everything else stays newest * Strict recency, newest first, as the last thing that happens to this list.
* first behind them.
* *
* Most review keys have published exactly one event in their life: the review * The heading says "Latest reviews", so the order under it is the event
* itself. Nothing can be fetched for them, so a strict recency order fills this * `created_at` and nothing else. This used to float reviews whose author had a
* strip with anonymous npubs while a named review sits a few rows below the * kind 0 to the front, which read as broken on the page: a named review from two
* cut. This is the home page shop window, which already drops junk bodies, so * months ago sat between two reviews from this week, and the card feet said so,
* it prefers a review a reader can attach a person to. Nothing is hidden: * because the "2mo ago" label is formatted from the very same `created_at` this
* every review is on its mint page, and PROMOTE_MAX_AGE_S keeps "latest" * sorts on. Author names are still resolved and still shown — they just no longer
* meaning latest. * decide the order.
*
* Sorted here rather than left to the scan above, so that the invariant holds
* whatever the gathering loop does later, and `id` breaks ties between two events
* that share a second so two builds of the same events agree.
*/ */
const cutoff = Math.floor(Date.now() / 1000) - PROMOTE_MAX_AGE_S; candidates.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
const promoted = (review: BuildReview): number =>
review.profile?.found && review.created_at >= cutoff ? 1 : 0;
candidates.sort((a, b) => promoted(b) - promoted(a) || b.created_at - a.created_at);
return candidates.slice(0, limit); return candidates.slice(0, limit);
} catch { } catch {
+3 -17
View File
@@ -37,16 +37,8 @@ export interface MintRef {
} }
export interface CardOptions { export interface CardOptions {
/** Adds the "propagating to relays" note, for a review published in this session. */ /** Adds the transient publishing note for a review published in this session. */
propagating?: boolean; propagating?: boolean;
/**
* How many relays took it, when the review was published in this session.
*
* Only said out loud when some relay refused: "published to 2 of 4" is a fact the
* author can act on (try again later, and it is already on the network), where a
* clean 4 of 4 is just noise on the card.
*/
publishedTo?: { accepted: number; total: number } | null;
/** Adds a link to the mint being reviewed. */ /** Adds a link to the mint being reviewed. */
mint?: MintRef | null; mint?: MintRef | null;
/** /**
@@ -303,15 +295,9 @@ function actionsHtml(review: LoadedReview, f: Formatters, options: CardOptions):
/* ---------- the card ---------- */ /* ---------- the card ---------- */
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string { export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
// The note pulses gently while the review is still in flight. It stops the moment // The note pulses gently for a few seconds after a review is published.
// the relays echo the review back, because then the card is redrawn without it.
const relays = options.publishedTo;
const partial =
relays && relays.accepted < relays.total
? f.t('reviews.publishedPartial', { accepted: relays.accepted, total: relays.total })
: f.t('reviews.published');
const propagating = options.propagating const propagating = options.propagating
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>` ? `<p class="rev-note propagating">${escapeHtml(f.t('reviews.propagating'))}</p>`
: ''; : '';
// The anchor id only for real event ids, so `#review-{id}` deep links resolve and // The anchor id only for real event ids, so `#review-{id}` deep links resolve and
+3
View File
@@ -98,6 +98,9 @@ export const FIXTURE_MINT: MintDetail = {
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9', pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
info: null, info: null,
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'], nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
// Both NUTs published and neither switched off, so this fixture draws no card chip —
// which is what a representative healthy mint should look like.
capabilities: { mintDisabled: false, meltDisabled: false, mintPublished: true, meltPublished: true },
first_seen: daysAgo(420), first_seen: daysAgo(420),
last_probe: daysAgo(0), last_probe: daysAgo(0),
updated_at: daysAgo(0), updated_at: daysAgo(0),
+43 -3
View File
@@ -138,7 +138,7 @@ const schema = [
) )
} }
<div class="mint-grid" data-mint-grid> <div class="mint-grid" data-mint-grid="fedimint">
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)} {federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
</div> </div>
@@ -247,6 +247,7 @@ const schema = [
<script> <script>
import { wireCopyableIds } from '../../lib/client'; import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client'; import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal'; import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -258,7 +259,7 @@ const schema = [
const t = useI18n(); const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]'); const grid = document.querySelector<HTMLElement>('[data-mint-grid="fedimint"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]'); const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]'); const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]'); const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
@@ -267,7 +268,15 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]'); const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) { if (grid && searchInput && sortSelect && hideOffline) {
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')]; /*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0); const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0); const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -415,6 +424,37 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort; if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply(); if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'fedimint',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => { onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer); for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear(); leaving.clear();
+74 -8
View File
@@ -100,7 +100,18 @@ const signedBody = t('home.why.signed.body', {
}); });
--- ---
<Base title={t('home.title')} description={description} current="mints"> {/*
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
links, so those strings have to reach the browser; 1,300 mint pages have no use for
them. See `clientCatalog` in src/i18n/index.ts.
*/}
<Base
title={t('home.title')}
description={description}
current="mints"
clientNamespaces={['home']}
>
{/* {/*
The hero sits on an animated colour field: ColorBends from React Bits, ported to The hero sits on an animated colour field: ColorBends from React Bits, ported to
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
@@ -180,9 +191,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal> <div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2> <h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
<span class="sec-sub">{t('home.top.sub')}</span> <span class="sec-sub">{t('home.top.sub')}</span>
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a> <a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
{t('home.top.all', { n: mints.length })}
</a>
</div> </div>
<div class="mint-grid"> <div class="mint-grid" data-home-grid="cashu">
{top.map((mint, i) => ( {top.map((mint, i) => (
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} /> <MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
))} ))}
@@ -200,11 +213,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal> <div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2> <h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
<span class="sec-sub">{t('home.topFedimints.sub')}</span> <span class="sec-sub">{t('home.topFedimints.sub')}</span>
<a class="sec-link" href={localePath('/fedimints', locale)}> <a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
{t('home.topFedimints.all', { n: federations.length })} {t('home.topFedimints.all', { n: federations.length })}
</a> </a>
</div> </div>
<div class="mint-grid"> <div class="mint-grid" data-home-grid="fedimint">
{topFederations.map((federation, i) => ( {topFederations.map((federation, i) => (
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} /> <MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
))} ))}
@@ -225,11 +238,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal> <div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2> <h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
<span class="sec-sub">{t('home.topLnurl.sub')}</span> <span class="sec-sub">{t('home.topLnurl.sub')}</span>
<a class="sec-link" href={localePath('/lnurl-mints', locale)}> <a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
{t('home.topLnurl.all', { n: stats.lnurl_total })} {t('home.topLnurl.all', { n: stats.lnurl_total })}
</a> </a>
</div> </div>
<div class="mint-grid"> <div class="mint-grid" data-home-grid="lnurl">
{topLnurl.map((mint, i) => ( {topLnurl.map((mint, i) => (
<MintCard <MintCard
mint={mint} mint={mint}
@@ -615,14 +628,67 @@ const signedBody = t('home.why.signed.body', {
</style> </style>
<script> <script>
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal'; import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
/** One card every six seconds, until the reader touches the track. */ /** One card every six seconds, until the reader touches the track. */
const AUTO_MS = 6000; const AUTO_MS = 6000;
/** Matches the track's CSS gap. */ /** Matches the track's CSS gap. */
const GAP = 16; const GAP = 16;
/**
* The three top-six strips, and the "All N" link over each of them.
*
* The home page prerenders six cards per ecosystem and a count beside them, from the
* same API the index pages read. Between builds the strips went stale in two ways at
* once: a new mint could not appear in the top six however good it was, and the count
* beside the link said how many mints existed at 03:30. Both are one fetch away.
*
* The key is the catalog string for that link, which is why this page inlines the
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
*
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
* cards get real rating distributions from a per-mint detail fetch at build time, and
* the list payload has no distribution in it. A refreshed card falls back to the
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
*/
const STRIPS = [
{ type: 'cashu', key: 'home.top.all' },
{ type: 'fedimint', key: 'home.topFedimints.all' },
{ type: 'lnurl', key: 'home.topLnurl.all' },
] as const;
const hydrateStrips = (): void => {
const t = useI18n();
for (const strip of STRIPS) {
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
// A section with nothing in it is not rendered at all, and a strip that was empty
// at build time stays empty until the next one: there is no heading to hang cards
// under. Rare, and not worth building a section in JavaScript for.
if (!grid) continue;
void hydrateMintGrid({
type: strip.type,
grid,
limit: 6,
// The reveal delay the build gives these six, so a refreshed strip arrives the
// same way the prerendered one did.
revealDelayStep: 50,
onReplaced: (_cards, all) => {
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
if (link) swapText(link, t(strip.key, { n: all.length }));
},
});
}
};
const setup = (): void => { const setup = (): void => {
requestAnimationFrame(() => hydrateStrips());
// No track at all means the relays gave the build nothing, and the section is // No track at all means the relays gave the build nothing, and the section is
// showing its empty state instead. // showing its empty state instead.
const found = { const found = {
+63 -23
View File
@@ -2,8 +2,7 @@
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro'; import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro'; import ReviewByUrl from '../../components/ReviewByUrl.astro';
import type { LnurlDetail } from '@cashumints/shared'; import { fetchLnurlMints } from '../../lib/api';
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n'; import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema'; import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo'; import { isIndexableMint } from '../../lib/seo';
@@ -32,25 +31,17 @@ const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed. // `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale); const t = useI18n(locale);
const mints = await fetchLnurlMints();
/* /*
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt" One request, no N+1.
chips need each mint's detail. One fetch per mint, at build time, against the API on
the same machine — exactly what /mints does for its NUT switches. A mint whose detail This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
cannot be read simply gets no chip. purely to read the two probed facts behind the "no withdrawals" and "no mint / melt"
chips: an advertised withdraw ceiling of zero, and an unreachable Lightning node.
`MintListItem` carries both now, exactly as it carries the Cashu NUT switches, so the
chips come off the same payload as everything else — which is what makes the hydration
below possible without an N+1 in every visitor's browser.
*/ */
const details = await Promise.all( const mints = await fetchLnurlMints();
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
);
const chipSources = details.map((detail: LnurlDetail | null) =>
detail
? {
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
funding_available: detail.funding_available ?? null,
}
: null,
);
const online = mints.filter((m) => m.status === 'online').length; const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length; const offline = mints.filter((m) => m.status === 'offline').length;
@@ -151,8 +142,17 @@ const schema = [
{t('lnurlMints.showing', { total: mints.length, online, offline })} {t('lnurlMints.showing', { total: mints.length, online, offline })}
</p> </p>
<div class="mint-grid" data-mint-grid> <div class="mint-grid" data-mint-grid="lnurl">
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)} {mints.map((mint, i) => (
<MintCard
mint={mint}
rank={i + 1}
lnurl={{
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}}
/>
))}
</div> </div>
<p class="no-results" data-no-results hidden> <p class="no-results" data-no-results hidden>
@@ -258,6 +258,7 @@ const schema = [
<script> <script>
import { wireCopyableIds } from '../../lib/client'; import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client'; import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal'; import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -271,7 +272,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too. // `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n(); const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]'); const grid = document.querySelector<HTMLElement>('[data-mint-grid="lnurl"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]'); const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]'); const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]'); const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
@@ -280,7 +281,15 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]'); const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) { if (grid && searchInput && sortSelect && hideOffline) {
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')]; /*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0); const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0); const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -431,6 +440,37 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort; if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply(); if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'lnurl',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => { onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer); for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear(); leaving.clear();
+55 -17
View File
@@ -2,8 +2,7 @@
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro'; import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro'; import ReviewByUrl from '../../components/ReviewByUrl.astro';
import { readCapabilities } from '@cashumints/shared'; import { fetchMints } from '../../lib/api';
import { fetchMint, fetchMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n'; import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema'; import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo'; import { isIndexableMint } from '../../lib/seo';
@@ -20,19 +19,18 @@ const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed. // `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale); const t = useI18n(locale);
/*
One request, no N+1.
This used to be `fetchMints()` followed by one `fetchMint(host)` per mint, purely to
read the two NUT switches behind the "melt only" and "frozen" chips. `MintListItem`
carries `capabilities` now, so the chips come off the same payload as everything else.
That was worth doing for the build — fifty-six requests down to one — and it was
necessary for the hydration below, which does the same read in every visitor's browser
and could not have done it fifty-six times.
*/
const mints = await fetchMints(); const mints = await fetchMints();
/*
The list payload carries no NUT information, so the "melt only" and "frozen" chips
need each mint's cached info. One detail fetch per mint, at build time, against the
API on the same machine: the mint pages already do exactly this in getStaticPaths.
A mint whose detail cannot be read simply gets no chip.
*/
const capabilities = await Promise.all(
mints.map((mint) =>
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
),
);
const online = mints.filter((m) => m.status === 'online').length; const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length; const offline = mints.filter((m) => m.status === 'offline').length;
@@ -127,8 +125,8 @@ const schema = [
{t('mints.showing', { total: mints.length, online, offline })} {t('mints.showing', { total: mints.length, online, offline })}
</p> </p>
<div class="mint-grid" data-mint-grid> <div class="mint-grid" data-mint-grid="cashu">
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)} {mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
</div> </div>
<p class="no-results" data-no-results hidden> <p class="no-results" data-no-results hidden>
@@ -232,6 +230,7 @@ const schema = [
<script> <script>
import { wireCopyableIds } from '../../lib/client'; import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client'; import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal'; import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -245,7 +244,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too. // `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n(); const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]'); const grid = document.querySelector<HTMLElement>('[data-mint-grid="cashu"]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]'); const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]'); const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]'); const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
@@ -254,7 +253,15 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]'); const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) { if (grid && searchInput && sortSelect && hideOffline) {
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')]; /*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0); const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0); const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -405,6 +412,37 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort; if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply(); if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'cashu',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => { onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer); for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear(); leaving.clear();
+9
View File
@@ -208,6 +208,15 @@ export function armSharedTransitions(): void {
* only does its work at module scope is dead after the first navigation. It does fire * only does its work at module scope is dead after the first navigation. It does fire
* `astro:page-load` on every arrival, including the first, but on the first it waits * `astro:page-load` on every arrival, including the first, but on the first it waits
* for `window.load`, which is far too late to be the only trigger. Hence both. * for `window.load`, which is far too late to be the only trigger. Hence both.
*
* The listener is never removed, on purpose: a layout island (the language switcher,
* the login dialog) has DOM on every page, and this is what keeps it alive. The flip
* side is that `setup` runs on *every* page the router swaps in, not only pages that
* include the script. A page-level script must therefore look its DOM up by a marker
* unique to that page — `data-mint-grid="cashu"`, `data-home-grid="fedimint"` — and do
* nothing when the marker is absent. A marker shared between pages means a stale page's
* setup finds the current page's DOM and rewrites it: the listing pages once flashed
* back to the previous ecosystem's mints for exactly that reason.
*/ */
export function onReady(setup: () => void): void { export function onReady(setup: () => void): void {
let done = false; let done = false;
+6 -1
View File
@@ -1268,8 +1268,13 @@ main { flex: 1; }
0%, 25% { opacity: .85; } 0%, 25% { opacity: .85; }
100% { opacity: 0; } 100% { opacity: 0; }
} }
/* Still travelling. Stops when the relays echo the review back on the next load. */ /* Visible for a few seconds after a review is published from this tab. */
.rev-note.propagating { animation: soft-pulse 2s var(--ease-in-out) infinite; } .rev-note.propagating { animation: soft-pulse 2s var(--ease-in-out) infinite; }
.rev-note.propagating.is-out {
animation: none;
opacity: 0;
transition: opacity var(--dur-quick) var(--ease-out);
}
/* ---------- status dot ---------- */ /* ---------- status dot ---------- */
.status-dot { position: relative; } .status-dot { position: relative; }
+60
View File
@@ -0,0 +1,60 @@
/**
* The three listing pages must each own their grid.
*
* `<ClientRouter />` swaps pages without reloading, and `onReady` in `scripts/reveal.ts`
* re-runs every page's setup on every arrival — deliberately, since that is what keeps
* the layout islands alive. So once `/mints` has been visited, its setup also runs on
* `/fedimints`. If both pages mark their grid the same way, the stale cashu setup finds
* the fedimint grid, fetches `?type=cashu` and rewrites it: the page shows fedimints for
* a moment, then flashes back to cashu mints. That happened.
*
* The guard is that each page's marker carries its ecosystem, and its script only looks
* for its own. This checks the template, the query and the hydrate call agree, and that
* no bare `data-mint-grid` has crept back in. Plain node, source read as text, like
* `i18n-wiring.test.mjs`.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/pages/[...locale]');
const read = (file) => readFileSync(path.join(pagesDir, file), 'utf8');
const LISTINGS = [
['mints.astro', 'cashu'],
['fedimints.astro', 'fedimint'],
['lnurl-mints.astro', 'lnurl'],
];
for (const [file, type] of LISTINGS) {
test(`${file} marks, queries and hydrates its grid as ${type}`, () => {
const source = read(file);
assert.match(
source,
new RegExp(`<div class="mint-grid" data-mint-grid="${type}">`),
`${file}: template grid is not marked data-mint-grid="${type}"`,
);
assert.match(
source,
new RegExp(`querySelector<HTMLElement>\\('\\[data-mint-grid="${type}"\\]'\\)`),
`${file}: script does not query [data-mint-grid="${type}"]`,
);
assert.match(
source,
new RegExp(`type: '${type}',`),
`${file}: hydrateMintGrid is not called with type '${type}'`,
);
// A bare marker, in either the template or the query, is the bug coming back.
assert.doesNotMatch(source, /data-mint-grid[>\s]/, `${file}: bare data-mint-grid in template`);
assert.doesNotMatch(source, /\[data-mint-grid\]/, `${file}: bare [data-mint-grid] selector`);
});
}
test('the three listings use three different markers', () => {
const types = new Set(LISTINGS.map(([, type]) => type));
assert.equal(types.size, LISTINGS.length);
});
+166
View File
@@ -0,0 +1,166 @@
/**
* What the production server has to get right that a static file server does not.
*
* The site was served by nginx pointing a `root` at dist until this process took over,
* and the rules that lived in that config are the ones worth pinning down here: a miss
* has to answer 404 rather than 200 with a 404-shaped page, a miss under a locale has
* to stay in that locale, hashed assets have to be immutable and markup must not be,
* and nothing outside the root may be readable however the path is spelled.
*
* A fixture tree rather than dist/: these are assertions about the server, and running
* them should not require a build.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cashumints-web-'));
const write = async (rel, body) => {
await fs.mkdir(path.join(root, path.dirname(rel)), { recursive: true });
await fs.writeFile(path.join(root, rel), body);
};
await write('index.html', '<!doctype html><html lang="en">home</html>');
await write('404.html', '<!doctype html><html lang="en">missing</html>');
await write('es/404/index.html', '<!doctype html><html lang="es">no encontrado</html>');
await write('es/mints/index.html', '<!doctype html><html lang="es">casas</html>');
await write('mints/index.html', '<!doctype html><html lang="en">mints</html>');
await write('robots.txt', 'User-agent: *\n');
await write('_astro/app.abc123.js', 'export default 1;\n');
await write('og/default.png', 'not really a png');
await write('og/mint.abc123.png', 'not really a png either');
// ROOT is read from the environment once, at import.
process.env.WEB_ROOT = root;
const { createServer, safePath, candidates, cacheControl } = await import('../server.mjs');
const server = createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
test.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const get = (p, init) => fetch(`${base}${p}`, init);
test('serves the index at the root', async () => {
const res = await get('/');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'text/html; charset=utf-8');
assert.match(await res.text(), /home/);
});
test('resolves directory routes with and without a trailing slash', async () => {
for (const p of ['/mints', '/mints/']) {
const res = await get(p);
assert.equal(res.status, 200, p);
assert.match(await res.text(), /mints/);
}
});
test('a miss is a 404, not a 200 carrying the 404 page', async () => {
const res = await get('/nope');
assert.equal(res.status, 404);
assert.match(await res.text(), /missing/);
});
test('a miss under a locale stays in that locale', async () => {
const res = await get('/es/nope');
assert.equal(res.status, 404);
assert.match(await res.text(), /no encontrado/);
});
test('a miss under a prefix that is not a locale falls back to English', async () => {
const res = await get('/mint/does-not-exist');
assert.equal(res.status, 404);
assert.match(await res.text(), /missing/);
});
test('hashed assets are immutable and markup is not', async () => {
const asset = await get('/_astro/app.abc123.js');
assert.equal(asset.headers.get('cache-control'), 'public, max-age=31536000, immutable');
const page = await get('/');
assert.equal(page.headers.get('cache-control'), 'public, max-age=0, must-revalidate');
});
test('the one unhashed card is not cached for a year', async () => {
const shared = await get('/og/default.png');
assert.equal(shared.headers.get('cache-control'), 'public, max-age=3600');
const hashed = await get('/og/mint.abc123.png');
assert.equal(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
});
test('a matching ETag revalidates into a bodiless 304', async () => {
const first = await get('/');
const etag = first.headers.get('etag');
assert.ok(etag);
const second = await get('/', { headers: { 'If-None-Match': etag } });
assert.equal(second.status, 304);
assert.equal(await second.text(), '');
});
test('HEAD answers with the headers and no body', async () => {
const res = await get('/', { method: 'HEAD' });
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-length'), String((await fs.stat(path.join(root, 'index.html'))).size));
assert.equal(await res.text(), '');
});
test('anything but GET and HEAD is refused', async () => {
const res = await get('/', { method: 'POST' });
assert.equal(res.status, 405);
assert.equal(res.headers.get('allow'), 'GET, HEAD');
});
test('nothing outside the root is readable', async () => {
await fs.writeFile(path.join(root, '..', 'cashumints-secret.txt'), 'secret');
for (const p of ['/../cashumints-secret.txt', '/%2e%2e/cashumints-secret.txt', '/mints/../../cashumints-secret.txt']) {
const res = await get(p);
assert.equal(res.status, 404, p);
assert.doesNotMatch(await res.text(), /secret/, p);
}
await fs.rm(path.join(root, '..', 'cashumints-secret.txt'), { force: true });
});
test('dotfiles are refused rather than looked up', async () => {
const res = await get('/.env');
assert.equal(res.status, 400);
});
test('every response carries the baseline security headers', async () => {
const res = await get('/');
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
assert.equal(res.headers.get('referrer-policy'), 'strict-origin-when-cross-origin');
assert.equal(res.headers.get('x-frame-options'), 'DENY');
});
test('safePath refuses what it should and keeps what it should', () => {
assert.deepEqual(safePath('/mints/'), ['mints']);
assert.deepEqual(safePath('/'), []);
assert.equal(safePath('/a\0b'), null);
assert.equal(safePath('/.git/config'), null);
// Normalised away rather than escaping: the lookup stays inside the root.
assert.deepEqual(safePath('/../../etc/passwd'), ['etc', 'passwd']);
});
test('candidates cover the shapes a static build emits', () => {
assert.deepEqual(candidates([]), ['index.html']);
assert.deepEqual(candidates(['mints']), ['mints', 'mints/index.html', 'mints.html']);
});
test('cacheControl is decided by path and extension', () => {
assert.match(cacheControl('/mints', '.html'), /must-revalidate/);
assert.match(cacheControl('/_astro/x.js', '.js'), /immutable/);
assert.match(cacheControl('/robots.txt', '.txt'), /max-age=3600/);
assert.match(cacheControl('/favicon.ico', '.ico'), /max-age=604800/);
});