Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user