2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00
2026-08-20 22:41:25 +02:00

cashumints.space

A Cashu mint explorer and review site. Lists every mint discoverable on the Nostr network, shows each mint's live metadata from its own /v1/info, and surfaces community reviews published as NIP-87 events.

Made by Azzamo.

  Nostr relays                Cashu mints
 (NIP-87 events)               (/v1/info)
       |                            |
       v  discovery (hourly)        v  probe (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, better-sqlite3, nostr-tools.
web/ Astro static site with vanilla TypeScript islands.
shared/ Types, NIP-87 constants, URL normalization, scoring, NUT 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

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

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.

What is indexed, and what is not

One rule, in web/src/lib/seo.ts, decides it: a mint page is left out of the index when the site has never once reached that mint 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 or a person who wrote something — so every one of them is the same page as the next. It stays listed on /mints, stays linked, stays searchable on the site and stays reviewable; it just carries noindex, follow and is absent from the sitemap, until the mint answers once or someone reviews it.

The mint page 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.

  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:

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

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
DB_PATH api/data/cashumints.db SQLite file
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
SCORE_PRIOR_MEAN 3 Bayesian prior. See "Ranking" below before changing.

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

API

Four endpoints, CORS open, no auth.

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 All mints, online first then score descending. Optional ?limit=.
GET /api/mints/:host One mint plus info, NUTs, distribution, uptime and probe history.

/icons/* serves the cached mint icons.

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
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 social card, the icons 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";
  }

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

  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.

S
Description
No description provided
Readme
6.2 MiB
Languages
TypeScript 55.8%
Astro 28.9%
JavaScript 9.5%
CSS 5.8%