diff --git a/.env.example b/.env.example index 08e4928..0d48460 100644 --- a/.env.example +++ b/.env.example @@ -38,16 +38,45 @@ PUBLIC_API_URL= # Canonical origin for canonical links, OpenGraph tags and the sitemap. SITE_URL=https://cashumints.space -# ─── API storage ───────────────────────────────────────────────────────────── -# Unset means api/data/, resolved from the source tree regardless of where you run -# from. Set these and they are taken as given: a relative path resolves against the -# working directory (api/ under every pnpm script), so prefer absolute paths. In -# production point both at a directory the service user owns. +# Whether a rated mint's JSON-LD also carries the review-snippet-eligible Product +# type next to Service (web/src/lib/schema.ts explains the trade). On by default; +# set to 0 to ship the plain Service node on the next build, no code change needed. +SEO_PRODUCT_JSONLD=1 -# SQLite file. +# ─── API storage ───────────────────────────────────────────────────────────── +# The API serves everything from its own database and nothing from a mint or a relay at +# request time, so a restart loses nothing: mint metadata, cached /v1/info payloads, +# reviews, probe history and the discovery cursor all live here. Icons live next to it +# as files. In production point both at a directory the service user owns. + +# Which database. Unset means SQLite at DB_PATH below, which is what every existing +# deployment has and needs no setup at all. Set it to a postgres:// URL to use Postgres +# instead — put the API on one host and the database on another, run more than one API +# process, or fold the data into an existing backup and replication setup. +# +# Both backends create their tables on the first connection, so an empty database is +# the whole installation. To carry existing data across, in either direction: +# +# pnpm --filter ./api migrate --to postgres://user:pw@localhost:5432/cashumints +# +# then set DATABASE_URL to the same value and restart. Stop the API first: migrating +# from a database that is still being written to copies a moving target. +# +# DATABASE_URL=postgres://cashumints:secret@localhost:5432/cashumints +# DATABASE_URL=sqlite:/var/lib/cashumints/cashumints.db + +# SQLite file. Ignored when DATABASE_URL is set. Unset means api/data/, resolved from +# the source tree regardless of where you run from. Set it and it is taken as given: a +# relative path resolves against the working directory (api/ under every pnpm script), +# so prefer an absolute path. # DB_PATH=/var/lib/cashumints/cashumints.db -# Cached mint icons, served at /icons/*. +# Postgres connections held open. Ignored by SQLite, which has exactly one. The default +# of 10 is well above what one indexer plus the read endpoints need. +# DB_POOL_MAX=10 + +# Cached mint icons, served at /icons/*. Files, not rows — they are kept here whichever +# database is in use, and `migrate` does not touch them. # ICON_DIR=/var/lib/cashumints/icons # ─── Nostr relays ──────────────────────────────────────────────────────────── @@ -99,9 +128,22 @@ DISCOVERY_INTERVAL_MIN=60 # Mints probed in parallel. PROBE_CONCURRENCY=8 -# Per-mint request timeout, milliseconds. +# Per-mint request timeout, milliseconds. Cashu mints only: a federation is not +# fetched directly, see below. PROBE_TIMEOUT_MS=5000 +# ─── Fedimint ──────────────────────────────────────────────────────────────── +# A federation has no HTTP status endpoint of its own — confirming one is up means +# being a Fedimint client — so its status is read from an outside checker instead, +# once per probe cycle for every federation at once. Every row whose status came +# from here records that fact and its page prints it, so nothing implies this site +# opened a socket itself. +# +# Empty disables the lookup entirely: every federation then carries the `announced` +# status, which is the honest one for "nothing checks this". It is never inferred +# to be online or offline. See the "Ecosystems" section of README.md. +FEDIMINT_OBSERVER_URL=https://observer.fedimint.org/api/federations + # ─── Ranking ───────────────────────────────────────────────────────────────── # Bayesian prior mean C. The neutral 3 is intentional — read the "Ranking" section diff --git a/.gitignore b/.gitignore index 5da4566..ac50c20 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ api/data/ *.log .DS_Store .env +# generated social images and their manifest (pnpm og); og-fixtures/ stays committed +web/public/og/ +web/src/generated/ +web/scripts/og/fonts/cache/ diff --git a/README.md b/README.md index 53bfcdb..8549399 100644 --- a/README.md +++ b/README.md @@ -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 @@ -207,17 +282,47 @@ OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link` alternates, and `robots.txt` sits beside it. +### Social cards + +`pnpm build` starts with `pnpm og` (`web/scripts/og/build-og.mjs`), which renders one +1200x630 PNG per mint and federation into `web/public/og/` from the same API data the +pages use — satori lays the card out and turns the text into glyph paths (the six +static TTFs under `web/scripts/og/fonts/` are the same faces the site uses), +`@resvg/resvg-js` rasterises it. No browser involved; a full run of ~70 cards takes a +few seconds, and a rerun with unchanged data renders nothing: each card's inputs are +hashed into its filename (`mint.example.com.a1b2c3d4e5.png`) and +`web/src/generated/og-manifest.json` records what is already on disk. The hashed name +is also the cache-busting story — link-preview scrapers cache an `og:image` by URL, so +a mint whose rating moved or that went offline gets a new URL on the next scheduled +rebuild, while the stable-named `default.png` (brand plus network stats, used by every +non-mint page) is served with a one-hour cache instead (see the nginx block below). + +The images are one English render shared by every locale; titles, descriptions and alt +text translate per page. Relative times stay out of the PNGs on purpose — a "3d ago" +would go stale inside a static file — so the cards carry absolute month/year stamps, +and the one exception, the "Offline {n}d" chip, is derived from a day-granular clock so +it regenerates at most once a day. + +`pnpm og:fixtures` renders the six edge cases in `web/scripts/og/fixtures.mjs` +(30-character name, no icon, zero reviews, offline, melt only, announced-only +federation) into `web/og-fixtures/` at a pinned timestamp; those snapshots are +committed, so eyeball them after any template change. + ### 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 +526,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 +564,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:` | +| 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 +661,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 +706,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. @@ -566,14 +765,27 @@ server { 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. + # The favicons 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"; } + # The generated social cards (`pnpm og`). The default card keeps a stable name and + # changes with the site's stats, so it gets an hour; every per-mint card carries a + # content hash in its filename and a stale hash is never referenced again, so those + # are immutable. (The hash is also what actually refreshes link previews: the big + # scrapers cache an og:image by URL and ignore these headers.) + location = /og/default.png { + add_header Cache-Control "public, max-age=3600"; + } + + location /og/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + # 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/ { diff --git a/api/package.json b/api/package.json index ad173f7..0a4829a 100644 --- a/api/package.json +++ b/api/package.json @@ -7,6 +7,7 @@ "dev": "node --env-file-if-exists=../.env --experimental-strip-types --watch src/index.ts", "start": "node --env-file-if-exists=../.env --experimental-strip-types src/index.ts", "seed": "node --env-file-if-exists=../.env --experimental-strip-types src/seed.ts", + "migrate": "node --env-file-if-exists=../.env --experimental-strip-types src/migrate.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --env-file-if-exists=../.env --experimental-strip-types src/check.ts", "test:warnings": "node --env-file-if-exists=../.env --experimental-strip-types src/check-warnings.ts", @@ -17,11 +18,13 @@ "@hono/node-server": "^1.13.7", "better-sqlite3": "^11.7.0", "hono": "^4.6.14", - "nostr-tools": "^2.10.4" + "nostr-tools": "^2.10.4", + "pg": "^8.13" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.2", + "@types/pg": "^8.23.1", "typescript": "^5.6.3" } } diff --git a/api/src/check-offline.ts b/api/src/check-offline.ts index 47c4aae..44255a5 100644 --- a/api/src/check-offline.ts +++ b/api/src/check-offline.ts @@ -8,44 +8,62 @@ * a test that actually kills a mint rather than a unit test of a helper. * * Runs against a throwaway copy of the seeded database, so the real one is untouched. + * The copy is made with the migrator, which means the check works whichever backend + * holds the real data — and, as a side effect, exercises the migrator on every run. + * + * Set CHECK_DB_URL to run the copy on Postgres instead of a temporary SQLite file: + * + * CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offline */ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { parseDbTarget, resolveDbConfig } from './config.ts'; +import { TABLES } from './db-schema.ts'; +import { openDb } from './db.ts'; +import { migrate } from './migrate.ts'; -const source = process.env['DB_PATH'] ?? path.resolve(import.meta.dirname, '..', 'data', 'cashumints.db'); -if (!fs.existsSync(source)) { - console.error(`No seeded database at ${source}. Run "pnpm seed" first.`); +const source = resolveDbConfig(); +if (source.dialect === 'sqlite' && !fs.existsSync(source.file)) { + console.error(`No seeded database at ${source.file}. Run "pnpm seed" first.`); process.exit(1); } -// Point the whole process at a copy before anything opens the real database. const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-')); -const copy = path.join(scratch, 'test.db'); -fs.copyFileSync(source, copy); -process.env['DB_PATH'] = copy; +const target = parseDbTarget(process.env['CHECK_DB_URL'] ?? path.join(scratch, 'test.db')); + +// A reused Postgres test database still holds the last run's rows, and a stale mint row +// would be picked as the victim. Start empty either way. +const wipe = await openDb(target); +for (const table of TABLES) await wipe.run(`DELETE FROM ${table}`); +await wipe.close(); + +await migrate({ from: source, to: target, force: true, dryRun: false }); + +// Point the whole process at the copy before anything else opens a database. +process.env['DATABASE_URL'] = target.dialect === 'postgres' ? target.url : `sqlite:${target.file}`; +delete process.env['DB_PATH']; const { getDb, closeDb } = await import('./db.ts'); const { probeMint } = await import('./probe.ts'); -const { getMintDetail } = await import('./queries.ts'); +const { getMintDetail, listMints, resetStatsCache } = await import('./queries.ts'); const { mintByUrl } = await import('./mints.ts'); -const db = getDb(); +const db = await getDb(); +assert.equal(db.label, target.label, 'the check must not be talking to the real database'); // Pick a mint that is online and has real cached metadata to lose. -const victim = db - .prepare( - `SELECT * FROM mints - WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL - ORDER BY LENGTH(info_json) DESC LIMIT 1`, - ) - .get() as { url: string; host: string; name: string } | undefined; +const victim = await db.get<{ url: string; host: string; name: string }>( + `SELECT url, host, name FROM mints + WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL + ORDER BY LENGTH(info_json) DESC LIMIT 1`, +); assert.ok(victim, 'seeded database has no online mint with cached metadata'); console.log(`victim: ${victim.name} (${victim.host})`); -const before = getMintDetail(victim.host); +const before = await getMintDetail(victim.host); assert.ok(before, 'detail must exist before the mint dies'); assert.equal(before.status, 'online'); assert.ok(before.info, 'must have cached /v1/info before'); @@ -53,18 +71,20 @@ assert.ok(before.nuts.length > 0, 'must have parsed NUTs before'); // Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test. const DEAD = 'https://this-mint-is-gone.invalid'; -db.prepare('UPDATE mints SET url = ? WHERE host = ?').run(DEAD, victim.host); -db.prepare('UPDATE reviews SET mint_url = ? WHERE mint_url = ?').run(DEAD, victim.url); +await db.run('UPDATE mints SET url = ? WHERE host = ?', DEAD, victim.host); +await db.run('UPDATE reviews SET mint_url = ? WHERE mint_url = ?', DEAD, victim.url); // Three failures is the offline threshold. for (let i = 0; i < 3; i++) { - const row = mintByUrl(DEAD); + const row = await mintByUrl(DEAD); assert.ok(row, 'row must survive the URL change'); const result = await probeMint(row); assert.equal(result.ok, false, 'a dead URL must not probe ok'); } -const after = getMintDetail(victim.host); +resetStatsCache(); + +const after = await getMintDetail(victim.host); assert.ok(after, 'the mint page must still resolve when the mint is dead'); assert.equal(after.status, 'offline', 'three failures means offline'); @@ -86,17 +106,17 @@ assert.ok(after.last_online !== null, 'last_online must be set so the banner has assert.ok(after.last_online <= Math.floor(Date.now() / 1000)); // And it must sink below every online mint without disappearing. -const { listMints } = await import('./queries.ts'); -const list = listMints(); +const list = await listMints(); const index = list.findIndex((m) => m.host === victim.host); assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it'); const lastOnline = list.map((m) => m.status).lastIndexOf('online'); assert.ok(index > lastOnline, 'offline mints rank below every online mint'); -closeDb(); +await closeDb(); fs.rmSync(scratch, { recursive: true, force: true }); console.log( - `ok, offline mint still serves ${after.nuts.length} NUTs, ${after.review_count} reviews, ` + - `and full cached metadata (offline since ${new Date(after.last_online * 1000).toISOString()})`, + `ok on ${db.dialect}, offline mint still serves ${after.nuts.length} NUTs, ` + + `${after.review_count} reviews, and full cached metadata ` + + `(offline since ${new Date(after.last_online * 1000).toISOString()})`, ); diff --git a/api/src/check.ts b/api/src/check.ts index d6ec93b..c0120b0 100644 --- a/api/src/check.ts +++ b/api/src/check.ts @@ -6,8 +6,18 @@ * it throws on the first failure and prints a count on success. */ import assert from 'node:assert/strict'; -import { bayesianScore, compareMints, NEUTRAL_PRIOR_MEAN, normalizeMintUrl, parseNuts, parseLimits, parseRating } from '@cashumints/shared'; +import path from 'node:path'; +import { + bayesianScore, cleanInviteCodes, compareMints, ecosystemForKind, fedimintKey, fedimintSlug, + federationIdFromKey, getMintWarnings, hasModule, NEUTRAL_PRIOR_MEAN, normalizeMintUrl, + normalizeNetwork, parseFedimintAnnouncement, parseLimits, parseModules, parseNuts, parseRating, + reviewEcosystem, +} from '@cashumints/shared'; +import { parseDbTarget } from './config.ts'; +import { openDb } from './db.ts'; +import { toPgPlaceholders } from './db-postgres.ts'; import { statusForFails } from './probe.ts'; +import { LATEST_REVIEWS } from './queries.ts'; let checks = 0; function check(name: string, fn: () => void): void { @@ -185,31 +195,244 @@ check('limits come from NUT-04 methods', () => { assert.equal(parseLimits(undefined), null); }); -// The dedupe rule lives in SQL, so exercise it against a real in-memory database -// using the same statement the API uses. -check('one review per author per mint, newest wins', async () => { - const { default: Database } = await import('better-sqlite3'); - const db = new Database(':memory:'); - db.exec(`CREATE TABLE reviews (event_id TEXT PRIMARY KEY, mint_url TEXT, pubkey TEXT, - rating INTEGER, created_at INTEGER)`); - const insert = db.prepare('INSERT INTO reviews VALUES (?,?,?,?,?)'); - insert.run('e1', 'https://m', 'alice', 1, 100); - insert.run('e2', 'https://m', 'alice', 5, 200); // alice changed her mind - insert.run('e3', 'https://m', 'bob', 3, 150); +/* ---------- fedimint ---------- */ - const rows = db - .prepare( - `SELECT pubkey, rating FROM ( - SELECT pubkey, rating, ROW_NUMBER() OVER ( - PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id) rn - FROM reviews) WHERE rn = 1 ORDER BY pubkey`, - ) - .all() as { pubkey: string; rating: number }[]; +/** + * A real kind 38173 from the relay pool, kept verbatim. + * + * Three things in it disagree with a plain reading of NIP-87 and are exactly why this + * fixture is a copy rather than something written to match the spec: `n` is `bitcoin` + * and not `mainnet`, `modules` are short names and not prose, and `content` carries + * `federation_name` rather than a kind-0 `name`. + */ +const REAL_ANNOUNCEMENT = { + id: 'f1', + pubkey: '141d2053cb29535ad45aa9e865cdec492524f0ec0066496b98b7099daab5d658', + kind: 38173, + created_at: 1_756_224_071, + content: '{"federation_name":"E-Cash Club","meta_external_url":"https://fm.ctrb.io/meta.json"}', + tags: [ + ['d', 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21'], + [ + 'u', + 'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' + + 'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg', + ], + ['n', 'bitcoin'], + ['modules', 'ln,mint,wallet,meta'], + ], +}; - assert.equal(rows.length, 2, 'alice must count once'); - assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, 'newest review wins'); - db.close(); +check('a real fedimint announcement parses into the fields the pages render', () => { + const a = parseFedimintAnnouncement(REAL_ANNOUNCEMENT); + assert.ok(a, 'the announcement every other check builds on must parse'); + assert.equal(a.federationId, 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21'); + assert.equal(a.inviteCodes.length, 1); + assert.deepEqual(a.modules, ['ln', 'mint', 'wallet', 'meta']); + // `bitcoin` on the wire is `mainnet` here, so one federation cannot appear on two + // networks depending on which word its operator used. + assert.equal(a.network, 'mainnet'); + assert.equal(a.name, 'E-Cash Club'); + assert.equal(a.announcerPubkey, REAL_ANNOUNCEMENT.pubkey); }); -// The last check is async, so report after the microtask queue drains. -queueMicrotask(() => console.log(`ok, ${checks} checks passed`)); +check('an invite code survives the validator that first rejected every real one', () => { + // `fed1` is the human-readable part and the `1` after it is bech32's separator, so + // every real code starts `fed11`. Anchoring on a bech32 data character after `fed1` + // rejected all fifteen announcements on the network. + const code = REAL_ANNOUNCEMENT.tags[1]?.[1] ?? ''; + assert.deepEqual(cleanInviteCodes([code]), [code]); + assert.deepEqual(cleanInviteCodes([code, code.toUpperCase()]), [code], 'deduped case-insensitively'); + assert.deepEqual(cleanInviteCodes(['https://mint.example.com', 'fed1', '']), []); +}); + +check('an announcement with no invite code is not a federation anyone can join', () => { + // This is a real event too: someone published a review as a 38173. It has a valid + // `d` and nothing to join, so there is no row to make from it. + assert.equal( + parseFedimintAnnouncement({ + id: 'x', pubkey: 'p', kind: 38173, created_at: 1, content: '[5/5] test', + tags: [['d', '412d2a9338ebeee5957382eb06eac07fa5235087b5a7d5d0a6e18c635394e9ed'], ['n', 'mainnet'], ['k', '38173']], + }), + null, + ); +}); + +check('the fedimint slug and its key round trip', () => { + const id = 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21'; + assert.equal(fedimintSlug(id), 'fed-aeca6cc80ffc530b'); + assert.equal(fedimintSlug(id).length, 'fed-'.length + 16); + assert.equal(federationIdFromKey(fedimintKey(id)), id); + // A mint URL is not a federation key, and must never be read as one. + assert.equal(federationIdFromKey('https://mint.example.com'), null); +}); + +check('versioned modules still satisfy the named rows', () => { + // A federation running lnv2 and no ln can do Lightning. A row reading "not + // supported" beside an lnv2 chip would be false. + const modules = parseModules('lnv2,mintv2,walletv2,meta'); + assert.ok(hasModule(modules, 'lightning')); + assert.ok(hasModule(modules, 'mint')); + assert.ok(hasModule(modules, 'wallet')); + assert.ok(!hasModule(parseModules('meta'), 'lightning')); +}); + +check('both spellings of mainnet arrive as one network', () => { + assert.equal(normalizeNetwork('bitcoin'), 'mainnet'); + assert.equal(normalizeNetwork('mainnet'), 'mainnet'); + assert.equal(normalizeNetwork('signet'), 'signet'); + assert.equal(normalizeNetwork(''), null); + assert.equal(normalizeNetwork(' diff --git a/web/src/components/MintCard.astro b/web/src/components/MintCard.astro index 528bfc0..ccc333a 100644 --- a/web/src/components/MintCard.astro +++ b/web/src/components/MintCard.astro @@ -1,5 +1,8 @@ --- -import { getMintWarnings, mintChip, type MintCapabilities, type MintListItem } from '@cashumints/shared'; +import { + federationIdFromSlug, getMintWarnings, mintChip, + type MintCapabilities, type MintListItem, +} from '@cashumints/shared'; import { iconUrl } from '../lib/api'; import { displayDomain, iconGradient, initials, transitionName } from '../lib/format'; import { localePath, useI18n } from '../i18n'; @@ -33,10 +36,28 @@ const { locale } = pageLocale(Astro); const t = useI18n(locale); const f = formatters(t); -const domain = displayDomain(mint.url); -const name = mint.name ?? domain.split('/')[0] ?? domain; +/* + * One card, both ecosystems. + * + * A federation has no URL, so the second line cannot be a domain: `fedimint:aeca6c…` + * is a database key and means nothing to a reader. It shows the federation id instead, + * shortened, which is the thing that actually identifies it — and which is what the + * search box below matches on, so pasting an id finds its federation. + * + * Everything else on the card is identical, and deliberately: rating, review count, + * sentiment bar and status all mean the same thing for both, and a reader moving + * between /mints and /fedimints should not have to re-learn a card. + */ +const fedimint = mint.type === 'fedimint'; +const domain = fedimint ? federationIdFromSlug(mint.host) : displayDomain(mint.url); +const name = fedimint + ? mint.name ?? t('card.unnamedFederation') + : mint.name ?? domain.split('/')[0] ?? domain; const icon = iconUrl(mint.icon); const offline = mint.status === 'offline'; +/** Announced on Nostr and never confirmed by any check. Federations only. */ +const announced = mint.status === 'announced'; +const href = localePath(`${fedimint ? '/fedimint' : '/mint'}/${mint.host}`, locale); /* Two words at most. Offline already has its own card styling, so only the states a @@ -87,7 +108,7 @@ const bar = */} {name} - {domain} + {domain} {rank !== undefined && #{rank}} @@ -164,13 +185,20 @@ const bar = {chip && {chip.label}} { - offline - ? mint.last_online - ? t('card.lastSeen', { when: f.relative(mint.last_online) }) - : t('card.neverSeen') - : mint.last_review_at - ? t('card.reviewed', { when: f.relative(mint.last_review_at) }) - : t('card.noReviews') + /* + "Announced" has no "last seen" to print, and printing one anyway is exactly + the fake status this site refuses to show. What it has instead is the fact + that nothing has checked it, said in as many words. + */ + announced + ? t('card.notChecked') + : offline + ? mint.last_online + ? t('card.lastSeen', { when: f.relative(mint.last_online) }) + : t('card.neverSeen') + : mint.last_review_at + ? t('card.reviewed', { when: f.relative(mint.last_review_at) }) + : t('card.noReviews') } diff --git a/web/src/components/MintLive.astro b/web/src/components/MintLive.astro index 6e40420..c34727d 100644 --- a/web/src/components/MintLive.astro +++ b/web/src/components/MintLive.astro @@ -10,6 +10,12 @@ * The banner, the limits cell and the NUT rows are rebuilt from lib/mint-state.ts, * the same code that prerendered them, so a live upgrade can never leave the three * disagreeing with each other. + * + * Both a mint page and a federation page mount this, unchanged. It fetches the same + * endpoint for both and updates whichever of the four regions the page actually has: + * a federation has no limits cell and no NUT rows, and a mint has no modules panel, so + * each lookup simply misses. `getMintWarnings` branches on the `type` in the payload, + * so a fresh read can never put a Cashu banner on a federation. */ interface Props { host: string; @@ -23,6 +29,7 @@ const { host } = Astro.props; import { getMintWarnings, readCapabilities, type MintWarning } from '@cashumints/shared'; import { apiBase } from '../lib/client'; import { bannerHtml, limitsHtml, nutRowsHtml } from '../lib/mint-state'; + import { moduleRowsHtml } from '../lib/fedimint-ui'; import { useI18n } from '../i18n/client'; import { formatters, type Formatters } from '../i18n/format'; import { statusLabel, warningOptions } from '../i18n/mint'; @@ -77,8 +84,20 @@ const { host } = Astro.props; else head.after(banner); } - /** Keep the two places that repeat the banner's facts in step with it. */ + /** Keep the places that repeat the banner's facts in step with it. */ function renderCapabilities(mint: MintDetail, f: Formatters): void { + /* + * A federation's modules panel, rebuilt from a fresh announcement. Handled before + * the NUT work rather than after it because the two are mutually exclusive: the + * element only exists on a federation page, and `readCapabilities` on a payload + * with no `info` would be reading NUT switches that federations do not have. + */ + const modules = document.querySelector('[data-live-modules]'); + if (modules) { + modules.innerHTML = moduleRowsHtml(mint.modules ?? [], f); + return; + } + const caps = readCapabilities(mint.info?.nuts); const limits = document.querySelector('[data-live-limits]'); @@ -112,7 +131,7 @@ const { host } = Astro.props; const chip = document.querySelector('[data-live-status]'); const label = document.querySelector('[data-live-status-label]'); if (chip && label) { - chip.classList.remove('online', 'offline', 'degraded', 'unknown'); + chip.classList.remove('online', 'offline', 'degraded', 'unknown', 'announced'); chip.classList.add(mint.status); label.textContent = statusLabel(mint.status, t); @@ -127,8 +146,15 @@ const { host } = Astro.props; renderBanner(mint, f); renderCapabilities(mint, f); + /* + * The "checked" line, on a mint page only. + * + * A federation's equivalent cell says who confirmed it as well as when, in two + * elements rather than one string, and is left to the page: rewriting it from + * here would mean this island owning a second wording it cannot see. + */ const checked = document.querySelector('[data-live-checked]'); - if (checked) { + if (checked && mint.type !== 'fedimint') { checked.textContent = mint.status !== 'offline' ? t('mint.checked', { when: f.relative(mint.last_probe) }) : mint.last_online ? t('mint.lastSeen', { when: f.relative(mint.last_online) }) diff --git a/web/src/components/Reviews.astro b/web/src/components/Reviews.astro index d413ad7..886185f 100644 --- a/web/src/components/Reviews.astro +++ b/web/src/components/Reviews.astro @@ -5,8 +5,13 @@ * Prerendered with the counts the API already knows, so the panel occupies its final * space and shows real numbers with JavaScript off. Review bodies live on Nostr, so * the list itself hydrates from relays on load. + * + * Used by both a mint page and a federation page, unchanged. What is being reviewed + * arrives as a `ReviewSubject` — which `k`, which `d`, which `u` — and nothing below + * this line asks which ecosystem produced it. See `lib/review-subject.ts`. */ -import type { MintDetail } from '@cashumints/shared'; +import type { RatingDistribution } from '@cashumints/shared'; +import type { ReviewSubject } from '../lib/review-subject'; import { renderBones } from 'boneyard-js'; import reviewsBones from '../bones/reviews-panel.bones.json'; import { BONE_COLOR } from '../lib/skeleton'; @@ -15,15 +20,33 @@ import { formatters } from '../i18n/format'; import { pageLocale } from '../i18n/paths'; interface Props { - mint: MintDetail; + /** What is being reviewed: the tags a review carries and the filters that find them. */ + subject: ReviewSubject; + /** The counts the API already knows, prerendered so the panel is honest without JS. */ + reviewCount: number; + distribution: RatingDistribution; + /** Named in the write dialog's heading. The thing's own name, never translated. */ + name: string; } -const { mint } = Astro.props; +const { subject, reviewCount, distribution, name } = Astro.props; const { locale } = pageLocale(Astro); const t = useI18n(locale); const f = formatters(t); -const dist = mint.rating_distribution; +const dist = distribution; + +/* + * The placeholder asks about the thing being reviewed, which is the one sentence in + * this panel that cannot be ecosystem-neutral without going vague: "how did minting and + * melting work out" means nothing about a federation, and "how did it go" means nothing + * about anything. One key per ecosystem, falling back to the Cashu wording for a type + * whose own has not been written yet. + */ +const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject.type}`; +const bodyPlaceholder = t.has(placeholderKey) + ? t(placeholderKey) + : t('reviews.dialog.bodyPlaceholder.cashu'); const critical = dist['1'] + dist['2']; const fiveStar = dist['5']; @@ -44,7 +67,7 @@ const boneBlocks = Object.entries(reviewsBones.breakpoints) // Bones stand in for reviews the API has already counted. With none counted there is // nothing to wait for, and the empty state below is the truthful thing to prerender. -const showBoneBlocks = mint.review_count > 0 && boneBlocks.length > 0; +const showBoneBlocks = reviewCount > 0 && boneBlocks.length > 0; /** * Without JavaScript the relays are never queried, so the bones would sit there for @@ -75,9 +98,7 @@ function escapeText(value: string): string { aria-labelledby="reviews-title" data-reveal data-reviews-panel - data-mint-url={mint.url} - data-mint-pubkey={mint.pubkey ?? ''} - data-mint-name={mint.name ?? mint.host} + data-subject={JSON.stringify(subject)} >

