Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+200 -27
View File
@@ -1,26 +1,27 @@
# 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.
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
(NIP-87 events) (/v1/info)
| |
v discovery (hourly) v probe (10 min)
+----------------------------------------------+
| api/ Node + Hono + SQLite |
| last-known-good metadata, 4 REST endpoints |
+----------------------------------------------+
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 |
+----------------------------------------------+
+--------------------------------------------------------------+
| web/ Astro, static, prerendered |
+--------------------------------------------------------------+
|
browser <-> relays (review text, signing via NIP-07 or NIP-46)
```
@@ -38,9 +39,9 @@ this repository.
| Path | What it is |
| --------- | -------------------------------------------------------------- |
| `api/` | Indexer and REST API. Hono, better-sqlite3, nostr-tools. |
| `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 names. |
| `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
@@ -76,6 +77,68 @@ the relays, and prints a summary table. Takes about a minute against the live ne
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
@@ -189,6 +252,18 @@ 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:
```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.
Type checking across the workspace:
```bash
@@ -209,15 +284,19 @@ 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.
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.
The mint page and the sitemap import that one predicate rather than each testing for 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.
@@ -421,13 +500,16 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
| Variable | Default | Meaning |
| ------------------------ | --------------------------- | ---------------------------------------------------- |
| `PORT` | `8787` | HTTP port |
| `DB_PATH` | `api/data/cashumints.db` | SQLite file |
| `DATABASE_URL` | empty (SQLite at `DB_PATH`) | `postgres://…` or `sqlite:…`. See [Database](#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](#ecosystems). |
| `SCORE_PRIOR_MEAN` | `3` | Bayesian prior. See "Ranking" below before changing. |
### Web (`web/`)
@@ -456,6 +538,95 @@ 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](https://observer.fedimint.org), 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. Point `FEDIMINT_OBSERVER_URL`
somewhere else, or set it empty to disable the lookup entirely.
- **It does not cover everything.** A federation the observer does not track gets the
`announced` status: Nostr says it exists, nothing says it runs. It is never `online`,
never `offline`, has no `last_online`, no uptime figure and no sparkline. `announced`
sorts 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.
1. **A `type` value and its announcement kind.** One entry in `ANNOUNCEMENT_KINDS`
(`shared/src/nostr.ts`). That alone puts the kind on discovery's subscription list,
teaches the review resolver and the `/reviews` feed which `k` tag belongs to it, and
adds its pill to the ecosystem filter. `mints.type` is TEXT with no CHECK constraint,
so no migration is involved.
2. **Discovery: how to read its announcement.** A parser beside
`parseFedimintAnnouncement` and an `upsert…` beside `upsertFedimint`, called from
`runDiscovery`. Whatever is type-specific goes in `ecosystem_json`, which
`GET /api/mints/:host` spreads across the detail payload; the shared columns
(`name`, `description`, `icon_url`, `status`) are filled the same way for everyone.
3. **A probe strategy.** A branch in `probeAll` (`api/src/probe.ts`) and a checker beside
`fedimint-observer.ts`. If there is no reliable public check, use a pseudo-status like
`announced` and record why — do not infer a status from the announcement.
4. **Pages.** A list page and a detail page under `web/src/pages/[...locale]/`, a
`…Subject()` builder in `web/src/lib/review-subject.ts` (which is what makes the
reviews panel work unchanged), a route in `targetPath` (`web/src/lib/feed-resolve.ts`),
nav entries in `Topbar.astro` and `Footer.astro`, and sitemap entries in
`src/pages/sitemap.xml.ts`.
5. **Copy.** A namespace in `en.json`, `es.json` and `nl.json`, the namespace added to
`CLIENT_NAMESPACES` if an island renders any of it, and a row in
`web/src/i18n/GLOSSARY.md` for every term the ecosystem introduces. `pnpm check:i18n`
fails 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
Four endpoints, CORS open, no auth.
@@ -464,8 +635,8 @@ Four endpoints, CORS open, no auth.
| -------------------- | ------------------------------------------------------------------ |
| `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. |
| `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. |
`/icons/*` serves the cached mint icons.
@@ -509,6 +680,8 @@ 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.