# 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](https://azzamo.net). ``` 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](#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 22.18 or newer (native TypeScript type stripping, so no build step for the API) - pnpm 9 or newer ## Setup ```bash pnpm install ``` ```bash 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. ```bash 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: ```bash 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`: ```bash 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: ```bash 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](#configuration). ```bash pnpm dev ``` Run them separately if you prefer: ```bash pnpm dev:api ``` ```bash 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](https://github.com/0xGF/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: ```bash 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: ```bash 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: ```bash 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: ```bash 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`: ```bash 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: ```bash 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: ```bash 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. ```bash pnpm --filter ./api test:index ``` Type checking across the workspace: ```bash pnpm typecheck ``` ## Build the site ```bash 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 `@id`s. 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: ```bash 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: `` 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: ```bash pnpm check:hreflang ``` The catalogs are checked before every build, and separately with: ```bash pnpm check:i18n ``` Both gate a deploy. See [Languages](#languages) for what they look for. ## Languages Twenty-four, listed in `web/src/i18n/config.ts`. English is the site as it was; the other twenty-three are the same site, prerendered again, 84 pages each. English, Spanish and Dutch are hand-written. The other twenty-one began as bulk machine translation, and they are in two states. **German, Danish, Swedish, Indonesian, Vietnamese and Turkish** have had a full terminology pass: every string that names a mint, and every label, title and meta description on the home page, the mint list and a mint page. The machine had translated the site's central noun into the local word for a coin factory (`Münzprägeanstalt`, `mincovna`, `darphane`) or, in the Germanic languages, into the *sweet*: Danish and Swedish shipped `Cashu-pastiller`, "Cashu lozenges", and Swedish offered to sort your `minttabletter`. Indonesian had `permen`, Vietnamese `cây mint`, the mint plant. Those six now say `mint`, and `sats`, `ecash` and `Lightning` survive untranslated in them as the glossary requires. **The other fifteen** still carry that damage in their body copy, in the same shapes: Czech `mincovny`, Polish `mennice`, Romanian `monetării`, Greek `νομισματοκοπείο`. Their chrome, titles and meta descriptions are repaired, so what a reader meets first is right, but the prose underneath is not. Roughly 400 strings, concentrated in the four inflected languages where a word-level fix needs case-correct edits inside running sentences, which is a native speaker's job rather than a careful search and replace. `web/src/i18n/GLOSSARY.md` is where a pass starts, and the house rules at the top of it hold in every language, particularly the first row of the table. ### 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 every locale: 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 twenty-four 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-label`s, 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 language Five steps. Four of them the build tells you about; the fifth it cannot, which is why it has a test of its own. 1. **`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. 2. **`web/src/i18n/xx.json`** — copy `en.json` and translate it. Flat, dotted keys. A key with a count uses `.one` / `.other`; a language with more plural categories may add `.few`, `.many`, `.zero`, and `Intl.PluralRules` picks between them. Nothing else in the codebase has to know. 3. **`web/src/i18n/config.ts`** — add the entry to `LOCALES`: ```ts { code: 'pt', label: 'Português', intl: 'pt-BR', og: 'pt_BR' }, ``` `label` is the language's own name for itself, and is what the switcher shows. `intl` is the tag `Intl` gets, region included, because number and date formatting differ by region even when the language does not. 4. **`web/src/i18n/locales.mjs`** — add the code to `LOCALE_CODES`. This is the same list in plain JavaScript, for `astro.config.mjs` and the checker, both of which run before TypeScript exists. `check-i18n` compares the two lists and fails if they disagree, so forgetting this is a build error rather than a mystery. 5. **`web/src/i18n/index.ts`** — `import pt from './pt.json'` and add `pt: pt as Catalog` to `CATALOGS`. This is the step to get right, because it is the only one nothing downstream complains about: `catalogFor()` falls back to English by design, so a locale listed in `LOCALES` with no catalog behind it does not fail the build. It prerenders the whole English site under the new `lang`, the new URL prefix and a full reciprocal hreflang set, and tells every crawler those pages are a different language. Twenty-one locales shipped in exactly that state once. `pnpm test` now fails on it: see `web/test/i18n-wiring.test.mjs`. 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: `` 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 `