{t('reviews.title')}

@@ -85,7 +106,7 @@ function escapeText(value: string): string { NIP-87
+ ) + } + +
+
+ + + + {/* + Three cells, not four. Rating and activity mean exactly what they mean on a mint + page; the third says who last confirmed this federation and when, which is the + question the software and limits cells cannot be asked here. + */} +
+

{t('mint.summary')}

+ +
+
{t('mint.rating.label')}
+
+
+ { + federation.rating_avg === null ? ( + <> +
{t('time.none')}
+
{t('mint.rating.none')}
+ + ) : ( + <> +
+ {f.decimal(federation.rating_avg)} +
+ +
+ {t('mint.rating.reviews', { n: federation.review_count })} + {unrated > 0 && <>
{t('mint.rating.unrated', { n: unrated })}} +
+ + ) + } +
+ +
+
+ +
+
{t('mint.activity.label')}
+
{f.shortDuration(federation.last_review_at)}
+
+ {federation.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')} + {federation.reviews_90d > 0 && ( + <>
{t('mint.activity.recent', { n: federation.reviews_90d })} + )} +
+ {/* + The sparkline draws real check results and nothing else. A federation nothing + checks has no probe rows at all, so it renders empty rather than flat — and an + empty strip is the honest picture of no data. + */} + +
+ +
+
{t('fedimint.confirmed.label')}
+
+ { + /* + The one cell on this page that could most easily lie, so it is the most + carefully worded. "Announced" gets no date at all beyond the announcement's + own, and says outright that nothing has checked. A confirmed federation + names who confirmed it, because this site did not: it read fedimint.observer. + */ + announced ? ( + <> + {t('fedimint.confirmed.never')}
+ + {federation.announced_at + ? t('fedimint.announcedOn', { when: f.relative(federation.announced_at) }) + : t('fedimint.announcedUnknown')} + + + ) : ( + <> + {f.relative(federation.status === 'offline' ? federation.last_online : federation.last_probe)}
+ + {federation.status_source + ? t('fedimint.confirmed.by', { source: federation.status_source }) + : t('fedimint.confirmed.unattributed')} + + + ) + } +
+
+
+ +
+ + + +
+ + { + related.length > 0 && ( +
+
+

{t('fedimint.more.title')}

+ {t('fedimint.more.all')} +
+
+ {related.map((item) => )} +
+
+ ) + } + +

