# 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 20.18 or newer to build and to run what a build produces - Node 22.18 or newer to *develop*: `pnpm dev`, `pnpm seed` and the `api` test scripts run `src/*.ts` through node directly, which needs native type stripping - pnpm 9 or newer `engines.node` is the first of those, not the second, on purpose: it is the floor a deployment has to clear, and a production host should never be told it needs a newer Node than the compiled service actually runs on. ## 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 `web/server.mjs` 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 ``` Three packages in order, and the order is a dependency chain rather than a habit: `shared` emits the types and the warning copy both other packages import, `api` compiles `api/src` to `api/dist`, and `web` prerenders against a running API. Output lands in `api/dist/` and `web/dist/`. ### Live lists The prerendered mint list is a snapshot of what `GET /api/mints` said when the build ran. It used to stay that until the next build, which is why there was a nightly timer: a mint indexed at noon was reviewable at once — the 404 resolver saw to that — and had no card on `/mints` until 03:30, beside cards whose ratings and statuses were equally old. `/mints`, `/fedimints`, `/lnurl-mints` and the home page's three top-six strips now refetch that endpoint once, after paint, and rebuild their grids from the answer. One request per page, no relays involved: card counts have always come from the API's ingested review aggregates, and review *bodies* remain a mint page and `/reviews` concern. **The prerendered cards stay.** They are the first paint, they are what a crawler indexes, and they are the whole page for a reader with no JavaScript — `