Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
854 lines
37 KiB
Markdown
854 lines
37 KiB
Markdown
# 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 or newer (uses `--experimental-strip-types`, 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.
|
|
|
|
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.
|
|
|
|
### 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: `<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:
|
|
|
|
```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
|
|
|
|
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-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 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`:
|
|
|
|
```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.
|
|
|
|
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:
|
|
|
|
```bash
|
|
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 |
|
|
| `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/`)
|
|
|
|
| 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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
| 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` | 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.
|
|
|
|
### 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
|
|
|
|
```ini
|
|
# /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
|
|
# 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.
|
|
KillSignal=SIGTERM
|
|
TimeoutStopSec=30
|
|
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
ReadWritePaths=/var/lib/cashumints
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
```bash
|
|
sudo systemctl enable --now cashumints-api
|
|
```
|
|
|
|
### Static site behind nginx
|
|
|
|
```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.
|
|
|
|
```nginx
|
|
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.
|
|
|
|
```ini
|
|
# /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/
|
|
```
|
|
|
|
```ini
|
|
# /etc/systemd/system/cashumints-build.timer
|
|
[Unit]
|
|
Description=Nightly cashumints.space rebuild
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* 03:30:00
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
```
|
|
|
|
```bash
|
|
sudo systemctl enable --now cashumints-build.timer
|
|
```
|
|
|
|
## Licence
|
|
|
|
See `LICENSE`.
|