+ {t('fedimint.disclaimer')} + {t('mint.disclaimerLink')}. +

+ + + + + + + + diff --git a/web/src/pages/[...locale]/fedimints.astro b/web/src/pages/[...locale]/fedimints.astro new file mode 100644 index 0000000..972a785 --- /dev/null +++ b/web/src/pages/[...locale]/fedimints.astro @@ -0,0 +1,408 @@ +--- +/** + * The federation index. /mints' twin, and deliberately built from the same parts. + * + * Same container, same card, same search, same result line, same leave-and-return + * animation. Two differences, both of them because a federation is a different thing + * rather than because this page is: + * + * - There is no "recently seen online" sort. It reads as a claim about uptime, and + * for the federations nothing checks there is no such reading. Ordering a column + * that is null for a third of the rows is not a sort, it is a shuffle. + * - The count line names the announced ones separately, because "12 federations, 6 + * online" invites the reader to assume the other 6 are down. + */ +import Base from '../../layouts/Base.astro'; +import MintCard from '../../components/MintCard.astro'; +import { fetchFedimints } from '../../lib/api'; +import { localePath, useI18n, type Locale } from '../../i18n'; +import { itemListNode } from '../../lib/schema'; +import { isIndexableMint } from '../../lib/seo'; +import { localePaths } from '../../i18n/paths'; + +/** One page per locale: `/fedimints`, `/es/fedimints`, `/nl/fedimints`. */ +export const getStaticPaths = localePaths; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; + +const t = useI18n(locale); + +const federations = await fetchFedimints(); + +/* + * No capability fetch, unlike /mints. + * + * That page asks the API for each mint's detail to read NUT-04 and NUT-05 and draw a + * "melt only" chip. A federation publishes no such switches, so there is nothing to + * fetch and no chip to draw — and inventing one from its module list would be exactly + * the fake capability warning this whole feature refuses to ship. + */ +const online = federations.filter((f) => f.status === 'online').length; +const announced = federations.filter((f) => f.status === 'announced').length; + +const description = t('fedimints.description', { + total: federations.length, + online, + announced, +}); + +const canonical = new URL(localePath('/fedimints', locale), Astro.site).href; +const schema = [ + itemListNode({ + canonical, + fragment: 'fedimints', + name: t('fedimints.heading'), + items: federations.filter(isIndexableMint).map((federation) => ({ + name: federation.name ?? federation.host, + url: new URL(localePath(`/fedimint/${federation.host}`, locale), Astro.site).href, + })), + }), +]; +--- + + +
+
+

