Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/ index client flows so the site surfaces the new mint type end to end. Co-authored-by: Cursor <cursoragent@cursor.com>
cashumints.space
An ecash explorer and review site. Lists every Cashu mint and every Fedimint federation discoverable on the Nostr network, shows each one's metadata, and surfaces community reviews published as NIP-87 events.
Made by Azzamo.
Nostr relays Cashu mints Fedimint federations
(NIP-87 events) (/v1/info) (no status endpoint;
38172 / 38173 / 38000 see "Ecosystems")
| | |
v discovery (hourly) v probe (10 min) v check (10 min)
+--------------------------------------------------------------+
| api/ Node + Hono + SQLite |
| last-known-good metadata, 4 REST endpoints |
+--------------------------------------------------------------+
| |
build-time fetch runtime fetch (islands)
v v
+--------------------------------------------------------------+
| web/ Astro, static, prerendered |
+--------------------------------------------------------------+
|
browser <-> relays (review text, signing via NIP-07 or NIP-46)
The backend is a memory layer, not an authority. Its job is to remember what a mint looked like when it was last reachable, so an offline mint still has a page people can read and review. Review content is never stored server-side.
Identity is the same story: logging in is a browser-side affair the API never sees. A session is a pubkey plus, for a remote signer, the NIP-46 connection details, kept in localStorage. No private key is ever asked for, accepted or stored, by any code path in this repository.
Layout
| Path | What it is |
|---|---|
api/ |
Indexer and REST API. Hono, SQLite or Postgres, nostr-tools. |
web/ |
Astro static site with vanilla TypeScript islands. |
shared/ |
Types, NIP-87 constants, URL normalization, scoring, NUT and module names. |
The site is in English, Spanish and Dutch. web/src/i18n/ holds the message catalogs
and the locale table; web/src/i18n/GLOSSARY.md holds the term decisions a translator
needs before touching either. See Languages.
NOTES.md records what was found in the previous codebase: event kinds, tags, relays, the
rating encoding, and the bugs this rebuild fixes.
Requirements
- Node 20 or newer (uses
--experimental-strip-types, so no build step for the API) - pnpm 9 or newer
Setup
pnpm install
pnpm --filter ./shared build
shared/ compiles to dist/, which both other packages import. Run it once after install
and again whenever you change something in shared/src.
Seed the database
Ingests the initial mint list, probes every mint, runs a full discovery backfill against the relays, and prints a summary table. Takes about a minute against the live network.
pnpm seed
Database
Everything the API serves is read from its own database, never from a mint or a relay at
request time: mint metadata, /v1/info payloads, reviews, probe history and the
discovery cursor. That is what lets a mint page render in full while the mint is offline,
and it is why a restart loses nothing — the process keeps no state of its own beyond a
60 second cache in front of /api/stats. Icons are files under ICON_DIR and survive
alongside it.
Two backends, chosen by DATABASE_URL:
DATABASE_URL |
Backend |
|---|---|
| unset | SQLite at DB_PATH. The default. |
postgres://user:pw@host:5432/cashumints |
Postgres |
sqlite:/var/lib/cashumints/cashumints.db |
SQLite at that path |
SQLite is the right answer for a single API process, which is the shape this service has: one indexer, one writer, reads served from the page cache. Reach for Postgres when you need something SQLite cannot give you — the database on a different host from the API, more than one API process, or your existing backup and replication setup.
Neither needs a setup step. Both create their tables on first connection, so pointing the API at an empty Postgres database is the whole installation:
createdb cashumints
Both backends run the same SQL. One statement is written once and sent to either, so
there is no dialect-specific query path to drift. pnpm --filter ./api test runs the
review-dedupe statement against a real database, and CHECK_DB_URL runs it against
Postgres too. See the header of api/src/db-schema.ts for the rules that keep a
statement portable.
Moving between them
migrate copies every table from one database to the other. --from defaults to
whatever the current configuration points at, so the usual direction needs only --to:
pnpm --filter ./api migrate --to postgres://user:pw@localhost:5432/cashumints
Then set DATABASE_URL to the same value and restart the API. It works in both
directions and between two databases of the same kind:
pnpm --filter ./api migrate --from postgres://localhost/cashumints --to ./data/cashumints.db
Rows are upserted on their primary key, so an interrupted run can just be repeated, and
the migrator reads the counts back from the target and fails if any table came up short.
probes is the exception — an append-only log with no unique key, so a target that
already has probe rows is refused unless you pass --force, which replaces them. Add
--dry-run to see the row counts without writing anything.
Migrating while the API is running will copy a moving target. Stop it first.
Icons are files, not rows: migrate does not touch ICON_DIR, so copy that directory
yourself if the new database lives on a different host.
Development
Runs the API on :8787 and the Astro dev server on :4321. Both ports come from
.env — see Configuration.
pnpm dev
Run them separately if you prefer:
pnpm dev:api
pnpm dev:web
The web app reads the API at build time, so the API must be running before you build or start the dev server.
Skeleton loading
Two regions fetch their content at runtime and get a skeleton while they wait: the reviews
panel body on a mint page (reviews come from Nostr relays) and the prerender miss summary
on /mint/{host} for a mint added since the last build. Nothing prerendered is ever
covered by a skeleton, so the mint header, verdict strip, sidebar, the reviews panel's own
header and filter counts, the pulse ticker and every grid stay on screen as they are.
The bones are captured from the real rendered components by
Boneyard and committed as
web/src/bones/*.bones.json, so they ship with the island and draw immediately without a
network round trip.
Regenerate them with the dev server running:
pnpm bones
That visits a real mint page and a deliberately unprerendered /mint/ URL at 360, 768 and
1280px wide, and rewrites both bone files. Both islands render fixture content
(web/src/lib/skeleton-fixtures.ts) while the capture browser is looking, so the result
does not depend on a relay answering. The fixtures load only during a capture and are not
part of the bundle a visitor downloads.
Re-run pnpm bones after changing the layout of a review card or of the prerender miss
summary, including CSS-only changes. Stale bones are not a build error, they just stop
matching the content that replaces them.
Capturing needs a Chromium for Playwright, once per machine:
pnpm --filter ./web exec playwright install --with-deps chromium
Static assets
web/public/ ships as-is: the fonts, the social card and the icon set.
Fonts are self-hosted from web/src/assets/fonts/ — Space Grotesk, Inter and
JetBrains Mono, as the latin and latin-ext woff2 subsets Google Fonts serves, with
the same unicode-range declarations, so rendering is identical to the CDN it replaced.
All three are variable fonts, so six files cover all fourteen faces. The site makes no
third-party request for them, which is both one less thing in the critical path and one
less entry in the list on /privacy. They are SIL Open Font License 1.1; the notice
ships at /fonts/OFL.txt.
They sit in src/assets/ rather than public/ so the build fingerprints them into
/_astro/, which is what makes Cache-Control: immutable honest and puts them under a
cache rule the nginx config already has. Unhashed and uncached they are re-fetched on
every client-side navigation — the router re-inserts their preload links on each swap —
and the typefaces visibly reload from page to page.
The social card and icons (og.png, favicon.ico, favicon.svg,
apple-touch-icon.png, icon-192.png, icon-512.png) are generated once and committed:
node web/scripts/make-assets.mjs
That draws the 1200x630 card in a headless Chromium using the site's own fonts and
tokens, then downsamples one icon master into the rest. Re-run it after a brand change.
It is deliberately not part of pnpm build: the site has to build on a machine with no
browser, and these files change roughly never. It needs the same Chromium pnpm bones
does.
Tests
Unit checks over URL normalization, rating parsing, scoring and the review-dedupe SQL:
pnpm --filter ./api test
The warning banners. Fixture-driven checks over getMintWarnings, the one function that
decides whether a mint page shows "melt only", "withdrawals disabled", "mint frozen" or
one of the three offline tiers. Fixtures are real /v1/info payloads (or a real one with
a single flag flipped, noted in the file) under shared/fixtures/warnings:
pnpm --filter ./api test:warnings
The offline acceptance check. Copies the seeded database, points a healthy mint at a dead URL, probes it until it is marked offline, and asserts the mint page still serves its full cached metadata and reviews:
pnpm --filter ./api test:offline
The copy is made with the migrator, so the check runs against whichever backend holds the
real data and exercises the migration path every time. Point CHECK_DB_URL at a scratch
Postgres database to run the whole thing there — it is emptied first, so give it one of
its own:
CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offline
CHECK_DB_URL does the same for pnpm --filter ./api test, which then runs the
review-dedupe SQL against both backends instead of just SQLite.
On-demand indexing. The address rules that keep POST /api/index from being an SSRF
hole, the redirect hops, the slug collapse that stops one mint becoming two rows, the
invite-code decoder and the rate limiter. Nothing here touches the network: the resolver
and the fetch are both injected, because a rule that can only be exercised against the
real internet stops being exercised the first time CI runs offline.
pnpm --filter ./api test:index
Type checking across the workspace:
pnpm typecheck
Build the site
pnpm build
Output lands in web/dist/. Every page is prerendered once per language, so ~55 mints
and 9 static routes come out as ~200 pages, each with real titles, meta descriptions,
OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full hreflang
set and a JSON-LD graph. sitemap.xml lists every indexable one with its xhtml:link
alternates, and robots.txt sits beside it.
Social cards
pnpm build starts with pnpm og (web/scripts/og/build-og.mjs), which renders one
1200x630 PNG per mint and federation into web/public/og/ from the same API data the
pages use — satori lays the card out and turns the text into glyph paths (the six
static TTFs under web/scripts/og/fonts/ are the same faces the site uses),
@resvg/resvg-js rasterises it. No browser involved; a full run of ~70 cards takes a
few seconds, and a rerun with unchanged data renders nothing: each card's inputs are
hashed into its filename (mint.example.com.a1b2c3d4e5.png) and
web/src/generated/og-manifest.json records what is already on disk. The hashed name
is also the cache-busting story — link-preview scrapers cache an og:image by URL, so
a mint whose rating moved or that went offline gets a new URL on the next scheduled
rebuild, while the stable-named default.png (brand plus network stats, used by every
non-mint page) is served with a one-hour cache instead (see the nginx block below).
The images are one English render shared by every locale; titles, descriptions and alt text translate per page. Relative times stay out of the PNGs on purpose — a "3d ago" would go stale inside a static file — so the cards carry absolute month/year stamps, and the one exception, the "Offline {n}d" chip, is derived from a day-granular clock so it regenerates at most once a day.
pnpm og:fixtures renders the six edge cases in web/scripts/og/fixtures.mjs
(30-character name, no icon, zero reviews, offline, melt only, announced-only
federation) into web/og-fixtures/ at a pinned timestamp; those snapshots are
committed, so eyeball them after any template change.
What is indexed, and what is not
One rule, in web/src/lib/seo.ts, decides it, and it covers both ecosystems: a page is
left out of the index when the site has never once reached the thing it is about
and nobody has reviewed it. Such a page has no name, no description, no version and
no reviews, because all of those come from a mint that answered, a federation a check
confirmed, or a person who wrote something — so every one of them is the same page as the
next. It stays listed on /mints or /fedimints, stays linked, stays searchable on the
site and stays reviewable; it just carries noindex, follow and is absent from the
sitemap, until something answers or someone reviews it.
That rule is why an announced-only federation is usually unindexed: nothing has confirmed it, so until it collects a review its page says no more than the announcement did.
The two detail pages and the sitemap import that one predicate rather than each testing for it,
and pnpm check:hreflang verifies from the built output that they still agree — a page
saying noindex while the sitemap advertises it is a contradiction, and it fails the
build.
Structured data is assembled in web/src/layouts/Base.astro and nowhere else, from the
builders in web/src/lib/schema.ts, so a document cannot end up with two ld+json
blocks or two conflicting @ids. Every page carries Organization, WebSite and
WebPage; mint pages add BreadcrumbList and a Service node whose aggregateRating
appears only when reviews actually carried ratings; /mints and /wallets add an
ItemList. Mint names and descriptions are written by mint operators, so
serializeGraph escapes them for the one context that matters — nothing that could
close or reopen a script element survives it.
Check the build for broken internal links, links to routes that were never emitted, and anchors pointing at ids no page has:
pnpm check:links
It exits non-zero on a problem, so it can gate a deploy. LIST_TARGETS=1 prints every
internal target and which pages link to it. NOTES-PAGES.md holds the last full audit.
Check the i18n and indexing side of the build: <html lang> against the URL's locale, a
self-referencing canonical on every page, a complete and reciprocal hreflang set
including x-default, and a sitemap that lists every indexable page with its alternates
and no page that asked not to be indexed:
pnpm check:hreflang
The catalogs are checked before every build, and separately with:
pnpm check:i18n
Both gate a deploy. See Languages for what they look for.
Languages
English, Spanish and Dutch. English is the site as it was; the other two are the same site, prerendered again.
Routing
Path-prefix locales, with English at the root:
| Language | Home | A mint page |
|---|---|---|
| English | / |
/mint/kashu.me |
| Spanish | /es |
/es/mint/kashu.me |
| Dutch | /nl |
/nl/mint/kashu.me |
Every existing English URL stays exactly where it was, which matters more here than tidiness: those URLs are indexed, linked, and pasted into wallets.
Route slugs stay English in every language: /es/mints, never /es/mentas. This is
a deliberate trade. Translated slugs read marginally better in an address bar and cost a
permanent, growing table of slug-per-route-per-language that every internal link, the
sitemap, the link checker and the switcher have to agree on forever. Stable URLs are
worth more here, and the hreflang set is what actually tells a crawler these are the same
page.
Nothing redirects by language. No Accept-Language sniffing, no IP geolocation. A
crawler asking for /mint/kashu.me gets /mint/kashu.me, and a reader who deliberately
opened the English page is not overruled by a browser setting they configured years ago.
The language control in the header is how you change language, and it lands on the page
you were already reading.
One file per route emits all three: pages live under web/src/pages/[...locale]/, and
localePaths() in web/src/i18n/paths.ts is their getStaticPaths. There is one copy
of each page's markup, translated by t(), rather than three copies to keep in step.
What is and is not translated
Translated: every piece of site copy. Navigation, buttons, labels, filters, empty
states, error states, form copy, tooltips, aria-labels, the static pages, the warning
banners, page titles and meta descriptions, and the plain-language name beside a NUT
number.
Never translated, because it is data rather than copy:
- review text, and the name, NIP-05 and npub of whoever wrote it
- mint names, mint descriptions and MOTDs, in whatever language the operator wrote them
- URLs, hosts, npubs, pubkeys, version strings
- protocol terms:
NUT-04,NIP-87,bunker://, Nostr, Lightning, ecash, sat
A mint's own description is the first sentence of its page's meta description, verbatim.
Only when a mint has published none does the site write that sentence itself.
Adding a fourth language
Four steps, and the build tells you if you miss one.
-
web/src/i18n/GLOSSARY.md— add the column and decide the terms first. This is the step people skip, and it is the one that costs later: a reader who meets two words for "review" has to work out whether they are the same thing. -
web/src/i18n/xx.json— copyen.jsonand translate it. Flat, dotted keys. A key with a count uses.one/.other; a language with more plural categories may add.few,.many,.zero, andIntl.PluralRulespicks between them. Nothing else in the codebase has to know. -
web/src/i18n/config.ts— add the entry toLOCALES:{ code: 'pt', label: 'Português', intl: 'pt-BR', og: 'pt_BR' },labelis the language's own name for itself, and is what the switcher shows.intlis the tagIntlgets, region included, because number and date formatting differ by region even when the language does not. -
web/src/i18n/locales.mjs— add the code toLOCALE_CODES. This is the same list in plain JavaScript, forastro.config.mjsand the checker, both of which run before TypeScript exists.check-i18ncompares the two lists and fails if they disagree, so forgetting this is a build error rather than a mystery.
That is the whole change. The Astro i18n config, the routes, the switcher, the hreflang
sets, the og:locale:alternate list and the sitemap all read from LOCALES and pick the
new language up on the next build.
Two things you do not have to do: dates, times and numbers come from Intl and are right
for the new locale immediately, and relative times fall back to
Intl.RelativeTimeFormat for any unit the new catalog does not tighten with its own
time.ago.* string.
What the build checks
pnpm check:i18n runs before every build (from astro.config.mjs) and on its own. Four
questions:
missing |
a key English has and this locale does not | warning, listed by name |
unknown |
a key a locale has and English does not | error |
undefined |
a t('...') in the source no catalog defines |
error |
client |
an island reaching for a key outside CLIENT_NAMESPACES |
error |
Missing keys are a warning on purpose: they fall back to English, and half a language is
better than no language. Everything else is an error, because each one puts a wrong
string in front of a reader. A key that resolves nowhere at all renders as humanised
words rather than as mint.warnings.meltOnly, so even the unreachable case is not a raw
key on screen.
It also diffs the warning-banner copy against shared/src/warnings.ts. The decision
about which banner a mint gets lives in shared/ and has to stay language-free, so the
English sentences live there too (the API's fixture tests assert on them) and the
catalogs carry the same keys for translation. Two copies of safety copy is exactly the
sort of thing that drifts, so the two are compared on every build.
pnpm check:hreflang runs over dist/ after a build: <html lang> against the URL,
self-referencing canonicals, complete and reciprocal alternate sets, x-default pointing
at English, every alternate resolving to a page that was actually emitted, and a sitemap
entry with alternates for each. The 404 pages are the one exception and are checked for
the opposite: no canonical, no alternates, and a noindex.
How the strings reach an island
The prerendered half of the site is translated at build time and costs a visitor nothing. The islands need their strings in the browser, and the requirement is that a Spanish page ships Spanish and nothing else.
Base.astro inlines the current locale's client-facing keys as one
<script type="application/json" data-i18n> in the body, already resolved against
English. Islands read it through useI18n() in web/src/i18n/client.ts.
The alternative, importing es.json from an island, does not work here: island chunks
are shared across every locale, so anything imported into one is downloaded by all of
them. A dynamic import() keyed on locale would avoid that but costs a round trip on the
critical path of every island and leaves all three catalogs sitting in dist/_astro/.
Inlining travels in HTML the page was sending anyway.
It is in the body rather than the head because the view transition router replaces the body on every navigation. An island reading it after a swap reads the page it is actually on; a head script would be kept and would keep answering in the language the reader arrived in.
CLIENT_NAMESPACES in config.ts decides which namespaces are inlined, and
check-i18n fails the build if an island reaches outside that list.
Configuration
Every variable has a working default, so nothing has to be set to run the site locally. To change any of them, copy the example file and edit it:
cp .env.example .env
One .env at the repo root serves all three packages. The API loads it through node's
own --env-file-if-exists, and the web side through web/scripts/load-env.mjs, imported
at the top of astro.config.mjs. In both, a real environment variable wins over the file,
so a systemd Environment= line or a one-off PORT=9000 pnpm dev:api still overrides it.
.env is gitignored; .env.example is the documented copy.
API (api/)
| Variable | Default | Meaning |
|---|---|---|
PORT |
8787 |
HTTP port |
DATABASE_URL |
empty (SQLite at DB_PATH) |
postgres://… or sqlite:…. See Database. |
DB_PATH |
api/data/cashumints.db |
SQLite file. Ignored when DATABASE_URL is set. |
DB_POOL_MAX |
10 |
Postgres connections held open. Unused by SQLite. |
ICON_DIR |
api/data/icons |
Cached mint icons, served at /icons/* |
RELAYS |
see shared/src/nostr.ts |
Comma separated relay list |
PROBE_INTERVAL_MIN |
10 |
Minutes between probe cycles |
DISCOVERY_INTERVAL_MIN |
60 |
Minutes between discovery cycles |
PROBE_CONCURRENCY |
8 |
Mints probed in parallel |
PROBE_TIMEOUT_MS |
5000 |
Per-mint request timeout |
FEDIMINT_OBSERVER_URL |
https://observer.fedimint.org/api/federations |
Where federation health is read from. Empty disables the lookup, and every federation stays announced. See Ecosystems. |
SCORE_PRIOR_MEAN |
3 |
Bayesian prior. See "Ranking" below before changing. |
INDEX_RATE_LIMIT |
10 |
POST /api/index submissions allowed per address per hour |
Web (web/)
| Variable | Default | Meaning |
|---|---|---|
WEB_PORT |
4321 |
Dev server and preview port |
API_URL |
http://127.0.0.1:8787 |
API origin used at build time |
PUBLIC_API_URL |
empty (same origin) | API origin baked into markup and islands |
SITE_URL |
https://cashumints.space |
Canonical origin for meta tags |
PLAUSIBLE_URL |
https://analytics.azzamo.net/js/script.js |
Analytics script URL; empty disables analytics |
PLAUSIBLE_DOMAIN |
cashumints.space |
Domain reported to analytics; empty disables analytics |
RELAYS |
see shared/src/nostr.ts |
Relays for the build-time review fetch |
BONES_URL |
http://localhost:$WEB_PORT |
Dev server pnpm bones captures against |
PORT and API_URL have to agree: the build and the dev proxy reach the API at
API_URL, so moving the API off 8787 means changing both.
PUBLIC_API_URL deliberately does not fall back to API_URL: that is a build-machine
address, and a visitor's browser cannot reach 127.0.0.1. Left empty, icon src
attributes and island fetches are same-origin paths (/icons/..., /api/...), which the
dev server proxies to API_URL and nginx forwards in production — see "Static site behind
nginx" below.
Set it only when the API answers on its own origin:
API_URL=http://127.0.0.1:8787 PUBLIC_API_URL=https://api.cashumints.space pnpm build
Ecosystems
Two things are listed: Cashu mints at /mints and /mint/{host}, and Fedimint
federations at /fedimints and /fedimint/{slug}. They share one table, one review
pipeline, one review card and one write-review dialog; they differ in how they are
discovered, how they are checked, and what their page can honestly say.
| Cashu | Fedimint | |
|---|---|---|
mints.type |
cashu |
fedimint |
| Announcement | kind:38172 |
kind:38173 |
Review k tag |
38172 |
38173 |
Identity (d) |
the pubkey from /v1/info |
the federation id |
Address (u) |
the mint URL | the invite code (fed11…) |
Row key (mints.url) |
the mint URL | fedimint:<federation id> |
| Routing slug | the hostname | fed- + the first 16 characters of the id |
| Check | GET {url}/v1/info, every 10 min |
see below |
| Capability panel | supported NUTs, from /v1/info |
modules, from the announcement |
| Statuses | online / degraded / offline / unknown | online / offline / announced |
Checking a federation
A Cashu mint answers GET /v1/info over HTTPS, which is why probing one is fifteen
lines. A federation has no such endpoint: its guardians speak a JSON-RPC dialect over
websockets, their addresses are bech32m-encoded inside the invite code, and confirming
one is up means being a Fedimint client — decoding the code, opening sockets to a quorum
and agreeing a consensus session. Half of that would produce a status less trustworthy
than saying nothing.
So the federation check reads fedimint.observer, which
already keeps those client connections open and publishes the result at
/api/federations as { id, name, invite, health }. Two consequences, and the site
carries both rather than hiding them:
- It is somebody else's check. A federation whose status came from there stores
status_source: "fedimint.observer"and its page prints that beside the status, so nothing implies this site opened a socket itself. PointFEDIMINT_OBSERVER_URLsomewhere else, or set it empty to disable the lookup entirely. - It does not cover everything. A federation the observer does not track gets the
announcedstatus: Nostr says it exists, nothing says it runs. It is neveronline, neveroffline, has nolast_online, no uptime figure and no sparkline.announcedsorts between the confirmed-up rows and the confirmed-down ones, because not knowing is not the same as knowing otherwise.
When the observer itself is unreachable, nothing is written at all: a federation confirmed up an hour ago is not demoted because a third party had a bad minute.
What a federation page will not say
There is no Fedimint counterpart to the "melt only", "withdrawals disabled" or "mint
frozen" banners, and shared/src/warnings.ts cannot produce one. Those are read out of a
mint's own NUT-04 and NUT-05 switches; a federation's modules tag says which parts it
runs and nothing about whether any of them is accepting deposits, so the Modules panel
lists them and stops there. A federation gets two banners at most: a real check reported
its guardians down, or it has been announced for a month and nothing has ever confirmed
it.
Adding a third ecosystem
The extension points, in the order you would touch them. Nothing in the review pipeline, the card, the dialog or the feed needs an edit: they are already driven by the values below rather than by a test for Cashu.
- A
typevalue and its announcement kind. One entry inANNOUNCEMENT_KINDS(shared/src/nostr.ts). That alone puts the kind on discovery's subscription list, teaches the review resolver and the/reviewsfeed whichktag belongs to it, and adds its pill to the ecosystem filter.mints.typeis TEXT with no CHECK constraint, so no migration is involved. - Discovery: how to read its announcement. A parser beside
parseFedimintAnnouncementand anupsert…besideupsertFedimint, called fromrunDiscovery. Whatever is type-specific goes inecosystem_json, whichGET /api/mints/:hostspreads across the detail payload; the shared columns (name,description,icon_url,status) are filled the same way for everyone. - A probe strategy. A branch in
probeAll(api/src/probe.ts) and a checker besidefedimint-observer.ts. If there is no reliable public check, use a pseudo-status likeannouncedand record why — do not infer a status from the announcement. - Pages. A list page and a detail page under
web/src/pages/[...locale]/, a…Subject()builder inweb/src/lib/review-subject.ts(which is what makes the reviews panel work unchanged), a route intargetPath(web/src/lib/feed-resolve.ts), nav entries inTopbar.astroandFooter.astro, and sitemap entries insrc/pages/sitemap.xml.ts. - Copy. A namespace in
en.json,es.jsonandnl.json, the namespace added toCLIENT_NAMESPACESif an island renders any of it, and a row inweb/src/i18n/GLOSSARY.mdfor every term the ecosystem introduces.pnpm check:i18nfails the build until all three catalogs have the keys.
Not in scope, deliberately: LNURL. Nothing in the routes, event kinds, schema values or copy refers to it, and it is planned as a later stage rather than half-built now.
API
Five endpoints, CORS open, no auth. Four read; the fifth writes.
| Endpoint | Notes |
|---|---|
GET /api/health |
Never cached. 503 when probes are stale or discovery failed. |
GET /api/stats |
Network counters, memoized 60s in process. |
GET /api/mints |
Everything listed, online first then score descending. ?limit=, ?type=. |
GET /api/mints/:host |
One listing plus its ecosystem's own fields, distribution, uptime and probe history. |
POST /api/index |
Index a mint nobody has announced yet. Rate limited. See below. |
/icons/* serves the cached mint icons.
Indexing on demand
curl -X POST https://cashumints.space/api/index \
-H 'content-type: application/json' \
-d '{"type":"lnurl","input":"mint.600.wtf"}'
type is cashu, fedimint or lnurl; input is a URL, or an invite code for a
federation. The site itself calls this from two places: the 404 page, when somebody opens
/mint/…, /fedimint/… or /lnurl-mint/… for something this build has never heard of,
and the "Write a review" dialog on the three index pages.
What it does, in order, stopping at the first step that settles it:
- Already indexed —
200with the ordinaryGET /api/mints/:hostpayload plus"existing": true. Nothing is probed; a submission is not a reason to re-probe. - Answers as what it claims — one request, the standard
PROBE_TIMEOUT_MS. The row is written by the same functions the probe cycle uses, so it is indistinguishable from one the loop created.201, same payload shape, plus"indexed_from": "probe". - Answers as something else —
422witherror: "wrong_type"anddetected_type, which is what lets the dialog offer "this is a Cashu mint, review it there" as a button. A federation's invite code is decoded rather than fetched: there is no endpoint to probe. - Nothing answers — one bounded relay lookup (3s) for a NIP-87 announcement or any
review naming the address. Found: the row is written from the announcement with
status = offline, which is the mint that rugged last week and is exactly the one somebody wants to review. Nothing anywhere:404,error: "unverifiable".
Everything indexed this way is an ordinary row afterwards: the probe loop owns it from the next cycle.
Because it fetches an address a stranger chose, it is hardened accordingly (api/src/safe-fetch.ts):
https only, DNS resolved and every returned address checked against the private, loopback,
link-local, CGNAT and multicast ranges before a socket opens, redirects followed by hand
and capped at two with every hop re-checked, a 256KB response cap, and a per-IP limit of
INDEX_RATE_LIMIT an hour with a clean 429. Two simultaneous submissions of one address
share a single probe. pnpm --filter ./api test:index covers all of it without a network.
The limit needs to be able to tell two visitors apart, so behind the nginx block below add
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; to location /api/. The
last entry of that header is the one used, because it is the one the proxy vouched for;
the header is ignored entirely when the peer is not loopback.
Ranking
Bayesian weighted rating:
score = (v / (v + m)) * R + (m / (v + m)) * C
v is the review count, R the mint's mean rating, m = 5, and C the prior mean.
Offline mints are multiplied by 0.5 and always sorted below every online mint; mints with
no review in 180 days are multiplied by 0.9.
C defaults to 3, not the observed global mean. BACKEND.md specifies the global mean,
but almost every Cashu review is five stars, so that mean sits near 4.7. Shrinking two
above-prior means toward a prior that high cannot reorder them, it only compresses them,
and the result is that a single five star review outranks 39 considered ones. The neutral
prior is what makes the formula behave as intended. Set SCORE_PRIOR_MEAN=global for the
literal specified behaviour. shared/src/score.ts carries the arithmetic, and
pnpm --filter ./api test asserts both cases.
Deployment
API as a systemd unit
# /etc/systemd/system/cashumints-api.service
[Unit]
Description=cashumints.space indexer and API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cashumints
WorkingDirectory=/srv/cashumints/api
ExecStart=/usr/bin/node --experimental-strip-types src/index.ts
Environment=NODE_ENV=production
Environment=PORT=8787
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service above.
# Keep ICON_DIR either way: cached icons are files, not rows.
Restart=always
RestartSec=5
# The process finishes its in-flight probe batch and closes the database on SIGTERM.
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/cashumints
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now cashumints-api
Static site behind nginx
server {
listen 443 ssl http2;
server_name cashumints.space;
root /srv/cashumints/web;
index index.html;
# Astro emits directory-style routes, so try the directory index before 404.
location / {
try_files $uri $uri/ $uri.html /404.html;
}
# A miss under a language prefix answers in that language. Without these two blocks
# every 404 is the English one, including the ones a Spanish reader reaches by
# following a Spanish link, and the language control on it would take them to a page
# they were not on.
location /es/ {
try_files $uri $uri/ $uri.html /es/404/index.html;
}
location /nl/ {
try_files $uri $uri/ $uri.html /nl/404/index.html;
}
# Fingerprinted assets are immutable. This covers the CSS, the island bundles and the
# webfonts, which are all emitted here with a content hash in the name. Serving the
# fonts without it is visible, not theoretical: the view transition router re-inserts
# the font preload links on every navigation, so an uncached font is re-fetched on
# each page change and the typefaces flicker as they reload.
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The favicons and the manifest. Not fingerprinted, since their names are referenced
# from outside the site, so a week with revalidation rather than a year of immutability.
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
expires 7d;
add_header Cache-Control "public";
}
# The generated social cards (`pnpm og`). The default card keeps a stable name and
# changes with the site's stats, so it gets an hour; every per-mint card carries a
# content hash in its filename and a stale hash is never referenced again, so those
# are immutable. (The hash is also what actually refreshes link previews: the big
# scrapers cache an og:image by URL and ignore these headers.)
location = /og/default.png {
add_header Cache-Control "public, max-age=3600";
}
location /og/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ {
proxy_pass http://127.0.0.1:8787;
# POST /api/index is rate limited per address, and without this every visitor
# arrives as 127.0.0.1 and shares one budget.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
expires 1d;
}
error_page 404 /404.html;
}
Add a location block per language when you add one. The 404 is also the client-side
fallback for a mint discovered since the last build, and it reads the locale off its own
URL, so /es/mint/some-new-mint resolves that mint and renders its summary in Spanish.
API behind nginx, with a micro-cache
/api/mints and /api/stats change at most every few minutes but can be requested by
every visitor at once. A short micro-cache absorbs that without making the data stale.
/api/health is deliberately excluded: it is the endpoint you page on.
proxy_cache_path /var/cache/nginx/cashumints levels=1:2 keys_zone=cashumints:10m
max_size=256m inactive=10m use_temp_path=off;
server {
listen 443 ssl http2;
server_name api.cashumints.space;
location /api/health {
proxy_pass http://127.0.0.1:8787;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
location ~ ^/api/(mints|stats) {
proxy_pass http://127.0.0.1:8787;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
# Serve the previous response while one request refreshes it, so a slow
# backend never becomes a slow page.
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
}
}
Rebuilds
Mint pages are prerendered, so new mints and new review counts appear at the next build. A nightly rebuild is enough; the site stays correct in between because the islands refresh status and reviews at runtime, and an unbuilt mint still resolves through the client-side fallback on the 404 page.
# /etc/systemd/system/cashumints-build.service
[Unit]
Description=Rebuild the cashumints.space static site
[Service]
Type=oneshot
User=cashumints
WorkingDirectory=/srv/cashumints
Environment=API_URL=http://127.0.0.1:8787
Environment=PUBLIC_API_URL=https://api.cashumints.space
Environment=SITE_URL=https://cashumints.space
ExecStart=/usr/bin/pnpm build
ExecStartPost=/usr/bin/rsync -a --delete web/dist/ /srv/cashumints/web/
# /etc/systemd/system/cashumints-build.timer
[Unit]
Description=Nightly cashumints.space rebuild
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now cashumints-build.timer
Licence
See LICENSE.