Files
CashuMints.space/docs/dynamic-mint-data.md
T
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

22 KiB
Raw Blame History

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 rsyncs 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:

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 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[]
{
  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:
{
  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.

{
  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)

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.