{t('fedimints.heading')}

+

{t('fedimints.lede')}

+
+ +
+ + + + + + + +
+ +
+ + +
+ + +
+
+ +

+ {t('fedimints.showing', { total: federations.length, online, announced })} +

+ + {/* + What "announced" means, said once at the top of the list rather than on every + card that carries it. The status is unusual enough that a reader meeting it for + the first time deserves the sentence. + */} + { + announced > 0 && ( +

+ + {t('fedimints.announcedNote', { n: announced })} +

+ ) + } + +
+ {federations.map((federation, i) => )} +
+ + + + { + federations.length === 0 && ( +

{t('fedimints.emptyIndex')}

+ ) + } +
+ + + + + diff --git a/web/src/pages/[...locale]/index.astro b/web/src/pages/[...locale]/index.astro index d935f14..181e65e 100644 --- a/web/src/pages/[...locale]/index.astro +++ b/web/src/pages/[...locale]/index.astro @@ -3,8 +3,9 @@ import Base from '../../layouts/Base.astro'; import ColorBends from '../../components/ColorBends.astro'; import MintCard from '../../components/MintCard.astro'; import PulseTicker from '../../components/PulseTicker.astro'; -import { fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api'; +import { fetchFedimints, fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api'; import { fetchLatestReviews } from '../../lib/nostr-build'; +import { feedTargets, targetPath } from '../../lib/feed-resolve'; import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format'; import { profileName, readCapabilities } from '@cashumints/shared'; import { localePath, useI18n, type Locale } from '../../i18n'; @@ -22,15 +23,27 @@ const { locale } = Astro.props; const t = useI18n(locale); const f = formatters(t); -const [mints, stats, health] = await Promise.all([fetchMints(), fetchStats(), fetchHealth()]); +const [mints, federations, stats, health] = await Promise.all([ + fetchMints(), + fetchFedimints(), + fetchStats(), + fetchHealth(), +]); const top = mints.slice(0, 6); -const hostByUrl = new Map(mints.map((m) => [m.url, m.host])); + +/* + * The reviews strip resolves against both ecosystems, because its heading is "latest + * reviews" and not "latest mint reviews". Federations rarely reach it in practice — the + * junk filter wants a written body and nearly every 38173 review is a bare `[5/5]` — + * but when one does, it is genuinely among the latest and it links to its own page. + */ +const reviewTargets = feedTargets(mints, federations); /* * The mint details and the relay read have nothing to say to each other — the reviews - * only need `hostByUrl`, which the mint list above already gave us — so they wait - * together rather than one behind the other. + * only need the target list above — so they wait together rather than one behind the + * other. * * The carousel holds a screenful and a bit: enough that scrolling it is worth doing, * few enough that they are all real, recent, written reviews. Anything the relays @@ -39,7 +52,7 @@ const hostByUrl = new Map(mints.map((m) => [m.url, m.host])); const [details, latest] = await Promise.all([ // Distributions give the card sentiment bars real data instead of a rating proxy. Promise.all(top.map((m) => fetchMint(m.host).catch(() => null))), - fetchLatestReviews(hostByUrl, 10), + fetchLatestReviews(reviewTargets, 10), ]); const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined)); @@ -150,6 +163,49 @@ const signedBody = t('home.why.signed.body', { + {/* + The Fedimint strip. + + One row, under the mint grid and above the reviews, because that is what it is: a + signpost to a second, smaller index, not a second front page. The home page stays + Cashu-first — the hero, the search, the six ranked cards and the ticker are all + unchanged — and this is the one place that says the other ecosystem exists. + + The counts come from `/api/stats`, the same source as the ticker, and they name the + announced federations separately for the same reason the index does: "12 federations, + 6 online" invites the reader to assume the other six are down. + */} + { + stats.fedimint_total > 0 && ( + + ) + } +

{t('home.latest.title')}

@@ -189,7 +245,11 @@ const signedBody = t('home.why.signed.body', { const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase(); const filled = review.rating === null ? 0 : Math.round(review.rating); return ( - +
- +
{/* - A text input backed by a datalist rather than a select: 58 options is too many - to scroll, and typing "azz" should be enough. Any substring of a name or a - domain narrows the list, so a partial guess still works. + Which ecosystem, as its own group of pills rather than as more options inside + the rating group: they answer different questions and pressing one must not + clear the other. Same control, same styling, one row down on a narrow screen. + */} +
+ { + ecosystemFilters.map((option, index) => ( + + )) + } +
+ + {/* + A text input backed by a datalist rather than a select: seventy options is too + many to scroll, and typing "azz" should be enough. Any substring of a name, a + domain or a federation id narrows the list, so a partial guess still works. */}
- + @@ -125,10 +166,10 @@ const footNote = t('feed.foot', { type="text" list="feed-mint-options" autocomplete="off" - placeholder={t('feed.anyMint')} + placeholder={t('feed.anySubject')} data-mint-filter /> -