Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87901c56d2 | ||
|
|
1c5df18e81 | ||
|
|
6f17b572b1 |
+50
-8
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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:<federation id>` |
|
||||
| Routing slug | the hostname | `fed-` + the first 16 characters of the id |
|
||||
| Check | `GET {url}/v1/info`, every 10 min | see below |
|
||||
| Capability panel | supported NUTs, from `/v1/info` | modules, from the announcement |
|
||||
| Statuses | online / degraded / offline / unknown | online / offline / **announced** |
|
||||
|
||||
### Checking a federation
|
||||
|
||||
A Cashu mint answers `GET /v1/info` over HTTPS, which is why probing one is fifteen
|
||||
lines. A federation has no such endpoint: its guardians speak a JSON-RPC dialect over
|
||||
websockets, their addresses are bech32m-encoded inside the invite code, and confirming
|
||||
one is up means being a Fedimint client — decoding the code, opening sockets to a quorum
|
||||
and agreeing a consensus session. Half of that would produce a status less trustworthy
|
||||
than saying nothing.
|
||||
|
||||
So the federation check reads [fedimint.observer](https://observer.fedimint.org), which
|
||||
already keeps those client connections open and publishes the result at
|
||||
`/api/federations` as `{ id, name, invite, health }`. Two consequences, and the site
|
||||
carries both rather than hiding them:
|
||||
|
||||
- **It is somebody else's check.** A federation whose status came from there stores
|
||||
`status_source: "fedimint.observer"` and its page prints that beside the status, so
|
||||
nothing implies this site opened a socket itself. Point `FEDIMINT_OBSERVER_URL`
|
||||
somewhere else, or set it empty to disable the lookup entirely.
|
||||
- **It does not cover everything.** A federation the observer does not track gets the
|
||||
`announced` status: Nostr says it exists, nothing says it runs. It is never `online`,
|
||||
never `offline`, has no `last_online`, no uptime figure and no sparkline. `announced`
|
||||
sorts between the confirmed-up rows and the confirmed-down ones, because not knowing
|
||||
is not the same as knowing otherwise.
|
||||
|
||||
When the observer itself is unreachable, nothing is written at all: a federation
|
||||
confirmed up an hour ago is not demoted because a third party had a bad minute.
|
||||
|
||||
### What a federation page will not say
|
||||
|
||||
There is no Fedimint counterpart to the "melt only", "withdrawals disabled" or "mint
|
||||
frozen" banners, and `shared/src/warnings.ts` cannot produce one. Those are read out of a
|
||||
mint's own NUT-04 and NUT-05 switches; a federation's `modules` tag says which parts it
|
||||
runs and nothing about whether any of them is accepting deposits, so the Modules panel
|
||||
lists them and stops there. A federation gets two banners at most: a real check reported
|
||||
its guardians down, or it has been announced for a month and nothing has ever confirmed
|
||||
it.
|
||||
|
||||
### Adding a third ecosystem
|
||||
|
||||
The extension points, in the order you would touch them. Nothing in the review pipeline,
|
||||
the card, the dialog or the feed needs an edit: they are already driven by the values
|
||||
below rather than by a test for Cashu.
|
||||
|
||||
1. **A `type` value and its announcement kind.** One entry in `ANNOUNCEMENT_KINDS`
|
||||
(`shared/src/nostr.ts`). That alone puts the kind on discovery's subscription list,
|
||||
teaches the review resolver and the `/reviews` feed which `k` tag belongs to it, and
|
||||
adds its pill to the ecosystem filter. `mints.type` is TEXT with no CHECK constraint,
|
||||
so no migration is involved.
|
||||
2. **Discovery: how to read its announcement.** A parser beside
|
||||
`parseFedimintAnnouncement` and an `upsert…` beside `upsertFedimint`, called from
|
||||
`runDiscovery`. Whatever is type-specific goes in `ecosystem_json`, which
|
||||
`GET /api/mints/:host` spreads across the detail payload; the shared columns
|
||||
(`name`, `description`, `icon_url`, `status`) are filled the same way for everyone.
|
||||
3. **A probe strategy.** A branch in `probeAll` (`api/src/probe.ts`) and a checker beside
|
||||
`fedimint-observer.ts`. If there is no reliable public check, use a pseudo-status like
|
||||
`announced` and record why — do not infer a status from the announcement.
|
||||
4. **Pages.** A list page and a detail page under `web/src/pages/[...locale]/`, a
|
||||
`…Subject()` builder in `web/src/lib/review-subject.ts` (which is what makes the
|
||||
reviews panel work unchanged), a route in `targetPath` (`web/src/lib/feed-resolve.ts`),
|
||||
nav entries in `Topbar.astro` and `Footer.astro`, and sitemap entries in
|
||||
`src/pages/sitemap.xml.ts`.
|
||||
5. **Copy.** A namespace in `en.json`, `es.json` and `nl.json`, the namespace added to
|
||||
`CLIENT_NAMESPACES` if an island renders any of it, and a row in
|
||||
`web/src/i18n/GLOSSARY.md` for every term the ecosystem introduces. `pnpm check:i18n`
|
||||
fails the build until all three catalogs have the keys.
|
||||
|
||||
Not in scope, deliberately: **LNURL**. Nothing in the routes, event kinds, schema values
|
||||
or copy refers to it, and it is planned as a later stage rather than half-built now.
|
||||
|
||||
## API
|
||||
|
||||
Four endpoints, CORS open, no auth.
|
||||
@@ -464,8 +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/ {
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+46
-26
@@ -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()})`,
|
||||
);
|
||||
|
||||
+248
-25
@@ -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('<script>'), null);
|
||||
});
|
||||
|
||||
check('the k tag decides which ecosystem a review belongs to', () => {
|
||||
const review = (tags: string[][]) => ({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: '', tags });
|
||||
assert.equal(reviewEcosystem(review([['k', '38172']])), 'cashu');
|
||||
assert.equal(reviewEcosystem(review([['k', '38173']])), 'fedimint');
|
||||
// No `k` at all is Cashu, and that is a fact about the past rather than a guess:
|
||||
// every such event predates this site listing anything else. Most reviews on the
|
||||
// network are this shape.
|
||||
assert.equal(reviewEcosystem(review([['u', 'https://mint.example.com']])), 'cashu');
|
||||
// A kind this build has no ecosystem for is nobody's: it is dropped, not filed.
|
||||
assert.equal(reviewEcosystem(review([['k', '39999']])), null);
|
||||
assert.equal(ecosystemForKind(38173), 'fedimint');
|
||||
});
|
||||
|
||||
check('a federation never carries a capability warning it cannot know', () => {
|
||||
const now = 1_800_000_000;
|
||||
const base = { type: 'fedimint', status: 'announced', last_online: null, last_review_at: null };
|
||||
|
||||
// Announced this week: nothing has had the chance to check it, so nothing is said.
|
||||
assert.deepEqual(getMintWarnings({ ...base, announced_at: now - 86400 }, { now }), []);
|
||||
|
||||
// Announced two months ago and still unconfirmed: the one soft banner.
|
||||
const stale = getMintWarnings({ ...base, announced_at: now - 60 * 86400 }, { now });
|
||||
assert.equal(stale[0]?.kind, 'never-confirmed');
|
||||
assert.equal(stale[0]?.severity, 'warning');
|
||||
assert.equal(stale.length, 1, 'a federation gets one banner at most');
|
||||
|
||||
// Reported down by a real check: critical, and dated from the last time it answered.
|
||||
const down = getMintWarnings(
|
||||
{ ...base, status: 'offline', last_online: now - 10 * 86400 },
|
||||
{ now },
|
||||
);
|
||||
assert.equal(down[0]?.kind, 'fedimint-offline');
|
||||
assert.equal(down[0]?.severity, 'critical');
|
||||
|
||||
// And none of the NUT-derived banners can ever appear on one, whatever is passed.
|
||||
const kinds = new Set([...stale, ...down].map((w) => w.kind));
|
||||
for (const forbidden of ['melt-only', 'melt-disabled', 'frozen', 'gone', 'offline-long']) {
|
||||
assert.ok(!kinds.has(forbidden as never), `${forbidden} has no fedimint meaning`);
|
||||
}
|
||||
});
|
||||
|
||||
check('a cashu mint is unaffected by any of the above', () => {
|
||||
const now = 1_800_000_000;
|
||||
// The same input with no `type` and with `type: 'cashu'` must agree, because every
|
||||
// existing caller passes neither.
|
||||
const mint = {
|
||||
status: 'offline',
|
||||
last_online: now - 40 * 86400,
|
||||
info: { nuts: { '4': { disabled: true }, '5': { methods: [{ method: 'bolt11' }] } } },
|
||||
};
|
||||
const untyped = getMintWarnings(mint, { now });
|
||||
const typed = getMintWarnings({ ...mint, type: 'cashu' }, { now });
|
||||
assert.deepEqual(untyped, typed);
|
||||
assert.equal(untyped[0]?.kind, 'gone');
|
||||
});
|
||||
|
||||
check('announced federations sort between confirmed-up and confirmed-down', () => {
|
||||
// Not knowing is not the same as knowing otherwise: an announced federation must not
|
||||
// sit with the rows a check confirmed, nor sink below the ones a check failed on.
|
||||
const sorted = [
|
||||
{ status: 'offline', score: 4.9, review_count: 99 },
|
||||
{ status: 'announced', score: 2.7, review_count: 0 },
|
||||
{ status: 'online', score: 1.2, review_count: 1 },
|
||||
].sort(compareMints);
|
||||
assert.deepEqual(sorted.map((m) => m.status), ['online', 'announced', 'offline']);
|
||||
});
|
||||
|
||||
// Dialect portability. Every statement in the API is written once and run against both
|
||||
// backends, so the translation and the two spellings that differ get their own checks.
|
||||
check('placeholders translate to $n without touching quoted text', () => {
|
||||
assert.equal(
|
||||
toPgPlaceholders('SELECT ? FROM t WHERE a = ? AND b = ?'),
|
||||
'SELECT $1 FROM t WHERE a = $2 AND b = $3',
|
||||
);
|
||||
// A literal question mark is data, not a placeholder.
|
||||
assert.equal(toPgPlaceholders("SELECT '?' , ?"), "SELECT '?' , $1");
|
||||
// Postgres doubles a quote to escape it; the closing quote just opens the next literal.
|
||||
assert.equal(toPgPlaceholders("SELECT 'it''s ?' , ?"), "SELECT 'it''s ?' , $1");
|
||||
assert.equal(toPgPlaceholders('SELECT "od?d", ?'), 'SELECT "od?d", $1');
|
||||
});
|
||||
|
||||
check('connection targets are read the way people write them', () => {
|
||||
assert.equal(parseDbTarget('postgres://u:p@h:5432/db').dialect, 'postgres');
|
||||
assert.equal(parseDbTarget('postgresql://h/db').dialect, 'postgres');
|
||||
assert.equal(parseDbTarget('/var/lib/cashumints/x.db').dialect, 'sqlite');
|
||||
assert.equal(parseDbTarget('sqlite:/var/lib/x.db').file, '/var/lib/x.db');
|
||||
assert.equal(parseDbTarget('sqlite:///var/lib/x.db').file, '/var/lib/x.db');
|
||||
assert.equal(parseDbTarget('file:./data/x.db').file, path.resolve('./data/x.db'));
|
||||
assert.equal(parseDbTarget(':memory:').file, ':memory:');
|
||||
// A password must never reach a log line.
|
||||
assert.ok(!parseDbTarget('postgres://u:hunter2@h/db').label.includes('hunter2'));
|
||||
// An unsupported scheme must be an error, never a relative filename: a silent new
|
||||
// empty SQLite file looks exactly like a successful boot with all the data gone.
|
||||
assert.throws(() => parseDbTarget('mysql://h/db'), /Cannot tell what database/);
|
||||
assert.throws(() => parseDbTarget('redis://h'), /Cannot tell what database/);
|
||||
assert.throws(() => parseDbTarget('sqlite:'), /no path after the scheme/);
|
||||
assert.throws(() => parseDbTarget(' '), /Empty database target/);
|
||||
// A schemeless value is a path, including a bare relative filename.
|
||||
assert.equal(parseDbTarget('cashumints.db').file, path.resolve('cashumints.db'));
|
||||
});
|
||||
|
||||
/**
|
||||
* The dedupe rule lives in SQL, so exercise the real statement — the one queries.ts
|
||||
* ships — against a real database. Set CHECK_DB_URL to run it on Postgres too; that is
|
||||
* what catches a statement that quietly went SQLite-only.
|
||||
*/
|
||||
async function checkDedupe(target: string): Promise<void> {
|
||||
const db = await openDb(parseDbTarget(target));
|
||||
try {
|
||||
await db.run('DELETE FROM reviews');
|
||||
await db.run(
|
||||
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, k, created_at)
|
||||
VALUES (?,?,?,?,?,?), (?,?,?,?,?,?), (?,?,?,?,?,?), (?,?,?,?,?,?)`,
|
||||
'e1', 'https://m', 'alice', 1, '38172', 100,
|
||||
'e2', 'https://m', 'alice', 5, '38172', 200, // alice changed her mind
|
||||
'e3', 'https://m', 'bob', 3, '38172', 150,
|
||||
// Same author, a different subject: one npub reviewing a mint and a federation
|
||||
// is two reviews, and the dedupe partitions on the subject as well as the author.
|
||||
'e4', 'fedimint:aeca', 'alice', 4, '38173', 210,
|
||||
);
|
||||
|
||||
const rows = await db.all<{ pubkey: string; rating: number }>(
|
||||
`SELECT pubkey, rating FROM (${LATEST_REVIEWS}) AS latest WHERE mint_url = ? ORDER BY pubkey`,
|
||||
'https://m',
|
||||
);
|
||||
|
||||
assert.equal(rows.length, 2, `${db.dialect}: alice must count once`);
|
||||
assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, `${db.dialect}: newest wins`);
|
||||
|
||||
const federation = await db.all<{ pubkey: string; rating: number }>(
|
||||
`SELECT pubkey, rating FROM (${LATEST_REVIEWS}) AS latest WHERE mint_url = ?`,
|
||||
'fedimint:aeca',
|
||||
);
|
||||
assert.equal(federation.length, 1, `${db.dialect}: alice's federation review stands alone`);
|
||||
assert.equal(federation[0]?.rating, 4, `${db.dialect}: and is not collapsed into her mint one`);
|
||||
checks++;
|
||||
} catch (err) {
|
||||
console.error(`FAIL: one review per author per mint, newest wins (${db.dialect})`);
|
||||
throw err;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
await checkDedupe(':memory:');
|
||||
|
||||
const pgUrl = process.env['CHECK_DB_URL'];
|
||||
if (pgUrl) await checkDedupe(pgUrl);
|
||||
else console.log('note: set CHECK_DB_URL to a postgres:// database to check it there too');
|
||||
|
||||
console.log(`ok, ${checks} checks passed`);
|
||||
|
||||
+95
-1
@@ -1,6 +1,8 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_RELAYS } from '@cashumints/shared';
|
||||
import type { Dialect } from './db-driver.ts';
|
||||
import { redact } from './db-postgres.ts';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = path.resolve(here, '..');
|
||||
@@ -12,9 +14,101 @@ function int(name: string, fallback: number): number {
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export interface DbConfig {
|
||||
dialect: Dialect;
|
||||
/** SQLite file. Empty when the dialect is postgres. */
|
||||
file: string;
|
||||
/** libpq connection string. Empty when the dialect is sqlite. */
|
||||
url: string;
|
||||
/** Postgres connections held open. Ignored by SQLite, which has one. */
|
||||
poolMax: number;
|
||||
/** Safe to log: a Postgres password is replaced with `***`. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_DB_FILE = path.join(apiRoot, 'data', 'cashumints.db');
|
||||
|
||||
/**
|
||||
* Read one connection target.
|
||||
*
|
||||
* Accepts what a person is likely to type or paste:
|
||||
*
|
||||
* postgres://user:pw@host:5432/cashumints postgres
|
||||
* postgresql://… postgres
|
||||
* sqlite:/var/lib/cashumints/cashumints.db sqlite, absolute
|
||||
* sqlite://./data/cashumints.db sqlite, relative to the working directory
|
||||
* file:./data/cashumints.db sqlite
|
||||
* /var/lib/cashumints/cashumints.db sqlite, a bare path
|
||||
* :memory: sqlite, throwaway
|
||||
*
|
||||
* Throws on anything else rather than guessing, because the wrong guess here is a
|
||||
* second empty database that looks like data loss.
|
||||
*/
|
||||
export function parseDbTarget(raw: string, poolMax = 10): DbConfig {
|
||||
const value = raw.trim();
|
||||
if (!value) throw new Error('Empty database target.');
|
||||
|
||||
const sqlite = (file: string): DbConfig => {
|
||||
if (!file) throw new Error(`Database target has no path after the scheme: ${value}`);
|
||||
const resolved = file === ':memory:' ? file : path.resolve(file);
|
||||
return { dialect: 'sqlite', file: resolved, url: '', poolMax, label: `sqlite:${resolved}` };
|
||||
};
|
||||
|
||||
// A scheme is checked before anything else, so an unsupported one is an error rather
|
||||
// than a filename. Left to a "does it look like a path?" heuristic, `mysql://db/x`
|
||||
// reads as a relative path and the API starts on a brand new empty SQLite file — data
|
||||
// loss that announces itself as a successful boot.
|
||||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
|
||||
|
||||
switch (scheme) {
|
||||
case undefined:
|
||||
return sqlite(value); // A bare path, absolute or relative.
|
||||
case 'postgres':
|
||||
case 'postgresql':
|
||||
return { dialect: 'postgres', file: '', url: value, poolMax, label: redact(value) };
|
||||
case 'sqlite':
|
||||
case 'sqlite3':
|
||||
case 'file':
|
||||
return sqlite(value.replace(/^[a-z0-9+.-]+:(?:\/\/)?/i, ''));
|
||||
default:
|
||||
// `:memory:` has no scheme by this reading — the regex needs a letter first.
|
||||
if (value === ':memory:') return sqlite(value);
|
||||
throw new Error(
|
||||
`Cannot tell what database "${value}" means. Use a postgres:// URL, ` +
|
||||
'a sqlite: path, or a filesystem path.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this process keeps its data.
|
||||
*
|
||||
* `DATABASE_URL` decides the backend. Without it the API stays on SQLite at `DB_PATH`,
|
||||
* which is what every existing deployment already has, so adding Postgres support
|
||||
* changed nothing for anyone who does not ask for it.
|
||||
*/
|
||||
export function resolveDbConfig(env: NodeJS.ProcessEnv = process.env): DbConfig {
|
||||
const poolMax = int('DB_POOL_MAX', 10);
|
||||
const url = env['DATABASE_URL']?.trim();
|
||||
if (url) return parseDbTarget(url, poolMax);
|
||||
|
||||
const file = path.resolve(env['DB_PATH'] ?? DEFAULT_DB_FILE);
|
||||
return { dialect: 'sqlite', file, url: '', poolMax, label: `sqlite:${file}` };
|
||||
}
|
||||
|
||||
let dbConfig: DbConfig | null = null;
|
||||
|
||||
export const config = {
|
||||
port: int('PORT', 8787),
|
||||
dbPath: process.env['DB_PATH'] ?? path.join(apiRoot, 'data', 'cashumints.db'),
|
||||
/**
|
||||
* Resolved on first use rather than at import, so a process can still redirect itself
|
||||
* — `test:offline` points at a throwaway copy by setting DATABASE_URL before anything
|
||||
* opens a connection, and would otherwise rug the live database instead.
|
||||
*/
|
||||
get db(): DbConfig {
|
||||
dbConfig ??= resolveDbConfig();
|
||||
return dbConfig;
|
||||
},
|
||||
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
|
||||
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
|
||||
[...DEFAULT_RELAYS]) as string[],
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The one interface every backend implements.
|
||||
*
|
||||
* SQL is written once, in the portable subset both SQLite and Postgres speak, and
|
||||
* bound with `?` placeholders. The Postgres driver rewrites those to `$1..$n`; the
|
||||
* SQLite driver passes them straight through. See db-schema.ts for the rules that
|
||||
* keep a statement portable.
|
||||
*
|
||||
* Everything is async because `pg` is async. better-sqlite3 is not, so its driver
|
||||
* resolves already-settled promises — the cost is a microtask per query, which is
|
||||
* nothing next to the HTTP and relay work around it.
|
||||
*/
|
||||
|
||||
export type Dialect = 'sqlite' | 'postgres';
|
||||
|
||||
/** What a statement can be bound to. `undefined` is rejected by both drivers. */
|
||||
export type Param = string | number | null;
|
||||
|
||||
export interface RunResult {
|
||||
/** Rows inserted, updated or deleted. `ON CONFLICT DO NOTHING` that did nothing is 0. */
|
||||
changes: number;
|
||||
}
|
||||
|
||||
/** The read/write surface. A transaction hands back one of these bound to its connection. */
|
||||
export interface Sql {
|
||||
all<T>(sql: string, ...params: Param[]): Promise<T[]>;
|
||||
get<T>(sql: string, ...params: Param[]): Promise<T | undefined>;
|
||||
run(sql: string, ...params: Param[]): Promise<RunResult>;
|
||||
}
|
||||
|
||||
export interface Db extends Sql {
|
||||
readonly dialect: Dialect;
|
||||
/** Connection description safe to log: a Postgres password is never in it. */
|
||||
readonly label: string;
|
||||
/** Run DDL. Each element is one statement, no placeholders. */
|
||||
exec(statements: readonly string[]): Promise<void>;
|
||||
/**
|
||||
* Run `fn` inside a transaction. Statements issued through the handle `fn` receives
|
||||
* are part of it and see its uncommitted writes; anything issued through the Db
|
||||
* itself waits until the transaction settles. A throw rolls back and re-throws.
|
||||
*/
|
||||
transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import pg from 'pg';
|
||||
import type { Db, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
|
||||
/**
|
||||
* node-postgres hands back BIGINT and NUMERIC as strings, because either can hold a
|
||||
* value JavaScript's number cannot. Nothing here can: the counters are row counts, the
|
||||
* timestamps are unix seconds, and the averages are ratings between 1 and 5. Left
|
||||
* as-is the strings would flow straight into the JSON the API serves, turning
|
||||
* `review_count: 12` into `"12"` and quietly breaking every comparison on the way.
|
||||
*/
|
||||
types.setTypeParser(20, (v) => Number.parseInt(v, 10)); // int8
|
||||
types.setTypeParser(1700, (v) => Number.parseFloat(v)); // numeric
|
||||
|
||||
/**
|
||||
* Rewrite `?` placeholders to `$1..$n`.
|
||||
*
|
||||
* Quoted text is copied verbatim so a `?` inside a string literal is not renumbered.
|
||||
* Postgres escapes a quote by doubling it, which needs no special case: the closing
|
||||
* quote ends one literal and the next character opens another.
|
||||
*/
|
||||
export function toPgPlaceholders(sql: string): string {
|
||||
let out = '';
|
||||
let quote: string | null = null;
|
||||
let n = 0;
|
||||
|
||||
for (const ch of sql) {
|
||||
if (quote !== null) {
|
||||
if (ch === quote) quote = null;
|
||||
out += ch;
|
||||
} else if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
out += ch;
|
||||
} else if (ch === '?') {
|
||||
out += `$${++n}`;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const translated = new Map<string, string>();
|
||||
|
||||
function translate(sql: string): string {
|
||||
let pgSql = translated.get(sql);
|
||||
if (pgSql === undefined) {
|
||||
pgSql = toPgPlaceholders(sql);
|
||||
translated.set(sql, pgSql);
|
||||
}
|
||||
return pgSql;
|
||||
}
|
||||
|
||||
/** Bind a Sql surface to anything that can run a query: the pool, or one client. */
|
||||
function surface(run: (text: string, values: Param[]) => Promise<pg.QueryResult>): Sql {
|
||||
return {
|
||||
async all<T>(sql: string, ...params: Param[]): Promise<T[]> {
|
||||
return (await run(translate(sql), params)).rows as T[];
|
||||
},
|
||||
async get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
|
||||
return (await run(translate(sql), params)).rows[0] as T | undefined;
|
||||
},
|
||||
async run(sql: string, ...params: Param[]): Promise<RunResult> {
|
||||
return { changes: (await run(translate(sql), params)).rowCount ?? 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip the password so a connection string is safe to put in a log line. */
|
||||
export function redact(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.password) parsed.password = '***';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return 'postgres';
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresDb implements Db {
|
||||
readonly dialect = 'postgres' as const;
|
||||
readonly label: string;
|
||||
|
||||
#pool: pg.Pool;
|
||||
#sql: Sql;
|
||||
|
||||
constructor(connectionString: string, poolMax: number) {
|
||||
this.#pool = new Pool({ connectionString, max: poolMax });
|
||||
// A pool client can die between checkouts — a Postgres restart, a killed session, an
|
||||
// idle timeout on a proxy. Without a listener node-postgres emits that on the pool as
|
||||
// an unhandled 'error' and takes the process down with it.
|
||||
this.#pool.on('error', () => undefined);
|
||||
|
||||
this.#sql = surface((text, values) => this.#pool.query(text, values));
|
||||
this.label = redact(connectionString);
|
||||
}
|
||||
|
||||
all<T>(sql: string, ...params: Param[]): Promise<T[]> {
|
||||
return this.#sql.all<T>(sql, ...params);
|
||||
}
|
||||
|
||||
get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
|
||||
return this.#sql.get<T>(sql, ...params);
|
||||
}
|
||||
|
||||
run(sql: string, ...params: Param[]): Promise<RunResult> {
|
||||
return this.#sql.run(sql, ...params);
|
||||
}
|
||||
|
||||
async exec(statements: readonly string[]): Promise<void> {
|
||||
// One connection for the lot: concurrent CREATE ... IF NOT EXISTS on the same
|
||||
// catalog rows deadlocks in Postgres, and this runs on every boot.
|
||||
const client = await this.#pool.connect();
|
||||
try {
|
||||
for (const sql of statements) await client.query(sql);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T> {
|
||||
const client = await this.#pool.connect();
|
||||
const tx = surface((text, values) => client.query(text, values));
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(tx);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch {
|
||||
// The connection is already unusable; release() below discards it.
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* One schema for both backends.
|
||||
*
|
||||
* The types below were picked because SQLite and Postgres agree on all of them:
|
||||
* TEXT, INTEGER and SMALLINT are literal Postgres types and map to SQLite's INTEGER
|
||||
* and TEXT affinities, and BIGINT is what unix seconds need in Postgres, where plain
|
||||
* INTEGER runs out in 2038. `CREATE TABLE/INDEX IF NOT EXISTS` is spelled the same in
|
||||
* both, so first boot on an empty Postgres database needs no separate setup step.
|
||||
*
|
||||
* Rules for keeping a statement portable, learned from the ones that were not:
|
||||
* - Bind with `?`. The Postgres driver rewrites to `$1..$n`.
|
||||
* - A subquery in FROM needs an alias. Postgres rejects it without one.
|
||||
* - Count a condition with `COUNT(*) FILTER (WHERE ...)`, never `SUM(cond)`:
|
||||
* Postgres has no implicit boolean-to-integer cast.
|
||||
* - `INSERT OR IGNORE` is SQLite-only. `ON CONFLICT DO NOTHING` works in both and,
|
||||
* with no conflict target, covers every unique constraint on the table.
|
||||
*/
|
||||
|
||||
export const TABLES = ['mints', 'reviews', 'probes', 'state'] as const;
|
||||
export type TableName = (typeof TABLES)[number];
|
||||
|
||||
export const SCHEMA: readonly string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS mints (
|
||||
url TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'cashu',
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon_url TEXT,
|
||||
icon_file TEXT,
|
||||
pubkey TEXT,
|
||||
info_json TEXT,
|
||||
ecosystem_json TEXT,
|
||||
nuts_json TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
consecutive_fails INTEGER NOT NULL DEFAULT 0,
|
||||
last_online BIGINT,
|
||||
last_probe BIGINT,
|
||||
first_seen BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS reviews (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
mint_url TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
rating INTEGER,
|
||||
k TEXT,
|
||||
created_at BIGINT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS probes (
|
||||
mint_url TEXT NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
ok SMALLINT NOT NULL,
|
||||
latency_ms INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
];
|
||||
|
||||
/** Column order used by the migrator, so source and target line up without guessing. */
|
||||
export const COLUMNS: Record<TableName, readonly string[]> = {
|
||||
mints: [
|
||||
'url', 'host', 'type', 'name', 'description', 'icon_url', 'icon_file', 'pubkey',
|
||||
'info_json', 'ecosystem_json', 'nuts_json', 'version', 'status', 'consecutive_fails',
|
||||
'last_online', 'last_probe', 'first_seen', 'updated_at',
|
||||
],
|
||||
reviews: ['event_id', 'mint_url', 'pubkey', 'rating', 'k', 'created_at'],
|
||||
probes: ['mint_url', 'ts', 'ok', 'latency_ms'],
|
||||
state: ['key', 'value'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Primary key per table, for the migrator's upsert. `probes` has none — it is an
|
||||
* append-only log with no natural key — so the migrator replaces it wholesale.
|
||||
*/
|
||||
export const PRIMARY_KEY: Record<TableName, string | null> = {
|
||||
mints: 'url',
|
||||
reviews: 'event_id',
|
||||
probes: null,
|
||||
state: 'key',
|
||||
};
|
||||
|
||||
/**
|
||||
* Statements that bring an existing database up to the schema above.
|
||||
*
|
||||
* `CREATE TABLE IF NOT EXISTS` does nothing to a table that is already there, so a
|
||||
* database seeded before federations were indexed never grows `mints.type` on its own.
|
||||
* These run once per boot, after `SCHEMA`, and are the whole of this project's
|
||||
* migration story.
|
||||
*
|
||||
* The `ADD COLUMN`s are spelled without `IF NOT EXISTS` on purpose: Postgres accepts it
|
||||
* there and SQLite does not, so the portable form is to run the statement and swallow
|
||||
* the "duplicate column" both backends raise. `applySchemaUpgrades` in db.ts does that.
|
||||
*
|
||||
* Order matters, and the index at the end is why this list exists rather than the index
|
||||
* sitting with its table in `SCHEMA`: on an upgraded database the column is created
|
||||
* here, so an index over it declared any earlier fails on a column that is not there
|
||||
* yet — which is exactly how the first version of this failed to boot.
|
||||
*
|
||||
* Every statement must be re-runnable and must not need a value: a NOT NULL column
|
||||
* needs a constant DEFAULT (which is what makes existing rows read as Cashu), and
|
||||
* anything else is nullable.
|
||||
*/
|
||||
export const SCHEMA_UPGRADES: readonly string[] = [
|
||||
`ALTER TABLE mints ADD COLUMN type TEXT NOT NULL DEFAULT 'cashu'`,
|
||||
`ALTER TABLE mints ADD COLUMN ecosystem_json TEXT`,
|
||||
`ALTER TABLE reviews ADD COLUMN k TEXT`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mints_type ON mints(type, status)`,
|
||||
];
|
||||
@@ -0,0 +1,124 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Db, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
/**
|
||||
* better-sqlite3 behind the async Db interface.
|
||||
*
|
||||
* Every statement runs synchronously; the promises are already settled by the time
|
||||
* they are returned. Prepared statements are cached by SQL text, which the callers
|
||||
* rely on: they pass constant strings and would otherwise re-prepare on every request.
|
||||
*
|
||||
* The one subtlety is transactions. better-sqlite3's own `db.transaction()` cannot
|
||||
* wrap an async function, so this driver issues BEGIN/COMMIT itself — and that opens a
|
||||
* window the synchronous version did not have, where an unrelated `await`ing caller
|
||||
* could slip a statement between them and have it committed, or rolled back, with the
|
||||
* transaction. `#openTx` closes the window: statements issued through the Db while a
|
||||
* transaction is open queue behind it, and only the handle passed to the transaction
|
||||
* body bypasses the queue. Transactions themselves are serialized for the same reason.
|
||||
*/
|
||||
export class SqliteDb implements Db {
|
||||
readonly dialect = 'sqlite' as const;
|
||||
readonly label: string;
|
||||
|
||||
#db: Database.Database;
|
||||
#statements = new Map<string, Database.Statement>();
|
||||
/** Resolves when the transaction in flight settles. Null when none is open. */
|
||||
#openTx: Promise<unknown> | null = null;
|
||||
|
||||
constructor(file: string) {
|
||||
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
||||
|
||||
this.#db = new Database(file);
|
||||
this.#db.pragma('journal_mode = WAL');
|
||||
this.#db.pragma('synchronous = NORMAL');
|
||||
this.#db.pragma('busy_timeout = 5000');
|
||||
this.label = `sqlite:${file}`;
|
||||
}
|
||||
|
||||
#prepare(sql: string): Database.Statement {
|
||||
let stmt = this.#statements.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = this.#db.prepare(sql);
|
||||
this.#statements.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/** The synchronous core, shared by the Db surface and the transaction handle. */
|
||||
#direct: Sql = {
|
||||
all: <T>(sql: string, ...params: Param[]): Promise<T[]> =>
|
||||
Promise.resolve(this.#prepare(sql).all(...params) as T[]),
|
||||
get: <T>(sql: string, ...params: Param[]): Promise<T | undefined> =>
|
||||
Promise.resolve(this.#prepare(sql).get(...params) as T | undefined),
|
||||
run: (sql: string, ...params: Param[]): Promise<RunResult> =>
|
||||
Promise.resolve({ changes: this.#prepare(sql).run(...params).changes }),
|
||||
};
|
||||
|
||||
/** Wait out any open transaction, so a statement never lands inside someone else's. */
|
||||
async #settled(): Promise<void> {
|
||||
while (this.#openTx) await this.#openTx.catch(() => undefined);
|
||||
}
|
||||
|
||||
async all<T>(sql: string, ...params: Param[]): Promise<T[]> {
|
||||
await this.#settled();
|
||||
return this.#direct.all<T>(sql, ...params);
|
||||
}
|
||||
|
||||
async get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
|
||||
await this.#settled();
|
||||
return this.#direct.get<T>(sql, ...params);
|
||||
}
|
||||
|
||||
async run(sql: string, ...params: Param[]): Promise<RunResult> {
|
||||
await this.#settled();
|
||||
return this.#direct.run(sql, ...params);
|
||||
}
|
||||
|
||||
exec(statements: readonly string[]): Promise<void> {
|
||||
for (const sql of statements) this.#db.exec(sql);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T> {
|
||||
// Claim the slot synchronously, before the first await: two transactions started in
|
||||
// the same tick would otherwise both find no transaction open and issue a nested
|
||||
// BEGIN, which SQLite rejects outright.
|
||||
const previous = this.#openTx;
|
||||
|
||||
const work = (async (): Promise<T> => {
|
||||
if (previous) await previous.catch(() => undefined);
|
||||
|
||||
// IMMEDIATE takes the write lock up front rather than on the first write, so two
|
||||
// writers fail fast against busy_timeout instead of deadlocking mid-transaction.
|
||||
this.#db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await fn(this.#direct);
|
||||
this.#db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
this.#db.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Already rolled back by SQLite. The original error is the one that matters.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
this.#openTx = work;
|
||||
|
||||
return work.finally(() => {
|
||||
// Only the last transaction in the chain clears the slot; anything queued behind
|
||||
// this one has already replaced it.
|
||||
if (this.#openTx === work) this.#openTx = null;
|
||||
});
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#statements.clear();
|
||||
this.#db.close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
+103
-79
@@ -1,101 +1,125 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.ts';
|
||||
import { config, type DbConfig } from './config.ts';
|
||||
import type { Db } from './db-driver.ts';
|
||||
import { PostgresDb } from './db-postgres.ts';
|
||||
import { SCHEMA, SCHEMA_UPGRADES } from './db-schema.ts';
|
||||
import { SqliteDb } from './db-sqlite.ts';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS mints (
|
||||
url TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon_url TEXT,
|
||||
icon_file TEXT,
|
||||
pubkey TEXT,
|
||||
info_json TEXT,
|
||||
nuts_json TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
consecutive_fails INTEGER NOT NULL DEFAULT 0,
|
||||
last_online INTEGER,
|
||||
last_probe INTEGER,
|
||||
first_seen INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
|
||||
export type { Db, Dialect, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
mint_url TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
rating INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
|
||||
/**
|
||||
* Open a connection and make sure the schema is there.
|
||||
*
|
||||
* Both backends create their tables on first use, so pointing the API at an empty
|
||||
* Postgres database is the whole setup: no separate migration step to forget.
|
||||
*/
|
||||
export async function openDb(target: DbConfig = config.db): Promise<Db> {
|
||||
const db: Db =
|
||||
target.dialect === 'postgres'
|
||||
? new PostgresDb(target.url, target.poolMax)
|
||||
: new SqliteDb(target.file);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS probes (
|
||||
mint_url TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
|
||||
try {
|
||||
await db.exec(SCHEMA);
|
||||
await applySchemaUpgrades(db);
|
||||
} catch (err) {
|
||||
await db.close().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
let db: DB | null = null;
|
||||
|
||||
export function getDb(): DB {
|
||||
if (db) return db;
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
fs.mkdirSync(config.iconDir, { recursive: true });
|
||||
|
||||
const handle = new Database(config.dbPath);
|
||||
handle.pragma('journal_mode = WAL');
|
||||
handle.pragma('synchronous = NORMAL');
|
||||
handle.pragma('busy_timeout = 5000');
|
||||
handle.exec(SCHEMA);
|
||||
|
||||
db = handle;
|
||||
return handle;
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
db?.close();
|
||||
db = null;
|
||||
/**
|
||||
* Bring an existing database up to the current schema.
|
||||
*
|
||||
* `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a
|
||||
* deployment seeded before federations were indexed would otherwise boot against a
|
||||
* `mints` table with no `type` column and fail on the first query. Running these here
|
||||
* means an upgrade is a restart, with no separate step to forget.
|
||||
*
|
||||
* An already-present column is the expected outcome on every boot after the first, so
|
||||
* that error is swallowed and anything else is re-thrown. Both backends are matched on
|
||||
* the message rather than on a code: SQLite raises `duplicate column name: type` and
|
||||
* Postgres `column "type" of relation "mints" already exists`, and neither exposes
|
||||
* anything more structured through the drivers here.
|
||||
*/
|
||||
async function applySchemaUpgrades(db: Db): Promise<void> {
|
||||
for (const statement of SCHEMA_UPGRADES) {
|
||||
try {
|
||||
await db.exec([statement]);
|
||||
} catch (err) {
|
||||
/*
|
||||
* Postgres localizes its message text (lc_messages), so a German server says
|
||||
* neither phrase below and every boot after the first would re-throw. Its error
|
||||
* codes are locale-proof: 42701 duplicate_column, 42P07 duplicate_table. SQLite
|
||||
* has no code for this, so its English message is still matched.
|
||||
*/
|
||||
const code = (err as { code?: unknown }).code;
|
||||
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
const alreadyThere =
|
||||
code === '42701' ||
|
||||
code === '42P07' ||
|
||||
message.includes('duplicate column') ||
|
||||
message.includes('already exists');
|
||||
if (!alreadyThere) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getState(key: string): string | null {
|
||||
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
let opening: Promise<Db> | null = null;
|
||||
|
||||
/**
|
||||
* The process-wide connection, opened on first use.
|
||||
*
|
||||
* Async because `pg` is: there is no synchronous way to reach a Postgres server. The
|
||||
* promise is memoized, so concurrent callers during startup share one connection
|
||||
* rather than racing to open several.
|
||||
*/
|
||||
export function getDb(): Promise<Db> {
|
||||
if (!opening) {
|
||||
fs.mkdirSync(config.iconDir, { recursive: true });
|
||||
opening = openDb().catch((err: unknown) => {
|
||||
// A failed open must not be cached, or every later call replays the same error
|
||||
// against a connection that was never established.
|
||||
opening = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return opening;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
const pending = opening;
|
||||
opening = null;
|
||||
if (pending) await pending.then((db) => db.close()).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function getState(key: string): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ value: string }>('SELECT value FROM state WHERE key = ?', key);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setState(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.run(key, value);
|
||||
export async function setState(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
'INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
export function getStateNumber(key: string): number | null {
|
||||
const raw = getState(key);
|
||||
export async function getStateNumber(key: string): Promise<number | null> {
|
||||
const raw = await getState(key);
|
||||
if (raw === null) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Delete probe rows older than 90 days. Called once a day. */
|
||||
export function pruneProbes(): number {
|
||||
export async function pruneProbes(): Promise<number> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
|
||||
const db = await getDb();
|
||||
return (await db.run('DELETE FROM probes WHERE ts < ?', cutoff)).changes;
|
||||
}
|
||||
|
||||
+352
-55
@@ -1,6 +1,8 @@
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import type { Event as NostrEvent, Filter } from 'nostr-tools';
|
||||
import {
|
||||
ANNOUNCEMENT_KINDS,
|
||||
KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
KIND_MINT_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
isCashuMintReview,
|
||||
@@ -8,12 +10,18 @@ import {
|
||||
mintUrlSpellings,
|
||||
mintUrlsFromEvent,
|
||||
normalizeMintUrl,
|
||||
parseFedimintAnnouncement,
|
||||
parseRating,
|
||||
reviewEcosystem,
|
||||
reviewTargetId,
|
||||
reviewTargetKind,
|
||||
type FedimintAnnouncement,
|
||||
} from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { getDb, getStateNumber, setState } from './db.ts';
|
||||
import { getDb, setState, getStateNumber } from './db.ts';
|
||||
import type { Sql } from './db-driver.ts';
|
||||
import { log } from './log.ts';
|
||||
import { insertMintIfNew, mintUrlByHost, mintUrlByPubkey } from './mints.ts';
|
||||
import { insertMintIfNew, upsertFedimint } from './mints.ts';
|
||||
|
||||
const QUERY_LIMIT = 500;
|
||||
const MAX_WAIT_MS = 12_000;
|
||||
@@ -101,18 +109,40 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
|
||||
* count in the API disagrees with the list the browser renders on the same page.
|
||||
*/
|
||||
async function fetchReviewsForMint(
|
||||
mintUrl: string,
|
||||
mintPubkey: string | null,
|
||||
target: ReviewTarget,
|
||||
since: number | null,
|
||||
): Promise<NostrEvent[]> {
|
||||
const filters: Filter[] = [];
|
||||
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
|
||||
if (since !== null) base.since = since;
|
||||
|
||||
if (mintPubkey) {
|
||||
filters.push({ ...base, '#d': [mintPubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
|
||||
if (target.type === 'fedimint') {
|
||||
/*
|
||||
* A federation is asked for by id, and only by id.
|
||||
*
|
||||
* The mint side asks by `#u` as well, and the obvious translation of that is `#u`
|
||||
* with the invite codes. Two of the five relays in the pool answer such a filter
|
||||
* with `ERROR: bad req: filter item too large` and nothing else — an invite code is
|
||||
* 150+ characters — so the query costs two round trips and returns less than
|
||||
* sending nothing would.
|
||||
*
|
||||
* It also buys nothing today: every kind 38000 with `k` = 38173 on the network
|
||||
* carries the federation id in `d` and no `u` tag at all. A review that arrives
|
||||
* with only an invite code is still matched, by the global sweep, through
|
||||
* `byInvite` in the index above; it just is not asked for by name.
|
||||
*/
|
||||
if (!target.federationId) return [];
|
||||
filters.push({
|
||||
...base,
|
||||
'#d': [target.federationId],
|
||||
'#k': [String(KIND_FEDIMINT_ANNOUNCEMENT)],
|
||||
});
|
||||
} else {
|
||||
if (target.pubkey) {
|
||||
filters.push({ ...base, '#d': [target.pubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
|
||||
}
|
||||
filters.push({ ...base, '#u': mintUrlSpellings(target.url) });
|
||||
}
|
||||
filters.push({ ...base, '#u': mintUrlSpellings(mintUrl) });
|
||||
|
||||
const batches = await Promise.all(
|
||||
filters.map((filter) =>
|
||||
@@ -125,14 +155,137 @@ async function fetchReviewsForMint(
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
/** Every mint URL an event points at, normalized and inserted if new. */
|
||||
function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>): void {
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const created = insertMintIfNew(raw, now);
|
||||
/**
|
||||
* One thing the targeted second pass asks the relays about.
|
||||
*
|
||||
* A mint is asked for by URL and by the pubkey it publishes; a federation by its id and
|
||||
* its invite codes. Nothing else about the row is needed, so the pass reads five
|
||||
* columns rather than the whole table.
|
||||
*/
|
||||
interface ReviewTarget {
|
||||
type: string;
|
||||
/** Row key: the mint URL, or `fedimint:<id>`. */
|
||||
url: string;
|
||||
pubkey: string | null;
|
||||
federationId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every federation the batch announces, inserted or refreshed.
|
||||
*
|
||||
* Unlike mints, a federation's row content comes entirely from these events — there is
|
||||
* nothing to fetch afterwards — so this both creates rows and keeps existing ones
|
||||
* current. Deduped by federation id inside the batch first, newest announcement per id
|
||||
* winning, because the same federation is routinely announced by several npubs and by
|
||||
* the same npub more than once, and writing each of those would be one UPDATE per event
|
||||
* to land on the same final state.
|
||||
*/
|
||||
async function ingestFedimints(
|
||||
events: Iterable<NostrEvent>,
|
||||
now: number,
|
||||
newRows: Set<string>,
|
||||
): Promise<void> {
|
||||
const newest = new Map<string, FedimintAnnouncement>();
|
||||
|
||||
for (const event of events) {
|
||||
const announcement = parseFedimintAnnouncement(event);
|
||||
if (!announcement) continue;
|
||||
const existing = newest.get(announcement.federationId);
|
||||
if (existing && existing.announcedAt >= announcement.announcedAt) continue;
|
||||
newest.set(announcement.federationId, announcement);
|
||||
}
|
||||
|
||||
for (const announcement of newest.values()) {
|
||||
const created = await upsertFedimint(announcement, now);
|
||||
if (created) newRows.add(created);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mint URL the batch points at, normalized and inserted if new.
|
||||
*
|
||||
* Deduped across the whole batch before anything is written. A popular mint's URL
|
||||
* appears in hundreds of events per cycle, and the old row-at-a-time version paid one
|
||||
* INSERT round trip for each of them — invisible against a local SQLite file, minutes
|
||||
* of latency against a Postgres server.
|
||||
*/
|
||||
async function ingestMintUrls(
|
||||
events: Iterable<NostrEvent>,
|
||||
now: number,
|
||||
newMints: Set<string>,
|
||||
): Promise<void> {
|
||||
const seen = new Set<string>();
|
||||
for (const event of events) {
|
||||
// A review that says it is about a federation carries an invite code in `u`, not a
|
||||
// mint URL, and feeding those to the mint normalizer is how `fed11…` filled the
|
||||
// skipped-URL log. (A handful of kind 38172 announcements carry one too, which is
|
||||
// their publisher's mistake and still logs once.)
|
||||
if (reviewEcosystem(event) !== 'cashu') continue;
|
||||
for (const raw of mintUrlsFromEvent(event)) seen.add(raw);
|
||||
}
|
||||
|
||||
for (const raw of seen) {
|
||||
const created = await insertMintIfNew(raw, now);
|
||||
if (created) newMints.add(created);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mints table as two lookup maps, read once per cycle.
|
||||
*
|
||||
* Review resolution asks "which mint is this?" once per event, and the answer only
|
||||
* changes when a mint is inserted — which happens in ingestMintUrls, before any of
|
||||
* this runs. So it is a snapshot, not a query per event.
|
||||
*/
|
||||
interface MintIndex {
|
||||
byHost: Map<string, string>;
|
||||
byPubkey: Map<string, string>;
|
||||
/**
|
||||
* Federation id to row key. A federation has no URL, so its `d` tag is the only thing
|
||||
* a review can be matched on, and it is matched exactly.
|
||||
*/
|
||||
byFederation: Map<string, string>;
|
||||
/** Invite code to row key, for a review that carries `u` but no usable `d`. */
|
||||
byInvite: Map<string, string>;
|
||||
}
|
||||
|
||||
async function loadMintIndex(): Promise<MintIndex> {
|
||||
const db = await getDb();
|
||||
const rows = await db.all<{
|
||||
url: string;
|
||||
host: string;
|
||||
type: string;
|
||||
pubkey: string | null;
|
||||
ecosystem_json: string | null;
|
||||
}>('SELECT url, host, type, pubkey, ecosystem_json FROM mints');
|
||||
|
||||
const byHost = new Map<string, string>();
|
||||
const byPubkey = new Map<string, string>();
|
||||
const byFederation = new Map<string, string>();
|
||||
const byInvite = new Map<string, string>();
|
||||
|
||||
for (const row of rows) {
|
||||
byHost.set(row.host, row.url);
|
||||
// First writer wins, matching the LIMIT 1 the per-event query used.
|
||||
if (row.pubkey && !byPubkey.has(row.pubkey)) byPubkey.set(row.pubkey, row.url);
|
||||
|
||||
if (row.type !== 'fedimint' || !row.ecosystem_json) continue;
|
||||
try {
|
||||
const fields = JSON.parse(row.ecosystem_json) as {
|
||||
federation_id?: string;
|
||||
invite_codes?: string[];
|
||||
};
|
||||
if (fields.federation_id) byFederation.set(fields.federation_id.toLowerCase(), row.url);
|
||||
for (const code of fields.invite_codes ?? []) byInvite.set(code.toLowerCase(), row.url);
|
||||
} catch {
|
||||
// A row whose ecosystem column will not parse simply cannot be matched by id.
|
||||
// It still has a page and still resolves by nothing else, which is honest.
|
||||
}
|
||||
}
|
||||
|
||||
return { byHost, byPubkey, byFederation, byInvite };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which mint a review is about.
|
||||
*
|
||||
@@ -141,23 +294,157 @@ function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>):
|
||||
* the `d` tag pubkey against the pubkey a mint publishes in its own /v1/info, which
|
||||
* catches reviews whose `u` tag is missing or wrong.
|
||||
*/
|
||||
function resolveReviewTarget(event: NostrEvent): string | null {
|
||||
function resolveReviewTarget(event: NostrEvent, index: MintIndex): string | null {
|
||||
/*
|
||||
* The `k` tag decides which ecosystem's resolver runs, and it is not merely a hint:
|
||||
* a federation id and a Cashu mint pubkey are both 64 hex characters in a `d` tag, so
|
||||
* without this a Fedimint review could in principle be filed against a mint. A review
|
||||
* with no `k` at all is Cashu, because every one of those predates anything else.
|
||||
*/
|
||||
const ecosystem = reviewEcosystem(event);
|
||||
if (ecosystem === 'fedimint') return resolveFedimintReview(event, index);
|
||||
// A `k` naming a kind this build has no ecosystem for: not ours to file.
|
||||
if (ecosystem === null) return null;
|
||||
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
// The row was inserted moments ago by ingestMintUrls, so this almost always hits.
|
||||
const canonical = mintUrlByHost(normalized.host);
|
||||
const canonical = index.byHost.get(normalized.host);
|
||||
if (canonical) return canonical;
|
||||
}
|
||||
|
||||
const pubkey = mintPubkeyRef(event);
|
||||
return pubkey ? mintUrlByPubkey(pubkey) : null;
|
||||
return pubkey ? index.byPubkey.get(pubkey) ?? null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which federation a review is about.
|
||||
*
|
||||
* `d` first and almost always: every kind 38000 with `k` = 38173 seen on the network
|
||||
* carries the federation id there and nothing else — no `u`, no `a`. The invite code
|
||||
* fallback is for publishers that write `u` instead, and matches the code exactly
|
||||
* rather than decoding it, because an invite code is opaque to this codebase.
|
||||
*
|
||||
* A review of a federation this site has never seen announced resolves to nothing and
|
||||
* is dropped, exactly as a review of an unknown mint is. There is no page to put it on.
|
||||
*/
|
||||
function resolveFedimintReview(event: NostrEvent, index: MintIndex): string | null {
|
||||
const d = reviewTargetId(event);
|
||||
if (d && /^[0-9a-f]{64}$/i.test(d)) {
|
||||
const found = index.byFederation.get(d.toLowerCase());
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const found = index.byInvite.get(raw.trim().toLowerCase());
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Postgres caps a statement at 65535 bounds parameters; this keeps every batch clear of it. */
|
||||
const INSERT_BATCH = 500;
|
||||
|
||||
interface ReviewRow {
|
||||
eventId: string;
|
||||
mintUrl: string;
|
||||
pubkey: string;
|
||||
rating: number | null;
|
||||
/** The `k` tag verbatim, or null when the event carried none. */
|
||||
k: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Which of these event ids the reviews table already holds. */
|
||||
async function knownEventIds(ids: string[]): Promise<Set<string>> {
|
||||
const db = await getDb();
|
||||
const known = new Set<string>();
|
||||
|
||||
for (let i = 0; i < ids.length; i += INSERT_BATCH) {
|
||||
const chunk = ids.slice(i, i + INSERT_BATCH);
|
||||
const rows = await db.all<{ event_id: string }>(
|
||||
`SELECT event_id FROM reviews WHERE event_id IN (${chunk.map(() => '?').join(',')})`,
|
||||
...chunk,
|
||||
);
|
||||
for (const row of rows) known.add(row.event_id);
|
||||
}
|
||||
|
||||
return known;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a batch of reviews and report how many were genuinely new.
|
||||
*
|
||||
* Deduped by event id first, keeping the last spelling seen. That is not just a saving:
|
||||
* Postgres refuses an ON CONFLICT DO UPDATE that would touch the same row twice in one
|
||||
* statement, so a duplicate inside a single VALUES list is an error, not a no-op. The
|
||||
* targeted second pass below queries by `#d` and by `#u` and routinely returns both.
|
||||
*
|
||||
* `newReviews` is a health signal — is discovery finding anything? — so it counts rows
|
||||
* that did not exist, not rows written. `changes` would count every re-seen event and
|
||||
* hide a stalled crawl behind a busy-looking number.
|
||||
*/
|
||||
async function ingestReviews(events: Iterable<NostrEvent>, index: MintIndex): Promise<number> {
|
||||
const rows = new Map<string, ReviewRow>();
|
||||
|
||||
for (const e of events) {
|
||||
/*
|
||||
* The `k` tag is what marks a 38000 as being about a Cashu mint rather than a
|
||||
* federation or something this site does not list. Events without it but with a
|
||||
* resolvable mint URL are still accepted: the old publisher's own events are the
|
||||
* reason, and every one of them is about a mint.
|
||||
*
|
||||
* A federation review has already proved itself by resolving: `resolveReviewTarget`
|
||||
* only returns a federation row for an event whose `k` says 38173, so nothing here
|
||||
* has to re-test that.
|
||||
*/
|
||||
const target = resolveReviewTarget(e, index);
|
||||
if (!target) continue;
|
||||
const fedimint = reviewTargetKind(e) === KIND_FEDIMINT_ANNOUNCEMENT;
|
||||
if (!fedimint && !isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
|
||||
|
||||
const k = reviewTargetKind(e);
|
||||
|
||||
rows.set(e.id, {
|
||||
eventId: e.id,
|
||||
mintUrl: target,
|
||||
pubkey: e.pubkey,
|
||||
rating: parseRating(e),
|
||||
k: k === null ? null : String(k),
|
||||
createdAt: e.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.size === 0) return 0;
|
||||
|
||||
const batch = [...rows.values()];
|
||||
const known = await knownEventIds(batch.map((r) => r.eventId));
|
||||
const db = await getDb();
|
||||
|
||||
await db.transaction(async (tx: Sql) => {
|
||||
for (let i = 0; i < batch.length; i += INSERT_BATCH) {
|
||||
const chunk = batch.slice(i, i + INSERT_BATCH);
|
||||
await tx.run(
|
||||
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, k, created_at)
|
||||
VALUES ${chunk.map(() => '(?, ?, ?, ?, ?, ?)').join(', ')}
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
mint_url = excluded.mint_url,
|
||||
rating = excluded.rating,
|
||||
k = excluded.k`,
|
||||
...chunk.flatMap((r) => [r.eventId, r.mintUrl, r.pubkey, r.rating, r.k, r.createdAt]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return batch.filter((r) => !known.has(r.eventId)).length;
|
||||
}
|
||||
|
||||
export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult> {
|
||||
const started = Date.now();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastRun = backfill ? null : getStateNumber('discovery_since');
|
||||
const lastRun = backfill ? null : await getStateNumber('discovery_since');
|
||||
// Overlap the window by an hour so an event that arrived late is not missed.
|
||||
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
|
||||
|
||||
@@ -167,9 +454,25 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
let ok = true;
|
||||
|
||||
try {
|
||||
const announcements = await fetchKind(KIND_MINT_ANNOUNCEMENT, since);
|
||||
events += announcements.length;
|
||||
for (const e of announcements) ingestMintUrls(e, now, newMints);
|
||||
/*
|
||||
* One query per announcement kind, driven by ANNOUNCEMENT_KINDS rather than by a
|
||||
* list written out here: adding an ecosystem is a line in shared/, not an edit in
|
||||
* this loop. They run together because they are independent reads against the same
|
||||
* relay pool, and running them in series doubled the cycle's wall clock for nothing.
|
||||
*/
|
||||
const announcementsByType = new Map<string, NostrEvent[]>();
|
||||
await Promise.all(
|
||||
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
|
||||
announcementsByType.set(type, await fetchKind(kind, since));
|
||||
}),
|
||||
);
|
||||
|
||||
const announcements = announcementsByType.get('cashu') ?? [];
|
||||
const federations = announcementsByType.get('fedimint') ?? [];
|
||||
events += announcements.length + federations.length;
|
||||
|
||||
await ingestMintUrls(announcements, now, newMints);
|
||||
await ingestFedimints(federations, now, newMints);
|
||||
|
||||
// Announcements alone miss mints that only ever appear in a review's `u` tag,
|
||||
// so reviews feed discovery too.
|
||||
@@ -182,42 +485,36 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
|
||||
events += byId.size;
|
||||
|
||||
for (const e of byId.values()) ingestMintUrls(e, now, newMints);
|
||||
await ingestMintUrls(byId.values(), now, newMints);
|
||||
|
||||
const insert = getDb().prepare(
|
||||
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(event_id) DO UPDATE SET
|
||||
mint_url = excluded.mint_url,
|
||||
rating = excluded.rating`,
|
||||
);
|
||||
// `changes` counts updates as well as inserts, so ask first. The log line is a
|
||||
// health signal (is discovery finding anything?) and an inflated number hides a
|
||||
// stalled crawl behind a busy-looking figure.
|
||||
const known = getDb().prepare('SELECT 1 FROM reviews WHERE event_id = ?');
|
||||
|
||||
const ingestReviews = getDb().transaction((list: NostrEvent[]) => {
|
||||
for (const e of list) {
|
||||
// The `k` tag is what marks a 38000 as being about a Cashu mint rather than
|
||||
// some other recommendable kind. Events without it but with a resolvable mint
|
||||
// URL are still accepted: the old publisher's own events are the reason.
|
||||
const target = resolveReviewTarget(e);
|
||||
if (!target) continue;
|
||||
if (!isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
|
||||
|
||||
if (!known.get(e.id)) newReviews++;
|
||||
insert.run(e.id, target, e.pubkey, parseRating(e), e.created_at);
|
||||
}
|
||||
});
|
||||
|
||||
ingestReviews([...byId.values()]);
|
||||
// Built after every mint this cycle found has been inserted, so review resolution
|
||||
// below sees them. Nothing after this point inserts a mint.
|
||||
const index = await loadMintIndex();
|
||||
newReviews += await ingestReviews(byId.values(), index);
|
||||
|
||||
// Second pass: ask each known mint's reviews by name, which is the only way the
|
||||
// counts converge (see fetchReviewsForMint). Runs after the global sweep so mints
|
||||
// discovered in this cycle are included.
|
||||
const targets = getDb()
|
||||
.prepare('SELECT url, pubkey FROM mints')
|
||||
.all() as { url: string; pubkey: string | null }[];
|
||||
const db = await getDb();
|
||||
const rows = await db.all<{
|
||||
url: string;
|
||||
type: string;
|
||||
pubkey: string | null;
|
||||
ecosystem_json: string | null;
|
||||
}>('SELECT url, type, pubkey, ecosystem_json FROM mints');
|
||||
|
||||
const targets: ReviewTarget[] = rows.map((row) => {
|
||||
let federationId: string | null = null;
|
||||
if (row.type === 'fedimint' && row.ecosystem_json) {
|
||||
try {
|
||||
federationId =
|
||||
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
|
||||
} catch {
|
||||
// Nothing to ask by. The global sweep above already had its chance.
|
||||
}
|
||||
}
|
||||
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId };
|
||||
});
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
let cursor = 0;
|
||||
@@ -226,21 +523,21 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
while (cursor < targets.length) {
|
||||
const target = targets[cursor++];
|
||||
if (!target) continue;
|
||||
const found = await fetchReviewsForMint(target.url, target.pubkey, since);
|
||||
const found = await fetchReviewsForMint(target, since);
|
||||
if (found.length > 0) {
|
||||
events += found.length;
|
||||
ingestReviews(found);
|
||||
newReviews += await ingestReviews(found, index);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setState('discovery_since', String(now));
|
||||
setState('last_discovery_at', String(now));
|
||||
setState('last_discovery_ok', '1');
|
||||
await setState('discovery_since', String(now));
|
||||
await setState('last_discovery_at', String(now));
|
||||
await setState('last_discovery_ok', '1');
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
setState('last_discovery_ok', '0');
|
||||
await setState('last_discovery_ok', '0').catch(() => undefined);
|
||||
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The only check this site can actually run against a Fedimint federation.
|
||||
*
|
||||
* A Cashu mint answers `GET /v1/info` over plain 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 decoding that code, opening a socket to a quorum of
|
||||
* guardians and agreeing a consensus session with them. That is a Fedimint client, not
|
||||
* a probe, and writing half of one here would produce a status less trustworthy than
|
||||
* saying nothing.
|
||||
*
|
||||
* What exists instead is fedimint.observer, which already keeps those client
|
||||
* connections open and publishes the result:
|
||||
*
|
||||
* GET https://observer.fedimint.org/api/federations
|
||||
* [{ "id": "<64 hex>", "name": "...", "invite": "fed11...", "health": "online" }, ...]
|
||||
*
|
||||
* So this is a real check, and it is somebody else's. Both halves of that matter and
|
||||
* both are recorded: a federation whose status came from here carries
|
||||
* `status_source: "fedimint.observer"` and its page prints that beside the status,
|
||||
* rather than implying this site opened a socket. A federation the observer does not
|
||||
* track gets `announced` — Nostr says it exists, nothing says it runs — and never
|
||||
* `online`, never `offline`, and never a made-up uptime figure.
|
||||
*
|
||||
* When the observer itself is unreachable, nothing is written. A federation confirmed
|
||||
* up an hour ago is not demoted because a third party had a bad minute; the row keeps
|
||||
* its last real answer and `last_probe` is left alone so `/api/health` can still see
|
||||
* that the cycle happened.
|
||||
*/
|
||||
import { log } from './log.ts';
|
||||
|
||||
/** Where the health data comes from, recorded on every row it decides. */
|
||||
export const OBSERVER_SOURCE = 'fedimint.observer';
|
||||
|
||||
/** Empty disables the lookup entirely: every federation then stays `announced`. */
|
||||
export const OBSERVER_URL =
|
||||
process.env['FEDIMINT_OBSERVER_URL'] ?? 'https://observer.fedimint.org/api/federations';
|
||||
|
||||
/** One request for every federation, so it gets longer than a single mint probe does. */
|
||||
const OBSERVER_TIMEOUT_MS = 12_000;
|
||||
|
||||
export interface ObserverFederation {
|
||||
id: string;
|
||||
name: string | null;
|
||||
/** `online` or `offline` as published. Anything else is treated as unknown. */
|
||||
health: 'online' | 'offline' | null;
|
||||
}
|
||||
|
||||
/** Federation id (lowercase hex) to what the observer says about it. */
|
||||
export type ObserverIndex = Map<string, ObserverFederation>;
|
||||
|
||||
function readHealth(value: unknown): 'online' | 'offline' | null {
|
||||
if (value === 'online') return 'online';
|
||||
if (value === 'offline') return 'offline';
|
||||
// Anything else is a word this build has no meaning for, and guessing at it is
|
||||
// exactly what "no fake statuses" rules out.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the observer about every federation it tracks.
|
||||
*
|
||||
* Returns null when the lookup failed, which is deliberately distinguishable from an
|
||||
* empty map: an empty map means the observer answered and tracks nothing, and the
|
||||
* caller writes `announced` everywhere; null means it did not answer, and the caller
|
||||
* writes nothing at all.
|
||||
*/
|
||||
export async function fetchObserverIndex(userAgent: string): Promise<ObserverIndex | null> {
|
||||
if (!OBSERVER_URL.trim()) return new Map();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OBSERVER_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(OBSERVER_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': userAgent },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const body: unknown = await res.json();
|
||||
if (!Array.isArray(body)) throw new Error('not a JSON array');
|
||||
|
||||
const index: ObserverIndex = new Map();
|
||||
for (const entry of body) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const row = entry as Record<string, unknown>;
|
||||
const id = typeof row['id'] === 'string' ? row['id'].toLowerCase() : '';
|
||||
if (!/^[0-9a-f]{64}$/.test(id)) continue;
|
||||
index.set(id, {
|
||||
id,
|
||||
name: typeof row['name'] === 'string' ? row['name'] : null,
|
||||
health: readHealth(row['health']),
|
||||
});
|
||||
}
|
||||
|
||||
return index;
|
||||
} catch (err) {
|
||||
log.warn('fedimint observer unreachable', {
|
||||
url: OBSERVER_URL,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Read a response body up to `maxBytes`, or refuse it.
|
||||
*
|
||||
* `res.arrayBuffer()` and `res.json()` buffer however much the server sends before any
|
||||
* size check can run, and a mint is an untrusted server: the abort timer bounds how
|
||||
* *long* a read may take, this bounds how *large* it may get, and both are needed. The
|
||||
* Content-Length header is checked first as a courtesy — a chunked or lying response
|
||||
* still hits the streaming cap.
|
||||
*
|
||||
* Returns null when the body is over the limit or unreadable. The caller treats that
|
||||
* exactly like a failed fetch.
|
||||
*/
|
||||
export async function readBodyBounded(res: Response, maxBytes: number): Promise<Buffer | null> {
|
||||
const declared = Number(res.headers.get('content-length') ?? '');
|
||||
if (Number.isFinite(declared) && declared > maxBytes) return null;
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return null;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
// Aborted by the caller's timer, or the connection died mid-body.
|
||||
return null;
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
+57
-15
@@ -1,6 +1,8 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { isFetchableUrl } from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { readBodyBounded } from './http.ts';
|
||||
import { log } from './log.ts';
|
||||
import type { MintRow } from './mints.ts';
|
||||
|
||||
@@ -8,13 +10,54 @@ const EXT_BY_TYPE: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/gif': '.gif',
|
||||
'image/x-icon': '.ico',
|
||||
'image/vnd.microsoft.icon': '.ico',
|
||||
// No SVG on purpose: SVG is markup that can carry script, and these files are
|
||||
// re-served from the site's own origin, so a cached one opened directly would run a
|
||||
// mint operator's script there. The sandbox header in server.ts covers files cached
|
||||
// before this rule existed.
|
||||
};
|
||||
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
/** Redirect hops followed, each hop re-checked before it is fetched. */
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
/**
|
||||
* Fetch an icon with redirects validated hop by hop.
|
||||
*
|
||||
* `icon_url` is whatever the mint's /v1/info says it is, so every address on the way —
|
||||
* the first one and each Location after it — has to pass `isFetchableUrl`, or a public
|
||||
* URL that 302s to a metadata endpoint walks straight around a check done only once.
|
||||
*/
|
||||
async function fetchIconResponse(startUrl: string, signal: AbortSignal): Promise<Response | null> {
|
||||
let target = startUrl;
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
if (!isFetchableUrl(target)) return null;
|
||||
|
||||
const res = await fetch(target, {
|
||||
signal,
|
||||
redirect: 'manual',
|
||||
headers: { 'User-Agent': config.userAgent },
|
||||
});
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return null;
|
||||
try {
|
||||
target = new URL(location, target).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
return res.ok ? res : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a mint's icon to disk so offline mints keep theirs.
|
||||
@@ -30,27 +73,26 @@ export async function cacheIcon(row: MintRow, iconUrl: string | null): Promise<s
|
||||
|
||||
try {
|
||||
const absolute = new URL(iconUrl, `${row.url}/`).toString();
|
||||
if (!absolute.startsWith('https://') && !absolute.startsWith('http://')) return row.icon_file;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
|
||||
let res: Response;
|
||||
let buf: Buffer | null = null;
|
||||
let ext: string | undefined;
|
||||
try {
|
||||
res = await fetch(absolute, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': config.userAgent },
|
||||
});
|
||||
const res = await fetchIconResponse(absolute, controller.signal);
|
||||
if (!res) return row.icon_file;
|
||||
|
||||
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
|
||||
ext = EXT_BY_TYPE[type];
|
||||
if (!ext) return row.icon_file;
|
||||
|
||||
// The timer stays armed through the body read: the abort is what stops a server
|
||||
// that sends its headers quickly and then never finishes the body.
|
||||
buf = await readBodyBounded(res, MAX_ICON_BYTES);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!res.ok) return row.icon_file;
|
||||
|
||||
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
|
||||
const ext = EXT_BY_TYPE[type];
|
||||
if (!ext) return row.icon_file;
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.byteLength === 0 || buf.byteLength > MAX_ICON_BYTES) return row.icon_file;
|
||||
if (!buf || buf.byteLength === 0) return row.icon_file;
|
||||
|
||||
const filename = `${row.host}${ext}`;
|
||||
await fs.writeFile(path.join(config.iconDir, filename), buf);
|
||||
|
||||
+40
-16
@@ -17,17 +17,30 @@ function track<T>(work: Promise<T>): Promise<T> {
|
||||
return work;
|
||||
}
|
||||
|
||||
/*
|
||||
* One cycle of each kind at a time. A cycle that outlives its interval (a relay socket
|
||||
* that never closes, a wedged fetch) must not have the next timer tick start a second
|
||||
* copy beside it — that doubles the relay load and interleaves state writes for no
|
||||
* new information. The late cycle finishes; the ticks it swallowed are simply skipped.
|
||||
*/
|
||||
let probeRunning = false;
|
||||
let discoveryRunning = false;
|
||||
|
||||
async function probeCycle(): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
if (shuttingDown || probeRunning) return;
|
||||
probeRunning = true;
|
||||
try {
|
||||
await track(probeAll());
|
||||
} catch (err) {
|
||||
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
probeRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoveryCycle(backfill: boolean): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
if (shuttingDown || discoveryRunning) return;
|
||||
discoveryRunning = true;
|
||||
try {
|
||||
const result = await track(runDiscovery(backfill));
|
||||
// New mints are probed straight away, not on the next cycle, so their page has
|
||||
@@ -37,16 +50,20 @@ async function discoveryCycle(backfill: boolean): Promise<void> {
|
||||
log.error('discovery cycle failed', {
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
discoveryRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
getDb();
|
||||
async function main(): Promise<void> {
|
||||
// Fail loudly here rather than on the first request: a bad DATABASE_URL, an
|
||||
// unreachable Postgres or an unwritable SQLite directory should stop the boot.
|
||||
await getDb();
|
||||
|
||||
const server = serve({ fetch: createApp().fetch, port: config.port }, (info) => {
|
||||
log.info('api listening', {
|
||||
port: info.port,
|
||||
db: config.dbPath,
|
||||
db: config.db.label,
|
||||
relays: config.relays.length,
|
||||
});
|
||||
});
|
||||
@@ -57,8 +74,13 @@ function main(): void {
|
||||
config.discoveryIntervalMin * 60 * 1000,
|
||||
);
|
||||
const pruneTimer = setInterval(() => {
|
||||
const removed = pruneProbes();
|
||||
if (removed > 0) log.info('pruned probes', { rows: removed });
|
||||
void pruneProbes()
|
||||
.then((removed) => {
|
||||
if (removed > 0) log.info('pruned probes', { rows: removed });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log.error('prune failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}, DAY_MS);
|
||||
|
||||
// Startup: probe what we already know, then a full discovery backfill so a fresh
|
||||
@@ -74,17 +96,16 @@ function main(): void {
|
||||
clearInterval(discoveryTimer);
|
||||
clearInterval(pruneTimer);
|
||||
|
||||
const stop = (): void => {
|
||||
// closeDb drains the Postgres pool, so it is awaited before the process leaves.
|
||||
void closeDb().finally(() => process.exit(0));
|
||||
};
|
||||
|
||||
void inFlight.finally(() => {
|
||||
closePool();
|
||||
server.close(() => {
|
||||
closeDb();
|
||||
process.exit(0);
|
||||
});
|
||||
server.close(stop);
|
||||
// Do not hang forever on a relay socket that refuses to close.
|
||||
setTimeout(() => {
|
||||
closeDb();
|
||||
process.exit(0);
|
||||
}, 8000).unref();
|
||||
setTimeout(stop, 8000).unref();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -92,4 +113,7 @@ function main(): void {
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
main();
|
||||
void main().catch((err: unknown) => {
|
||||
log.error('startup failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Copy the whole database from one backend to the other.
|
||||
*
|
||||
* pnpm --filter ./api migrate --to postgres://user:pw@localhost/cashumints
|
||||
* pnpm --filter ./api migrate --from postgres://… --to ./data/cashumints.db
|
||||
*
|
||||
* `--from` defaults to whatever the running configuration points at (DATABASE_URL, or
|
||||
* DB_PATH's SQLite file), so the common direction — the SQLite file you already have,
|
||||
* into a fresh Postgres — needs only `--to`.
|
||||
*
|
||||
* Both sides accept either spelling, so this is also how you move one SQLite file to
|
||||
* another, or one Postgres database to another.
|
||||
*
|
||||
* The target's schema is created if it is missing. Rows are upserted on the primary
|
||||
* key, so an interrupted run can simply be repeated. `probes` is the exception: it is
|
||||
* an append-only log with no unique key, so a target that already holds probe rows is
|
||||
* left alone unless `--force`, which replaces them wholesale.
|
||||
*
|
||||
* Icons are files, not rows. `ICON_DIR` is shared by both backends and nothing here
|
||||
* touches it.
|
||||
*/
|
||||
import { parseDbTarget, resolveDbConfig, type DbConfig } from './config.ts';
|
||||
import type { Db, Param } from './db-driver.ts';
|
||||
import { COLUMNS, PRIMARY_KEY, TABLES, type TableName } from './db-schema.ts';
|
||||
import { openDb } from './db.ts';
|
||||
import { log } from './log.ts';
|
||||
|
||||
/** Rows per statement. 16 columns x 500 stays far under Postgres' 65535 parameter cap. */
|
||||
const BATCH = 500;
|
||||
|
||||
interface Options {
|
||||
from: DbConfig;
|
||||
to: DbConfig;
|
||||
force: boolean;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
'Usage: migrate --to <target> [--from <source>] [--force] [--dry-run]',
|
||||
'',
|
||||
' <target>, <source> postgres://user:pw@host:5432/db',
|
||||
' /var/lib/cashumints/cashumints.db',
|
||||
' sqlite:./data/cashumints.db',
|
||||
'',
|
||||
' --from defaults to the configured database (DATABASE_URL, else DB_PATH)',
|
||||
' --force replace the target probes table when it already has rows',
|
||||
' --dry-run report what would be copied, write nothing',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): Options {
|
||||
let from: string | undefined;
|
||||
let to: string | undefined;
|
||||
let force = false;
|
||||
let dryRun = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--from') from = argv[++i];
|
||||
else if (arg === '--to') to = argv[++i];
|
||||
else if (arg === '--force') force = true;
|
||||
else if (arg === '--dry-run') dryRun = true;
|
||||
else if (arg === '--help' || arg === '-h') throw new Error(usage());
|
||||
else throw new Error(`Unknown argument "${arg}"\n\n${usage()}`);
|
||||
}
|
||||
|
||||
if (!to) throw new Error(`--to is required.\n\n${usage()}`);
|
||||
|
||||
const source = from ? parseDbTarget(from) : resolveDbConfig();
|
||||
const target = parseDbTarget(to);
|
||||
|
||||
if (source.label === target.label) {
|
||||
throw new Error(`Source and target are the same database (${source.label}).`);
|
||||
}
|
||||
|
||||
return { from: source, to: target, force, dryRun };
|
||||
}
|
||||
|
||||
async function countRows(db: Db, table: TableName): Promise<number> {
|
||||
const row = await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
/** `INSERT … ON CONFLICT (pk) DO UPDATE`, or a plain insert for the keyless probes table. */
|
||||
function insertStatement(table: TableName, rows: number): string {
|
||||
const cols = COLUMNS[table];
|
||||
const placeholders = Array.from({ length: rows }, () => `(${cols.map(() => '?').join(', ')})`);
|
||||
const pk = PRIMARY_KEY[table];
|
||||
|
||||
const conflict =
|
||||
pk === null
|
||||
? ''
|
||||
: ` ON CONFLICT (${pk}) DO UPDATE SET ${cols
|
||||
.filter((c) => c !== pk)
|
||||
.map((c) => `${c} = excluded.${c}`)
|
||||
.join(', ')}`;
|
||||
|
||||
return `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}${conflict}`;
|
||||
}
|
||||
|
||||
async function copyTable(source: Db, target: Db, table: TableName): Promise<number> {
|
||||
const cols = COLUMNS[table];
|
||||
const pk = PRIMARY_KEY[table];
|
||||
|
||||
// Read the whole table. The largest of them is probes, at roughly 13k rows per mint
|
||||
// per 90-day retention window — tens of megabytes at the very top end, and paging it
|
||||
// would need a stable sort key that probes does not have.
|
||||
const rows = await source.all<Record<string, Param>>(`SELECT ${cols.join(', ')} FROM ${table}`);
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
// Same primary key twice in one statement is an error in Postgres, not a silent
|
||||
// overwrite. A source that is itself consistent cannot produce one, but a hand-edited
|
||||
// file can, and the failure is confusing enough to be worth heading off.
|
||||
const deduped =
|
||||
pk === null ? rows : [...new Map(rows.map((r) => [String(r[pk]), r])).values()];
|
||||
|
||||
await target.transaction(async (tx) => {
|
||||
for (let i = 0; i < deduped.length; i += BATCH) {
|
||||
const chunk = deduped.slice(i, i + BATCH);
|
||||
await tx.run(
|
||||
insertStatement(table, chunk.length),
|
||||
...chunk.flatMap((row) => cols.map((c) => row[c] ?? null)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return deduped.length;
|
||||
}
|
||||
|
||||
export async function migrate(options: Options): Promise<void> {
|
||||
log.info('migrating', { from: options.from.label, to: options.to.label });
|
||||
|
||||
const source = await openDb(options.from);
|
||||
let target: Db | null = null;
|
||||
|
||||
try {
|
||||
// openDb creates the schema, so the target may be a database with nothing in it.
|
||||
target = await openDb(options.to);
|
||||
|
||||
const existingProbes = await countRows(target, 'probes');
|
||||
if (existingProbes > 0 && !options.force && !options.dryRun) {
|
||||
throw new Error(
|
||||
`Target already holds ${existingProbes} probe rows. probes has no primary key, so ` +
|
||||
'they cannot be upserted. Re-run with --force to replace them, or drop the table first.',
|
||||
);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
for (const table of TABLES) {
|
||||
log.info('would copy', {
|
||||
table,
|
||||
rows: await countRows(source, table),
|
||||
target_rows: await countRows(target, table),
|
||||
});
|
||||
}
|
||||
log.info('dry run, nothing written');
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingProbes > 0) {
|
||||
await target.run('DELETE FROM probes');
|
||||
log.info('cleared target probes', { rows: existingProbes });
|
||||
}
|
||||
|
||||
for (const table of TABLES) {
|
||||
const started = Date.now();
|
||||
const copied = await copyTable(source, target, table);
|
||||
log.info('copied', { table, rows: copied, ms: Date.now() - started });
|
||||
}
|
||||
|
||||
// Read the counts back from the target rather than trusting the copy loop.
|
||||
for (const table of TABLES) {
|
||||
const [before, after] = await Promise.all([
|
||||
countRows(source, table),
|
||||
countRows(target, table),
|
||||
]);
|
||||
const verdict = after >= before ? 'ok' : 'SHORT';
|
||||
log.info('verify', { table, source: before, target: after, verdict });
|
||||
if (after < before) {
|
||||
throw new Error(`${table}: target has ${after} rows, source has ${before}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info('migration complete', { to: options.to.label });
|
||||
} finally {
|
||||
await source.close().catch(() => undefined);
|
||||
if (target) await target.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Only when run directly, so the functions above stay importable from a test.
|
||||
if (process.argv[1] && import.meta.filename === process.argv[1]) {
|
||||
try {
|
||||
await migrate(parseArgs(process.argv.slice(2)));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+165
-27
@@ -1,23 +1,33 @@
|
||||
import { normalizeMintUrl } from '@cashumints/shared';
|
||||
import {
|
||||
fedimintKey, fedimintSlug, normalizeMintUrl,
|
||||
type FedimintAnnouncement, type FedimintFields,
|
||||
} from '@cashumints/shared';
|
||||
import { getDb } from './db.ts';
|
||||
import { log } from './log.ts';
|
||||
|
||||
/**
|
||||
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
|
||||
* onion address) recurs in dozens of events per cycle, and logging each occurrence
|
||||
* buries the lines that matter.
|
||||
* URLs already reported as unusable. A rejected value (an onion address, a bare label)
|
||||
* recurs in dozens of events per cycle, and logging each occurrence buries the lines
|
||||
* that matter.
|
||||
*
|
||||
* Fedimint invite codes used to be the loudest entry in here, arriving as `u` tags this
|
||||
* function could make no sense of. They have their own path now and never reach it.
|
||||
*/
|
||||
const reportedSkips = new Set<string>();
|
||||
|
||||
export interface MintRow {
|
||||
url: string;
|
||||
host: string;
|
||||
/** 'cashu' or 'fedimint'. Rows written before the column existed default to 'cashu'. */
|
||||
type: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
icon_url: string | null;
|
||||
icon_file: string | null;
|
||||
pubkey: string | null;
|
||||
info_json: string | null;
|
||||
/** Type-specific data. `FedimintFields` for a federation, null for a Cashu mint. */
|
||||
ecosystem_json: string | null;
|
||||
nuts_json: string | null;
|
||||
version: string | null;
|
||||
status: string;
|
||||
@@ -44,7 +54,10 @@ export interface MintRow {
|
||||
* If that ever stops holding, the upgrade is to probe both casings once and keep the
|
||||
* one that answers.
|
||||
*/
|
||||
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
|
||||
export async function insertMintIfNew(
|
||||
rawUrl: string,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
): Promise<string | null> {
|
||||
const normalized = normalizeMintUrl(rawUrl);
|
||||
if (!normalized) {
|
||||
if (!reportedSkips.has(rawUrl)) {
|
||||
@@ -54,41 +67,166 @@ export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 10
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
// OR IGNORE covers both unique keys, url and host, in one clause.
|
||||
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
|
||||
VALUES (?, ?, 'unknown', ?, ?)`,
|
||||
)
|
||||
.run(normalized.url, normalized.host, now, now);
|
||||
const db = await getDb();
|
||||
// No conflict target, so this covers both unique keys — url and host — in one clause.
|
||||
// (`INSERT OR IGNORE` would too, but only SQLite knows that spelling.)
|
||||
const result = await db.run(
|
||||
`INSERT INTO mints (url, host, type, status, first_seen, updated_at)
|
||||
VALUES (?, ?, 'cashu', 'unknown', ?, ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
normalized.url,
|
||||
normalized.host,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
return result.changes > 0 ? normalized.url : null;
|
||||
if (result.changes > 0) return normalized.url;
|
||||
|
||||
/*
|
||||
* The no-op insert is almost always this exact URL already being tracked. The other
|
||||
* possibility is a *different* URL whose slug collides (`host/a-b` vs `host/a/b`
|
||||
* both slug to `host-a-b`): that mint is silently not tracked and any review of it
|
||||
* files under the other one, which is worth a log line the first time it happens.
|
||||
*/
|
||||
const holder = await db.get<{ url: string }>(
|
||||
'SELECT url FROM mints WHERE host = ?',
|
||||
normalized.host,
|
||||
);
|
||||
if (holder && holder.url !== normalized.url && !reportedSkips.has(normalized.url)) {
|
||||
reportedSkips.add(normalized.url);
|
||||
log.warn('slug collision, mint not tracked', {
|
||||
url: normalized.url,
|
||||
host: normalized.host,
|
||||
existing: holder.url,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- fedimint ---------- */
|
||||
|
||||
/** Read a federation row's type-specific columns back out. */
|
||||
export function parseEcosystem(row: Pick<MintRow, 'ecosystem_json'>): FedimintFields | null {
|
||||
if (!row.ecosystem_json) return null;
|
||||
try {
|
||||
return JSON.parse(row.ecosystem_json) as FedimintFields;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or refresh a federation from a kind 38173 announcement.
|
||||
*
|
||||
* Deduped on the federation id, which is the only identity a federation has: the same
|
||||
* federation is announced by several people (two different npubs currently announce
|
||||
* "Bitcoin Principles" with the same `d`), and every one of those is the same thing to
|
||||
* join. One row, keyed on the id, whoever published it.
|
||||
*
|
||||
* Unlike `insertMintIfNew` this does update an existing row, and it has to: an
|
||||
* announcement is the *only* source of a federation's invite codes, modules and name,
|
||||
* where a Cashu mint's row is refreshed by probing the mint itself. Older announcements
|
||||
* are ignored (`announced_at` goes forwards only) so a replayed event from last year
|
||||
* cannot overwrite this week's invite code.
|
||||
*
|
||||
* The status columns are never touched here. Whether a federation is up is a probe's
|
||||
* answer, and an announcement is not evidence of anything being up.
|
||||
*
|
||||
* Returns the row's key when a row was created, null when one was merely updated.
|
||||
*/
|
||||
export async function upsertFedimint(
|
||||
announcement: FedimintAnnouncement,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const url = fedimintKey(announcement.federationId);
|
||||
const host = fedimintSlug(announcement.federationId);
|
||||
|
||||
const fields: FedimintFields = {
|
||||
federation_id: announcement.federationId,
|
||||
invite_codes: announcement.inviteCodes,
|
||||
modules: announcement.modules,
|
||||
network: announcement.network,
|
||||
announced_at: announcement.announcedAt,
|
||||
announcer_pubkey: announcement.announcerPubkey,
|
||||
// Set by the probe, not by an announcement. Carried over below when a row exists.
|
||||
status_source: null,
|
||||
};
|
||||
|
||||
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
|
||||
|
||||
if (!existing) {
|
||||
await db.run(
|
||||
`INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json,
|
||||
status, first_seen, updated_at)
|
||||
VALUES (?, ?, 'fedimint', ?, ?, ?, ?, 'announced', ?, ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
url,
|
||||
host,
|
||||
announcement.name,
|
||||
announcement.about,
|
||||
announcement.picture,
|
||||
JSON.stringify(fields),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
const previous = parseEcosystem(existing);
|
||||
if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null;
|
||||
|
||||
await db.run(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
icon_url = COALESCE(?, icon_url),
|
||||
ecosystem_json = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
announcement.name,
|
||||
announcement.about,
|
||||
announcement.picture,
|
||||
JSON.stringify({ ...fields, status_source: previous?.status_source ?? null }),
|
||||
now,
|
||||
url,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every federation row, for the probe cycle and for review resolution. */
|
||||
export async function fedimintRows(): Promise<MintRow[]> {
|
||||
const db = await getDb();
|
||||
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'fedimint'`);
|
||||
}
|
||||
|
||||
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
|
||||
export function mintUrlByHost(host: string): string | null {
|
||||
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
|
||||
| { url: string }
|
||||
| undefined;
|
||||
export async function mintUrlByHost(host: string): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', host);
|
||||
return row?.url ?? null;
|
||||
}
|
||||
|
||||
export function allMintRows(): MintRow[] {
|
||||
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
|
||||
export async function allMintRows(): Promise<MintRow[]> {
|
||||
const db = await getDb();
|
||||
return db.all<MintRow>('SELECT * FROM mints');
|
||||
}
|
||||
|
||||
export function mintByHost(host: string): MintRow | undefined {
|
||||
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
|
||||
export async function mintByHost(host: string): Promise<MintRow | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get<MintRow>('SELECT * FROM mints WHERE host = ?', host);
|
||||
}
|
||||
|
||||
export function mintByUrl(url: string): MintRow | undefined {
|
||||
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
|
||||
export async function mintByUrl(url: string): Promise<MintRow | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
|
||||
}
|
||||
|
||||
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
|
||||
export function mintUrlByPubkey(pubkey: string): string | null {
|
||||
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
|
||||
| { url: string }
|
||||
| undefined;
|
||||
export async function mintUrlByPubkey(pubkey: string): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE pubkey = ? LIMIT 1', pubkey);
|
||||
return row?.url ?? null;
|
||||
}
|
||||
|
||||
+163
-19
@@ -1,9 +1,11 @@
|
||||
import { parseNuts, type MintInfo } from '@cashumints/shared';
|
||||
import { parseNuts, type FedimintFields, type MintInfo } from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { getDb, setState } from './db.ts';
|
||||
import { fetchObserverIndex, OBSERVER_SOURCE, type ObserverIndex } from './fedimint-observer.ts';
|
||||
import { readBodyBounded } from './http.ts';
|
||||
import { cacheIcon } from './icons.ts';
|
||||
import { log } from './log.ts';
|
||||
import { allMintRows, mintByUrl, type MintRow } from './mints.ts';
|
||||
import { allMintRows, mintByUrl, parseEcosystem, type MintRow } from './mints.ts';
|
||||
|
||||
export interface ProbeResult {
|
||||
url: string;
|
||||
@@ -19,6 +21,13 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
|
||||
return fails < 3 ? 'degraded' : 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest /v1/info accepted. Real payloads are a few KB; the whole document is
|
||||
* stored in `info_json` and served back on every detail request, so an unbounded one
|
||||
* is both a memory spike here and a bloated row forever after.
|
||||
*/
|
||||
const MAX_INFO_BYTES = 256 * 1024;
|
||||
|
||||
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
@@ -32,7 +41,10 @@ async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: numb
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const info = (await res.json()) as MintInfo;
|
||||
const body = await readBodyBounded(res, MAX_INFO_BYTES);
|
||||
if (!body) throw new Error(`info larger than ${MAX_INFO_BYTES} bytes or unreadable`);
|
||||
|
||||
const info = JSON.parse(body.toString('utf8')) as MintInfo;
|
||||
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
|
||||
|
||||
return { info, latencyMs: Date.now() - started };
|
||||
@@ -48,7 +60,7 @@ async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: numb
|
||||
* what lets an offline mint still render its full page, which is acceptance criterion #1.
|
||||
*/
|
||||
export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
const db = getDb();
|
||||
const db = await getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
@@ -57,7 +69,7 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
|
||||
const iconFile = await cacheIcon(row, info.icon_url ?? null);
|
||||
|
||||
db.prepare(
|
||||
await db.run(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
@@ -73,7 +85,6 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
).run(
|
||||
info.name ?? null,
|
||||
info.description ?? null,
|
||||
info.icon_url ?? null,
|
||||
@@ -88,7 +99,8 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
row.url,
|
||||
);
|
||||
|
||||
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)').run(
|
||||
await db.run(
|
||||
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
|
||||
row.url,
|
||||
now,
|
||||
latencyMs,
|
||||
@@ -102,12 +114,18 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
const fails = row.consecutive_fails + 1;
|
||||
const status = statusForFails(fails);
|
||||
|
||||
db.prepare(
|
||||
await db.run(
|
||||
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
|
||||
WHERE url = ?`,
|
||||
).run(fails, status, now, now, row.url);
|
||||
fails,
|
||||
status,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)').run(
|
||||
await db.run(
|
||||
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
|
||||
row.url,
|
||||
now,
|
||||
);
|
||||
@@ -127,6 +145,112 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- fedimint ---------- */
|
||||
|
||||
/**
|
||||
* Write what a check said about one federation.
|
||||
*
|
||||
* Three outcomes, and the third is the one that matters most. `online` and `offline`
|
||||
* are recorded exactly as a mint probe records them, probe row and all, so uptime and
|
||||
* the sparkline work the same way. A federation the checker does not cover gets
|
||||
* `announced`, which is a statement about this site's reach and not about the
|
||||
* federation: no probe row is written for it, because nothing was probed, and
|
||||
* `last_online` stays null so nothing downstream can render a "last seen" it invented.
|
||||
*/
|
||||
async function applyFederationHealth(
|
||||
row: MintRow,
|
||||
health: 'online' | 'offline' | null,
|
||||
now: number,
|
||||
): Promise<ProbeResult> {
|
||||
const db = await getDb();
|
||||
const previous = parseEcosystem(row);
|
||||
|
||||
const ecosystem = (source: string | null): string =>
|
||||
JSON.stringify({ ...(previous ?? {}), status_source: source } as FedimintFields);
|
||||
|
||||
if (health === null) {
|
||||
// Nothing checks this federation. Say so, and leave every column a real check
|
||||
// would have written alone.
|
||||
const status = 'announced';
|
||||
const changed = row.status !== status;
|
||||
await db.run(
|
||||
`UPDATE mints SET status = ?, ecosystem_json = ?, last_probe = ?, updated_at = ?
|
||||
WHERE url = ?`,
|
||||
status,
|
||||
ecosystem(null),
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: status });
|
||||
return { url: row.url, ok: false, latencyMs: null, status, changed };
|
||||
}
|
||||
|
||||
const ok = health === 'online';
|
||||
const status = ok ? 'online' : 'offline';
|
||||
|
||||
await db.run(
|
||||
`UPDATE mints SET
|
||||
status = ?,
|
||||
consecutive_fails = ?,
|
||||
ecosystem_json = ?,
|
||||
last_online = COALESCE(?, last_online),
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
status,
|
||||
ok ? 0 : row.consecutive_fails + 1,
|
||||
ecosystem(OBSERVER_SOURCE),
|
||||
ok ? now : null,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, ?, NULL)',
|
||||
row.url,
|
||||
now,
|
||||
ok ? 1 : 0,
|
||||
);
|
||||
|
||||
const changed = row.status !== status;
|
||||
if (changed) {
|
||||
log.info('mint state change', {
|
||||
url: row.url,
|
||||
from: row.status,
|
||||
to: status,
|
||||
source: OBSERVER_SOURCE,
|
||||
});
|
||||
}
|
||||
|
||||
return { url: row.url, ok, latencyMs: null, status, changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* One lookup for every federation, rather than one request each.
|
||||
*
|
||||
* The checker publishes its whole table in a single response, so asking per row would
|
||||
* be the same answer fetched fifty times. Returns an empty list when the lookup itself
|
||||
* failed: no answer is not a result, and writing one would be inventing it.
|
||||
*/
|
||||
async function checkFederations(rows: MintRow[], now: number): Promise<ProbeResult[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const index: ObserverIndex | null = await fetchObserverIndex(config.userAgent);
|
||||
if (!index) {
|
||||
log.warn('fedimint check skipped', { federations: rows.length, reason: 'no observer data' });
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: ProbeResult[] = [];
|
||||
for (const row of rows) {
|
||||
const federationId = parseEcosystem(row)?.federation_id ?? '';
|
||||
results.push(await applyFederationHealth(row, index.get(federationId)?.health ?? null, now));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Run `tasks` with at most `limit` in flight. */
|
||||
async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> {
|
||||
let cursor = 0;
|
||||
@@ -139,31 +263,51 @@ async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unk
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
export async function probeAll(rows: MintRow[] = allMintRows()): Promise<ProbeResult[]> {
|
||||
if (rows.length === 0) return [];
|
||||
export async function probeAll(rows?: MintRow[]): Promise<ProbeResult[]> {
|
||||
const targets = rows ?? (await allMintRows());
|
||||
if (targets.length === 0) return [];
|
||||
|
||||
const started = Date.now();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Two ecosystems, two entirely different checks: an HTTP fetch per mint, and one
|
||||
// lookup covering every federation. Split here rather than inside the worker so the
|
||||
// federations are not each waiting behind a slot in the mint pool.
|
||||
const mints = targets.filter((row) => row.type !== 'fedimint');
|
||||
const federations = targets.filter((row) => row.type === 'fedimint');
|
||||
|
||||
const results: ProbeResult[] = [];
|
||||
await pooled(rows, config.probeConcurrency, async (row) => {
|
||||
results.push(await probeMint(row));
|
||||
});
|
||||
const [, federationResults] = await Promise.all([
|
||||
pooled(mints, config.probeConcurrency, async (row) => {
|
||||
results.push(await probeMint(row));
|
||||
}),
|
||||
checkFederations(federations, now),
|
||||
]);
|
||||
results.push(...federationResults);
|
||||
|
||||
const online = results.filter((r) => r.ok).length;
|
||||
const changed = results.filter((r) => r.changed).length;
|
||||
log.info('probe cycle', {
|
||||
mints: results.length,
|
||||
mints: mints.length,
|
||||
federations: federations.length,
|
||||
online,
|
||||
down: results.length - online,
|
||||
changed,
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
|
||||
setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
|
||||
// Only a full sweep counts for /api/health. probeUrls passes a handful of newly
|
||||
// discovered rows through here, and stamping the health marker for those would let
|
||||
// a fresh discovery hide hours of failed probe cycles.
|
||||
if (rows === undefined) {
|
||||
await setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Probe a set of URLs immediately, used when discovery finds new mints. */
|
||||
/** Probe a set of keys immediately, used when discovery finds new mints or federations. */
|
||||
export async function probeUrls(urls: string[]): Promise<void> {
|
||||
const rows = urls.map((u) => mintByUrl(u)).filter((r): r is MintRow => r !== undefined);
|
||||
const found = await Promise.all(urls.map((u) => mintByUrl(u)));
|
||||
const rows = found.filter((r): r is MintRow => r !== undefined);
|
||||
if (rows.length > 0) await probeAll(rows);
|
||||
}
|
||||
|
||||
+183
-88
@@ -8,13 +8,14 @@ import {
|
||||
type MintInfo,
|
||||
type MintListItem,
|
||||
type MintStatus,
|
||||
type MintType,
|
||||
type ProbeSample,
|
||||
type RatingDistribution,
|
||||
type Stats,
|
||||
} from '@cashumints/shared';
|
||||
import { config, startedAt } from './config.ts';
|
||||
import { getDb, getStateNumber, getState } from './db.ts';
|
||||
import { mintByHost, type MintRow } from './mints.ts';
|
||||
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
|
||||
|
||||
/**
|
||||
* One review per author per mint, newest wins.
|
||||
@@ -23,15 +24,18 @@ import { mintByHost, type MintRow } from './mints.ts';
|
||||
* replaces the earlier one. The old site applied the same rule in memory
|
||||
* (aggregateReviews, keyed by pubkey); doing it in SQL keeps counts honest and stops
|
||||
* one npub from moving a mint's average by re-posting.
|
||||
*
|
||||
* The `AS ranked` alias is not decoration: Postgres rejects an unaliased subquery in
|
||||
* FROM, and SQLite does not care either way.
|
||||
*/
|
||||
const LATEST_REVIEWS = `
|
||||
export const LATEST_REVIEWS = `
|
||||
SELECT mint_url, pubkey, rating, created_at FROM (
|
||||
SELECT mint_url, pubkey, rating, created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id
|
||||
) AS rn
|
||||
FROM reviews
|
||||
) WHERE rn = 1
|
||||
) AS ranked WHERE rn = 1
|
||||
`;
|
||||
|
||||
interface AggRow {
|
||||
@@ -41,17 +45,26 @@ interface AggRow {
|
||||
last_review_at: number | null;
|
||||
}
|
||||
|
||||
function aggregates(): Map<string, AggRow> {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT mint_url,
|
||||
COUNT(*) AS review_count,
|
||||
AVG(rating) AS rating_avg,
|
||||
MAX(created_at) AS last_review_at
|
||||
FROM latest GROUP BY mint_url`,
|
||||
)
|
||||
.all() as AggRow[];
|
||||
/**
|
||||
* Review counts and averages, for one mint or for all of them.
|
||||
*
|
||||
* A detail page passes its own URL. Computing the whole table to read one row off it
|
||||
* is free on a local SQLite file and is not on a Postgres server across a socket.
|
||||
*/
|
||||
async function aggregates(mintUrl?: string): Promise<Map<string, AggRow>> {
|
||||
const db = await getDb();
|
||||
const where = mintUrl === undefined ? '' : 'WHERE mint_url = ?';
|
||||
const params = mintUrl === undefined ? [] : [mintUrl];
|
||||
|
||||
const rows = await db.all<AggRow>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT mint_url,
|
||||
COUNT(*) AS review_count,
|
||||
AVG(rating) AS rating_avg,
|
||||
MAX(created_at) AS last_review_at
|
||||
FROM latest ${where} GROUP BY mint_url`,
|
||||
...params,
|
||||
);
|
||||
|
||||
return new Map(rows.map((r) => [r.mint_url, r]));
|
||||
}
|
||||
@@ -64,7 +77,7 @@ function aggregates(): Map<string, AggRow> {
|
||||
* fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md
|
||||
* behaviour, or set any number to pin it.
|
||||
*/
|
||||
function priorMean(): number {
|
||||
async function priorMean(): Promise<number> {
|
||||
const override = process.env['SCORE_PRIOR_MEAN'];
|
||||
|
||||
if (override && override !== 'global') {
|
||||
@@ -73,10 +86,11 @@ function priorMean(): number {
|
||||
}
|
||||
|
||||
if (override === 'global') {
|
||||
const row = getDb()
|
||||
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`)
|
||||
.get() as { avg: number | null };
|
||||
return row.avg ?? NEUTRAL_PRIOR_MEAN;
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ avg: number | null }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`,
|
||||
);
|
||||
return row?.avg ?? NEUTRAL_PRIOR_MEAN;
|
||||
}
|
||||
|
||||
return NEUTRAL_PRIOR_MEAN;
|
||||
@@ -103,6 +117,7 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
||||
host: row.host,
|
||||
name: row.name,
|
||||
icon: iconPath(row),
|
||||
type: row.type as MintType,
|
||||
status: row.status as MintStatus,
|
||||
last_online: row.last_online,
|
||||
review_count: base.review_count,
|
||||
@@ -113,26 +128,38 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
||||
};
|
||||
}
|
||||
|
||||
export function listMints(limit?: number): MintListItem[] {
|
||||
/**
|
||||
* The list, optionally narrowed to one ecosystem.
|
||||
*
|
||||
* `type` is filtered in SQL rather than after scoring, because the two list pages ask
|
||||
* for one ecosystem each and there is no reason to score fifty federations to render
|
||||
* /mints. Unfiltered still returns everything, so `/api/mints` on its own is the whole
|
||||
* index with a `type` on every item.
|
||||
*/
|
||||
export async function listMints(limit?: number, type?: string): Promise<MintListItem[]> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const agg = aggregates();
|
||||
const mean = priorMean();
|
||||
const db = await getDb();
|
||||
const [agg, mean, rows] = await Promise.all([
|
||||
aggregates(),
|
||||
priorMean(),
|
||||
type
|
||||
? db.all<MintRow>('SELECT * FROM mints WHERE type = ?', type)
|
||||
: db.all<MintRow>('SELECT * FROM mints'),
|
||||
]);
|
||||
|
||||
const items = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
|
||||
.map((row) => toListItem(row, agg.get(row.url), mean, now))
|
||||
.sort(compareMints);
|
||||
const items = rows.map((row) => toListItem(row, agg.get(row.url), mean, now)).sort(compareMints);
|
||||
|
||||
return limit && limit > 0 ? items.slice(0, limit) : items;
|
||||
}
|
||||
|
||||
function distribution(mintUrl: string): RatingDistribution {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT rating, COUNT(*) AS n FROM latest
|
||||
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
|
||||
)
|
||||
.all(mintUrl) as { rating: number; n: number }[];
|
||||
async function distribution(mintUrl: string): Promise<RatingDistribution> {
|
||||
const db = await getDb();
|
||||
const rows = await db.all<{ rating: number; n: number }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT rating, COUNT(*) AS n FROM latest
|
||||
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
|
||||
mintUrl,
|
||||
);
|
||||
|
||||
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
|
||||
for (const r of rows) {
|
||||
@@ -142,34 +169,39 @@ function distribution(mintUrl: string): RatingDistribution {
|
||||
return dist;
|
||||
}
|
||||
|
||||
function reviews90d(mintUrl: string): number {
|
||||
async function reviews90d(mintUrl: string): Promise<number> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
|
||||
)
|
||||
.get(mintUrl, cutoff) as { n: number };
|
||||
return row.n;
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ n: number }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
|
||||
mintUrl,
|
||||
cutoff,
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
function uptime30d(mintUrl: string): number | null {
|
||||
async function uptime30d(mintUrl: string): Promise<number | null> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
|
||||
const row = getDb()
|
||||
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
|
||||
.get(mintUrl, cutoff) as { up: number | null; n: number };
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ up: number | null; n: number }>(
|
||||
'SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?',
|
||||
mintUrl,
|
||||
cutoff,
|
||||
);
|
||||
|
||||
if (!row.n || row.up === null) return null;
|
||||
if (!row?.n || row.up === null) return null;
|
||||
return Math.round(row.up * 1000) / 1000;
|
||||
}
|
||||
|
||||
function recentProbes(mintUrl: string): ProbeSample[] {
|
||||
async function recentProbes(mintUrl: string): Promise<ProbeSample[]> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
|
||||
return getDb()
|
||||
.prepare(
|
||||
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
|
||||
)
|
||||
.all(mintUrl, cutoff) as ProbeSample[];
|
||||
const db = await getDb();
|
||||
return db.all<ProbeSample>(
|
||||
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
|
||||
mintUrl,
|
||||
cutoff,
|
||||
);
|
||||
}
|
||||
|
||||
function parseInfo(json: string | null): MintInfo | null {
|
||||
@@ -181,13 +213,23 @@ function parseInfo(json: string | null): MintInfo | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getMintDetail(host: string): MintDetail | null {
|
||||
const row = mintByHost(host);
|
||||
export async function getMintDetail(host: string): Promise<MintDetail | null> {
|
||||
const row = await mintByHost(host);
|
||||
if (!row) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const agg = aggregates().get(row.url);
|
||||
const item = toListItem(row, agg, priorMean(), now);
|
||||
// One round trip's worth of latency instead of six, which is the difference between
|
||||
// a local file and a Postgres server on another host.
|
||||
const [agg, mean, ratingDistribution, reviews, uptime, probes] = await Promise.all([
|
||||
aggregates(row.url),
|
||||
priorMean(),
|
||||
distribution(row.url),
|
||||
reviews90d(row.url),
|
||||
uptime30d(row.url),
|
||||
recentProbes(row.url),
|
||||
]);
|
||||
|
||||
const item = toListItem(row, agg.get(row.url), mean, now);
|
||||
const info = parseInfo(row.info_json);
|
||||
|
||||
let nuts: string[] = [];
|
||||
@@ -200,8 +242,20 @@ export function getMintDetail(host: string): MintDetail | null {
|
||||
}
|
||||
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
|
||||
|
||||
/*
|
||||
* Type-specific columns are spread across the payload rather than nested under a key.
|
||||
*
|
||||
* That is what keeps a Cashu detail byte for byte what it was: `ecosystem_json` is
|
||||
* null for a mint, so nothing is added and no consumer sees a new empty object to
|
||||
* handle. A federation gets `federation_id`, `invite_codes`, `modules`, `network` and
|
||||
* the rest at the top level, where the page reads them beside `status` and `name`
|
||||
* without unwrapping anything.
|
||||
*/
|
||||
const ecosystem = parseEcosystem(row);
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(ecosystem ?? {}),
|
||||
description: row.description,
|
||||
pubkey: row.pubkey,
|
||||
info,
|
||||
@@ -209,58 +263,99 @@ export function getMintDetail(host: string): MintDetail | null {
|
||||
first_seen: row.first_seen,
|
||||
last_probe: row.last_probe,
|
||||
updated_at: row.updated_at,
|
||||
rating_distribution: distribution(row.url),
|
||||
reviews_90d: reviews90d(row.url),
|
||||
uptime_30d: uptime30d(row.url),
|
||||
probes_recent: recentProbes(row.url),
|
||||
rating_distribution: ratingDistribution,
|
||||
reviews_90d: reviews,
|
||||
uptime_30d: uptime,
|
||||
probes_recent: probes,
|
||||
};
|
||||
}
|
||||
|
||||
let statsCache: { at: number; value: Stats } | null = null;
|
||||
const STATS_TTL_S = 60;
|
||||
|
||||
export function getStats(): Stats {
|
||||
export async function getStats(): Promise<Stats> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (statsCache && now - statsCache.at < STATS_TTL_S) return statsCache.value;
|
||||
|
||||
const db = getDb();
|
||||
const counts = db
|
||||
.prepare(
|
||||
const db = await getDb();
|
||||
/*
|
||||
* The four `mints_*` fields are scoped to `type = 'cashu'`, which is what they always
|
||||
* counted and what every consumer of them still means: the pulse ticker's "mints
|
||||
* indexed", the /mints page description, the home page. Letting federations quietly
|
||||
* inflate a number three pages print in a sentence would be a worse kind of breakage
|
||||
* than a missing field, because nothing would fail — the sentences would just stop
|
||||
* being true.
|
||||
*
|
||||
* COUNT(*) FILTER, not SUM(status = 'online'): Postgres has no implicit cast from
|
||||
* boolean to integer, so the SQLite spelling is a type error there.
|
||||
*/
|
||||
const [counts, federations, reviews, fedimintReviews] = await Promise.all([
|
||||
db.get<{ total: number; online: number; offline: number; degraded: number }>(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 'online') AS online,
|
||||
SUM(status = 'offline') AS offline,
|
||||
SUM(status = 'degraded') AS degraded
|
||||
FROM mints`,
|
||||
)
|
||||
.get() as { total: number; online: number | null; offline: number | null; degraded: number | null };
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'online') AS online,
|
||||
COUNT(*) FILTER (WHERE status = 'offline') AS offline,
|
||||
COUNT(*) FILTER (WHERE status = 'degraded') AS degraded
|
||||
FROM mints WHERE type = 'cashu'`,
|
||||
),
|
||||
db.get<{ total: number; online: number; offline: number; announced: number }>(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'online') AS online,
|
||||
COUNT(*) FILTER (WHERE status = 'offline') AS offline,
|
||||
COUNT(*) FILTER (WHERE status = 'announced') AS announced
|
||||
FROM mints WHERE type = 'fedimint'`,
|
||||
),
|
||||
db.get<{ n: number; last: number | null }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`,
|
||||
),
|
||||
db.get<{ n: number }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT COUNT(*) AS n FROM latest
|
||||
WHERE mint_url IN (SELECT url FROM mints WHERE type = 'fedimint')`,
|
||||
),
|
||||
]);
|
||||
|
||||
const reviews = db
|
||||
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`)
|
||||
.get() as { n: number; last: number | null };
|
||||
const cashuTotal = counts?.total ?? 0;
|
||||
|
||||
const value: Stats = {
|
||||
mints_total: counts.total,
|
||||
mints_online: counts.online ?? 0,
|
||||
mints_offline: counts.offline ?? 0,
|
||||
mints_degraded: counts.degraded ?? 0,
|
||||
reviews_total: reviews.n,
|
||||
last_review_at: reviews.last,
|
||||
mints_total: cashuTotal,
|
||||
mints_online: counts?.online ?? 0,
|
||||
mints_offline: counts?.offline ?? 0,
|
||||
mints_degraded: counts?.degraded ?? 0,
|
||||
reviews_total: reviews?.n ?? 0,
|
||||
last_review_at: reviews?.last ?? null,
|
||||
updated_at: now,
|
||||
cashu_total: cashuTotal,
|
||||
fedimint_total: federations?.total ?? 0,
|
||||
fedimint_online: federations?.online ?? 0,
|
||||
fedimint_offline: federations?.offline ?? 0,
|
||||
fedimint_announced: federations?.announced ?? 0,
|
||||
fedimint_reviews: fedimintReviews?.n ?? 0,
|
||||
};
|
||||
|
||||
statsCache = { at: now, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Health bypasses the stats cache: it is the endpoint you page on. */
|
||||
export function getHealth(): Health {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastProbe = getStateNumber('last_probe_at');
|
||||
const lastDiscovery = getStateNumber('last_discovery_at');
|
||||
const discoveryOk = getState('last_discovery_ok') !== '0';
|
||||
/** Drop the in-process stats cache. For tests that mutate the database underneath it. */
|
||||
export function resetStatsCache(): void {
|
||||
statsCache = null;
|
||||
}
|
||||
|
||||
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
|
||||
/** Health bypasses the stats cache: it is the endpoint you page on. */
|
||||
export async function getHealth(): Promise<Health> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const db = await getDb();
|
||||
|
||||
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([
|
||||
getStateNumber('last_probe_at'),
|
||||
getStateNumber('last_discovery_at'),
|
||||
getState('last_discovery_ok'),
|
||||
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
|
||||
]);
|
||||
|
||||
const discoveryOk = discoveryOkRaw !== '0';
|
||||
const staleAfter = config.probeIntervalMin * 60 * 3;
|
||||
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
|
||||
|
||||
@@ -269,7 +364,7 @@ export function getHealth(): Health {
|
||||
uptime_s: now - startedAt,
|
||||
last_probe_at: lastProbe,
|
||||
last_discovery_at: lastDiscovery,
|
||||
mints_tracked: tracked,
|
||||
mints_tracked: tracked?.n ?? 0,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
+30
-12
@@ -59,10 +59,11 @@ function pad(s: string, width: number): string {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
getDb();
|
||||
const db = await getDb();
|
||||
log.info('seeding', { db: db.label });
|
||||
|
||||
let added = 0;
|
||||
for (const url of SEED_MINTS) if (insertMintIfNew(url)) added++;
|
||||
for (const url of SEED_MINTS) if (await insertMintIfNew(url)) added++;
|
||||
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
|
||||
|
||||
await probeAll();
|
||||
@@ -75,13 +76,14 @@ async function main(): Promise<void> {
|
||||
await probeAll();
|
||||
}
|
||||
|
||||
const mints = listMints();
|
||||
const width = { host: 44, status: 9, score: 7, reviews: 8, rating: 7 };
|
||||
const listed = await listMints();
|
||||
const width = { host: 44, type: 9, status: 10, score: 7, reviews: 8, rating: 7 };
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
[
|
||||
pad('MINT', width.host),
|
||||
pad('TYPE', width.type),
|
||||
pad('STATUS', width.status),
|
||||
pad('SCORE', width.score),
|
||||
pad('REVIEWS', width.reviews),
|
||||
@@ -89,12 +91,13 @@ async function main(): Promise<void> {
|
||||
'VERSION',
|
||||
].join(' '),
|
||||
);
|
||||
console.log('-'.repeat(110));
|
||||
console.log('-'.repeat(120));
|
||||
|
||||
for (const m of mints) {
|
||||
for (const m of listed) {
|
||||
console.log(
|
||||
[
|
||||
pad(m.host, width.host),
|
||||
pad(m.type, width.type),
|
||||
pad(m.status, width.status),
|
||||
pad(m.score.toFixed(3), width.score),
|
||||
pad(String(m.review_count), width.reviews),
|
||||
@@ -104,16 +107,31 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
const reviews = mints.reduce((sum, m) => sum + m.review_count, 0);
|
||||
console.log('-'.repeat(110));
|
||||
const mints = listed.filter((m) => m.type === 'cashu');
|
||||
const federations = listed.filter((m) => m.type === 'fedimint');
|
||||
const count = (rows: typeof listed, status: string) =>
|
||||
rows.filter((m) => m.status === status).length;
|
||||
const reviews = listed.reduce((sum, m) => sum + m.review_count, 0);
|
||||
|
||||
console.log('-'.repeat(120));
|
||||
console.log(
|
||||
`${mints.length} mints (${online} online, ${offline} offline), ${reviews} reviews indexed`,
|
||||
`${mints.length} mints (${count(mints, 'online')} online, ${count(mints, 'offline')} offline)`,
|
||||
);
|
||||
/*
|
||||
* Federations are counted with `announced` called out rather than folded into the
|
||||
* offline total. It is the honest word for "nothing checks this one", and rolling it
|
||||
* into either of the other two would be the first place the site started claiming to
|
||||
* know something it does not.
|
||||
*/
|
||||
console.log(
|
||||
`${federations.length} federations (${count(federations, 'online')} confirmed up, ` +
|
||||
`${count(federations, 'offline')} reported down, ` +
|
||||
`${count(federations, 'announced')} announced only)`,
|
||||
);
|
||||
console.log(`${reviews} reviews indexed`);
|
||||
|
||||
closePool();
|
||||
closeDb();
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
+24
-8
@@ -12,22 +12,31 @@ export function createApp(): Hono {
|
||||
app.use('/api/*', cors());
|
||||
app.use('/icons/*', cors());
|
||||
|
||||
app.get('/api/health', (c) => {
|
||||
const health = getHealth();
|
||||
app.get('/api/health', async (c) => {
|
||||
const health = await getHealth();
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json(health, health.status === 'ok' ? 200 : 503);
|
||||
});
|
||||
|
||||
app.get('/api/stats', (c) => c.json(getStats()));
|
||||
app.get('/api/stats', async (c) => c.json(await getStats()));
|
||||
|
||||
app.get('/api/mints', (c) => {
|
||||
/*
|
||||
* The whole index, every ecosystem, each item carrying its `type`.
|
||||
*
|
||||
* `?type=cashu` / `?type=fedimint` narrows it, which is what the two list pages ask
|
||||
* for at build time. Unknown values are passed through rather than rejected: they
|
||||
* return an empty list, which is the honest answer to "show me the ecosystem this
|
||||
* build does not have".
|
||||
*/
|
||||
app.get('/api/mints', async (c) => {
|
||||
const raw = c.req.query('limit');
|
||||
const limit = raw ? Number.parseInt(raw, 10) : undefined;
|
||||
return c.json(listMints(Number.isFinite(limit) ? limit : undefined));
|
||||
const type = c.req.query('type')?.trim() || undefined;
|
||||
return c.json(await listMints(Number.isFinite(limit) ? limit : undefined, type));
|
||||
});
|
||||
|
||||
app.get('/api/mints/:host', (c) => {
|
||||
const detail = getMintDetail(c.req.param('host'));
|
||||
app.get('/api/mints/:host', async (c) => {
|
||||
const detail = await getMintDetail(c.req.param('host'));
|
||||
if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404);
|
||||
return c.json(detail);
|
||||
});
|
||||
@@ -38,7 +47,14 @@ export function createApp(): Hono {
|
||||
serveStatic({
|
||||
root: path.relative(process.cwd(), config.iconDir) || '.',
|
||||
rewriteRequestPath: (p) => p.replace(/^\/icons/, ''),
|
||||
onFound: (_p, c) => c.header('Cache-Control', 'public, max-age=86400'),
|
||||
onFound: (_p, c) => {
|
||||
c.header('Cache-Control', 'public, max-age=86400');
|
||||
// These files came from mint operators. nosniff pins the served type, and the
|
||||
// sandbox neutralizes anything script-capable (an SVG cached before icons.ts
|
||||
// stopped accepting them) when the file is opened directly on this origin.
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('Content-Security-Policy', 'sandbox');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Generated
+403
@@ -25,6 +25,9 @@ importers:
|
||||
nostr-tools:
|
||||
specifier: ^2.10.4
|
||||
version: 2.24.3(typescript@5.9.3)
|
||||
pg:
|
||||
specifier: ^8.13
|
||||
version: 8.23.0
|
||||
devDependencies:
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.12
|
||||
@@ -32,6 +35,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.20.1
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
@@ -60,9 +66,15 @@ importers:
|
||||
'@astrojs/check':
|
||||
specifier: ^0.9.4
|
||||
version: 0.9.10(prettier@3.9.6)(typescript@5.9.3)
|
||||
'@resvg/resvg-js':
|
||||
specifier: ^2.6.2
|
||||
version: 2.6.2
|
||||
boneyard-js:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))
|
||||
satori:
|
||||
specifier: ^0.33.0
|
||||
version: 0.33.0
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
@@ -634,6 +646,82 @@ packages:
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js@2.6.2':
|
||||
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@rollup/pluginutils@5.4.0':
|
||||
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -798,6 +886,11 @@ packages:
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||
|
||||
@@ -822,6 +915,9 @@ packages:
|
||||
'@types/node@22.20.1':
|
||||
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@@ -919,6 +1015,10 @@ packages:
|
||||
base-64@1.0.0:
|
||||
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||
|
||||
base64-js@0.0.8:
|
||||
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -972,6 +1072,9 @@ packages:
|
||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
camelize@1.0.1:
|
||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -1015,6 +1118,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
@@ -1035,9 +1141,26 @@ packages:
|
||||
crossws@0.3.5:
|
||||
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
|
||||
|
||||
css-background-parser@0.1.0:
|
||||
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
|
||||
|
||||
css-box-shadow@1.0.0-3:
|
||||
resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
|
||||
|
||||
css-color-keywords@1.0.0:
|
||||
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
css-gradient-parser@0.0.17:
|
||||
resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
|
||||
|
||||
css-tree@2.2.1:
|
||||
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
@@ -1130,6 +1253,10 @@ packages:
|
||||
emmet@2.4.11:
|
||||
resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==}
|
||||
|
||||
emoji-regex-xs@2.0.1:
|
||||
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
@@ -1164,6 +1291,9 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
escape-string-regexp@5.0.0:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1199,6 +1329,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.7.3:
|
||||
resolution: {integrity: sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
@@ -1243,6 +1376,9 @@ packages:
|
||||
h3@1.15.11:
|
||||
resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==}
|
||||
|
||||
harfbuzzjs@0.10.0:
|
||||
resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
|
||||
|
||||
@@ -1273,6 +1409,10 @@ packages:
|
||||
hastscript@9.0.1:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
hex-rgb@4.3.0:
|
||||
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
hono@4.13.3:
|
||||
resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
@@ -1344,6 +1484,9 @@ packages:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
linebreak@1.1.0:
|
||||
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -1582,6 +1725,12 @@ packages:
|
||||
package-manager-detector@1.8.0:
|
||||
resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
|
||||
|
||||
pako@0.2.9:
|
||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||
|
||||
parse-css-color@0.2.1:
|
||||
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
|
||||
|
||||
parse-latin@7.0.0:
|
||||
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
|
||||
|
||||
@@ -1591,6 +1740,40 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
||||
|
||||
pg-connection-string@2.14.0:
|
||||
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
|
||||
|
||||
pg-int8@1.0.1:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pg-pool@3.14.0:
|
||||
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
pg-protocol@1.16.0:
|
||||
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
|
||||
|
||||
pg-types@2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pg@8.23.0:
|
||||
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
piccolore@0.1.3:
|
||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||
|
||||
@@ -1615,10 +1798,29 @@ packages:
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.5.26:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postgres-bytea@1.0.1:
|
||||
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-date@1.0.7:
|
||||
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1733,6 +1935,10 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
satori@0.33.0:
|
||||
resolution: {integrity: sha512-yKmqoWcwSV5VPSrLa5huaH90Uszptnl0gU4Nwfg/bneQZ8j8Dd68n0124mN/7ZSJiKWV9VOeCscbLsBm4LHJGQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
@@ -1769,6 +1975,10 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1781,6 +1991,9 @@ packages:
|
||||
resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
string.prototype.codepointat@0.2.1:
|
||||
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -1876,6 +2089,9 @@ packages:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -2151,6 +2367,10 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
xxhash-wasm@1.1.0:
|
||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||
|
||||
@@ -2196,6 +2416,9 @@ packages:
|
||||
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zod-to-json-schema@3.25.2:
|
||||
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
|
||||
peerDependencies:
|
||||
@@ -2621,6 +2844,57 @@ snapshots:
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js@2.6.2':
|
||||
optionalDependencies:
|
||||
'@resvg/resvg-js-android-arm-eabi': 2.6.2
|
||||
'@resvg/resvg-js-android-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-x64': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-musl': 2.6.2
|
||||
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
|
||||
|
||||
'@rollup/pluginutils@5.4.0(rollup@4.62.4)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -2750,6 +3024,11 @@ snapshots:
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
dependencies:
|
||||
fflate: 0.7.3
|
||||
string.prototype.codepointat: 0.2.1
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
@@ -2778,6 +3057,12 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@ungap/structured-clone@1.3.3': {}
|
||||
@@ -2978,6 +3263,8 @@ snapshots:
|
||||
|
||||
base-64@1.0.0: {}
|
||||
|
||||
base64-js@0.0.8: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
better-sqlite3@11.10.0:
|
||||
@@ -3022,6 +3309,8 @@ snapshots:
|
||||
|
||||
camelcase@8.0.0: {}
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chalk@5.6.2: {}
|
||||
@@ -3054,6 +3343,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
@@ -3068,6 +3359,14 @@ snapshots:
|
||||
dependencies:
|
||||
uncrypto: 0.1.3
|
||||
|
||||
css-background-parser@0.1.0: {}
|
||||
|
||||
css-box-shadow@1.0.0-3: {}
|
||||
|
||||
css-color-keywords@1.0.0: {}
|
||||
|
||||
css-gradient-parser@0.0.17: {}
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
@@ -3076,6 +3375,12 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
dependencies:
|
||||
camelize: 1.0.1
|
||||
css-color-keywords: 1.0.0
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
css-tree@2.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.28
|
||||
@@ -3155,6 +3460,8 @@ snapshots:
|
||||
'@emmetio/abbreviation': 2.3.3
|
||||
'@emmetio/css-abbreviation': 2.1.8
|
||||
|
||||
emoji-regex-xs@2.0.1: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
@@ -3229,6 +3536,8 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
@@ -3251,6 +3560,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.7.3: {}
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
flattie@1.1.1: {}
|
||||
@@ -3291,6 +3602,8 @@ snapshots:
|
||||
ufo: 1.6.4
|
||||
uncrypto: 0.1.3
|
||||
|
||||
harfbuzzjs@0.10.0: {}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
@@ -3378,6 +3691,8 @@ snapshots:
|
||||
property-information: 7.2.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
hex-rgb@4.3.0: {}
|
||||
|
||||
hono@4.13.3: {}
|
||||
|
||||
html-escaper@3.0.3: {}
|
||||
@@ -3424,6 +3739,11 @@ snapshots:
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
linebreak@1.1.0:
|
||||
dependencies:
|
||||
base64-js: 0.0.8
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
lru-cache@11.5.2: {}
|
||||
@@ -3838,6 +4158,13 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.8.0: {}
|
||||
|
||||
pako@0.2.9: {}
|
||||
|
||||
parse-css-color@0.2.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
hex-rgb: 4.3.0
|
||||
|
||||
parse-latin@7.0.0:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
@@ -3853,6 +4180,41 @@ snapshots:
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
optional: true
|
||||
|
||||
pg-connection-string@2.14.0: {}
|
||||
|
||||
pg-int8@1.0.1: {}
|
||||
|
||||
pg-pool@3.14.0(pg@8.23.0):
|
||||
dependencies:
|
||||
pg: 8.23.0
|
||||
|
||||
pg-protocol@1.16.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.1
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pg@8.23.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.14.0
|
||||
pg-pool: 3.14.0(pg@8.23.0)
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.4.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
piccolore@0.1.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -3869,12 +4231,24 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.26:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-bytea@1.0.1: {}
|
||||
|
||||
postgres-date@1.0.7: {}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -4068,6 +4442,22 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
satori@0.33.0:
|
||||
dependencies:
|
||||
'@shuding/opentype.js': 1.4.0-beta.0
|
||||
css-background-parser: 0.1.0
|
||||
css-box-shadow: 1.0.0-3
|
||||
css-gradient-parser: 0.0.17
|
||||
css-to-react-native: 3.2.0
|
||||
emoji-regex-xs: 2.0.1
|
||||
escape-html: 1.0.3
|
||||
fflate: 0.7.3
|
||||
harfbuzzjs: 0.10.0
|
||||
linebreak: 1.1.0
|
||||
parse-css-color: 0.2.1
|
||||
postcss-value-parser: 4.2.0
|
||||
yoga-layout: 3.2.1
|
||||
|
||||
sax@1.6.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
@@ -4131,6 +4521,8 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -4148,6 +4540,8 @@ snapshots:
|
||||
get-east-asian-width: 1.6.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string.prototype.codepointat@0.2.1: {}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -4236,6 +4630,11 @@ snapshots:
|
||||
|
||||
undici@8.10.0: {}
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
dependencies:
|
||||
pako: 0.2.9
|
||||
tiny-inflate: 1.0.3
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -4461,6 +4860,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
xxhash-wasm@1.1.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
@@ -4505,6 +4906,8 @@ snapshots:
|
||||
|
||||
yoctocolors@2.2.0: {}
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Fedimint federations: what a NIP-87 kind 38173 announcement actually contains, and
|
||||
* the handful of values derived from it that the API, the pages and the islands all
|
||||
* have to agree on.
|
||||
*
|
||||
* Everything here was written against real events pulled off the relay pool this site
|
||||
* already reads, not against a reading of the spec, for the same reason NOTES.md gives
|
||||
* for the Cashu side: what matters is what publishers actually put on relays. Three
|
||||
* places where the two differ, all resolved in favour of the wire:
|
||||
*
|
||||
* - the `n` tag is `bitcoin`, not `mainnet`. `normalizeNetwork` maps it.
|
||||
* - `modules` are protocol short names (`ln`, `mint`, `wallet`, `lnv2`, `meta`,
|
||||
* `stability_pool`), not the prose words. `MODULE_ALIASES` maps them.
|
||||
* - `content` carries `{"federation_name": "..."}` rather than a kind-0 `name`.
|
||||
* `parseFedimintAnnouncement` accepts either.
|
||||
*
|
||||
* A federation has no URL and no HTTP info endpoint, so its identity is the federation
|
||||
* id from the `d` tag and nothing else. `fedimintKey` turns that into the synthetic
|
||||
* primary key its row uses, and `fedimintSlug` into the routing slug.
|
||||
*/
|
||||
import {
|
||||
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
|
||||
} from './nostr.js';
|
||||
|
||||
/** Routing slugs are prefixed so a federation can never collide with a mint host. */
|
||||
export const FEDIMINT_SLUG_PREFIX = 'fed-';
|
||||
|
||||
/** How much of the federation id the slug carries. The full id is in the payload. */
|
||||
export const FEDIMINT_SLUG_CHARS = 16;
|
||||
|
||||
/** The scheme on the synthetic `mints.url` a federation row is keyed by. */
|
||||
export const FEDIMINT_KEY_SCHEME = 'fedimint:';
|
||||
|
||||
/** A federation id is a 32 byte hash, written as 64 hex characters. */
|
||||
export function isFederationId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
/** `fed-` plus the first 16 characters of the federation id. */
|
||||
export function fedimintSlug(federationId: string): string {
|
||||
return FEDIMINT_SLUG_PREFIX + federationId.toLowerCase().slice(0, FEDIMINT_SLUG_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary key a federation row uses in place of a mint URL.
|
||||
*
|
||||
* `mints.url` is the table's primary key and a federation has no URL to put there. A
|
||||
* `fedimint:` scheme keeps the column non-null and unique, is obviously not something
|
||||
* to fetch, and is what a review row points at.
|
||||
*/
|
||||
export function fedimintKey(federationId: string): string {
|
||||
return FEDIMINT_KEY_SCHEME + federationId.toLowerCase();
|
||||
}
|
||||
|
||||
/** The federation id inside a `fedimint:` key, or null if that is not what this is. */
|
||||
export function federationIdFromKey(key: string): string | null {
|
||||
if (!key.startsWith(FEDIMINT_KEY_SCHEME)) return null;
|
||||
const id = key.slice(FEDIMINT_KEY_SCHEME.length);
|
||||
return isFederationId(id) ? id.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/* ---------- invite codes ---------- */
|
||||
|
||||
/**
|
||||
* A fedimint invite code: bech32m holding the federation id and the guardian addresses
|
||||
* a wallet needs to join. Long, opaque, and the only thing a reader actually copies off
|
||||
* a federation page.
|
||||
*
|
||||
* Every real code starts `fed11`, which is two things and not a typo: `fed1` is the
|
||||
* human-readable part, and the `1` after it is bech32's separator. A first attempt at
|
||||
* this anchored on `fed1` followed by a bech32 data character and rejected every code
|
||||
* on the network, because bech32's alphabet deliberately excludes `1`.
|
||||
*
|
||||
* Past the prefix it is loose on purpose. The code is handed to a wallet verbatim and
|
||||
* nothing in this codebase decodes it, so the check only has to keep junk out of a copy
|
||||
* button — and being stricter than the wallets that consume it would mean dropping a
|
||||
* federation from the site over a character this code has no opinion about.
|
||||
*/
|
||||
export function isInviteCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^fed1[a-z0-9]{20,}$/i.test(value);
|
||||
}
|
||||
|
||||
/** Every usable invite code in a list, lowercased, in order, without repeats. */
|
||||
export function cleanInviteCodes(values: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of values) {
|
||||
const code = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
||||
if (!isInviteCode(code) || seen.has(code)) continue;
|
||||
seen.add(code);
|
||||
out.push(code);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- modules ---------- */
|
||||
|
||||
/**
|
||||
* The three modules a federation page gives a named row of its own, and every short
|
||||
* name that satisfies each.
|
||||
*
|
||||
* Versioned modules are aliases rather than separate rows: a federation running `lnv2`
|
||||
* and no `ln` can still do Lightning, and a row reading "Lightning — not supported"
|
||||
* beside a `lnv2` chip would be false. The version still shows, as its own chip in the
|
||||
* list underneath.
|
||||
*/
|
||||
export const MODULE_ALIASES: Record<string, readonly string[]> = {
|
||||
lightning: ['ln', 'lnv2', 'lightning'],
|
||||
mint: ['mint', 'mintv2'],
|
||||
wallet: ['wallet', 'walletv2'],
|
||||
};
|
||||
|
||||
/** Order of the named rows, highest interest first. Mirrors HIGHLIGHT_NUTS. */
|
||||
export const HIGHLIGHT_MODULES = ['lightning', 'mint', 'wallet'] as const;
|
||||
export type HighlightModule = (typeof HIGHLIGHT_MODULES)[number];
|
||||
|
||||
/**
|
||||
* Plain-language names for the module short names seen in the wild, and the English
|
||||
* source of truth for them. The catalogs carry a translation per key under
|
||||
* `fedimint.module.`; a module neither knows renders as its own short name, which is
|
||||
* still a true label.
|
||||
*/
|
||||
export const MODULE_NAMES_EN: Record<string, string> = {
|
||||
lightning: 'Lightning',
|
||||
mint: 'Ecash mint',
|
||||
wallet: 'On-chain wallet',
|
||||
meta: 'Metadata',
|
||||
stability_pool: 'Stability pool',
|
||||
multi_sig_stability_pool: 'Stability pool (multisig)',
|
||||
'fedi-social': 'Social recovery',
|
||||
unknown: 'Unknown module',
|
||||
};
|
||||
|
||||
/** Which highlight row a module short name belongs to, or null for the chip list. */
|
||||
export function highlightModuleFor(module: string): HighlightModule | null {
|
||||
const name = module.toLowerCase();
|
||||
for (const key of HIGHLIGHT_MODULES) {
|
||||
if (MODULE_ALIASES[key]?.includes(name)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this federation runs anything satisfying one of the named rows. */
|
||||
export function hasModule(modules: readonly string[], key: HighlightModule): boolean {
|
||||
const aliases = MODULE_ALIASES[key] ?? [];
|
||||
return modules.some((module) => aliases.includes(module.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a `modules` tag: `"ln,mint,wallet,lnv2,meta"`.
|
||||
*
|
||||
* Comma separated in every event seen, but whitespace is tolerated because a publisher
|
||||
* writing `"ln, mint"` meant the same thing.
|
||||
*/
|
||||
export function parseModules(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const part of value.split(/[,\s]+/)) {
|
||||
const module = part.trim().toLowerCase();
|
||||
if (!module || module.length > 40 || seen.has(module)) continue;
|
||||
if (!/^[a-z0-9_-]+$/.test(module)) continue;
|
||||
seen.add(module);
|
||||
out.push(module);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- network ---------- */
|
||||
|
||||
/** The networks this site has a name for. Anything else renders as published. */
|
||||
export const KNOWN_NETWORKS = ['mainnet', 'testnet', 'testnet4', 'signet', 'regtest'] as const;
|
||||
|
||||
/**
|
||||
* Normalize an `n` tag.
|
||||
*
|
||||
* NIP-87 describes the value as mainnet/testnet/signet/regtest; every real announcement
|
||||
* on the network writes `bitcoin` for mainnet, which is what fedimint's own config
|
||||
* calls it. Both are accepted and both come out as `mainnet`, so one federation cannot
|
||||
* appear on two networks depending on which word its operator used.
|
||||
*/
|
||||
export function normalizeNetwork(value: string | null | undefined): string | null {
|
||||
const raw = value?.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw === 'bitcoin' || raw === 'main' || raw === 'mainnet') return 'mainnet';
|
||||
if (!/^[a-z0-9]{1,20}$/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** True for anything that is not real bitcoin. Worth a badge of its own on a page. */
|
||||
export function isTestNetwork(network: string | null): boolean {
|
||||
return network !== null && network !== 'mainnet';
|
||||
}
|
||||
|
||||
/* ---------- the announcement ---------- */
|
||||
|
||||
export interface FedimintAnnouncement {
|
||||
/** The `d` tag: the federation id, lowercase hex. */
|
||||
federationId: string;
|
||||
/** Every `u` tag that is a usable invite code. At least one, or this is not valid. */
|
||||
inviteCodes: string[];
|
||||
/** The `modules` tag, split. */
|
||||
modules: string[];
|
||||
/** The `n` tag, normalized. */
|
||||
network: string | null;
|
||||
/** From `content`, which is kind-0-shaped metadata or fedimint's own. */
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
about: string | null;
|
||||
/** Who published it. Needed for the `a` tag a review points back with. */
|
||||
announcerPubkey: string;
|
||||
announcedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a kind 38173 event, or return null if it is not one this site can use.
|
||||
*
|
||||
* An announcement with no valid `d` and no invite code is not something a reader can
|
||||
* act on: there is nothing to join and nothing to key the row by. Everything else is
|
||||
* optional and simply renders as absent.
|
||||
*/
|
||||
export function parseFedimintAnnouncement(event: NostrEventLike): FedimintAnnouncement | null {
|
||||
const d = tagValue(event.tags, 'd');
|
||||
if (!isFederationId(d)) return null;
|
||||
|
||||
const inviteCodes = cleanInviteCodes(tagValues(event.tags, 'u'));
|
||||
if (inviteCodes.length === 0) return null;
|
||||
|
||||
const meta = parseAnnouncementMetadata(event.content);
|
||||
|
||||
return {
|
||||
federationId: d.toLowerCase(),
|
||||
inviteCodes,
|
||||
modules: parseModules(tagValue(event.tags, 'modules')),
|
||||
network: normalizeNetwork(tagValue(event.tags, 'n')),
|
||||
name: meta.name,
|
||||
picture: meta.picture,
|
||||
about: meta.about,
|
||||
announcerPubkey: event.pubkey,
|
||||
announcedAt: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnnouncementMetadata {
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
about: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `content` of an announcement, which NIP-87 describes as kind-0-style metadata.
|
||||
*
|
||||
* Every fedimint announcement seen writes `{"federation_name": "..."}` instead, so both
|
||||
* spellings are read. Unparseable content degrades to no metadata rather than throwing:
|
||||
* this is arbitrary text written by anyone with a relay connection.
|
||||
*/
|
||||
export function parseAnnouncementMetadata(content: string): AnnouncementMetadata {
|
||||
const empty: AnnouncementMetadata = { name: null, picture: null, about: null };
|
||||
if (!content.trim()) return empty;
|
||||
|
||||
let meta: Record<string, unknown>;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty;
|
||||
meta = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const first = (...keys: string[]): unknown => {
|
||||
for (const key of keys) if (meta[key] !== undefined && meta[key] !== null) return meta[key];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
name: sanitizeDisplayText(first('name', 'federation_name', 'display_name'), 64) ?? null,
|
||||
picture: sanitizePictureUrl(first('picture', 'federation_icon_url', 'icon_url', 'image')) ?? null,
|
||||
about: sanitizeDisplayText(first('about', 'description', 'federation_description'), 400) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The federation id a slug carries, which is the first 16 characters of it.
|
||||
*
|
||||
* The list payload has no `federation_id` — that lives on the detail — so a card built
|
||||
* from the list reads its identifier back out of the routing slug. Shortened is all a
|
||||
* card has room for anyway, and the full id is on the page it links to.
|
||||
*/
|
||||
export function federationIdFromSlug(slug: string): string {
|
||||
return slug.startsWith(FEDIMINT_SLUG_PREFIX) ? slug.slice(FEDIMINT_SLUG_PREFIX.length) : slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* The short form of an invite code, for a row that has to fit one.
|
||||
*
|
||||
* More head than tail: the prefix is what tells a reader it is an invite code at all,
|
||||
* and the tail is what tells two of the same federation's codes apart.
|
||||
*/
|
||||
export function shortInviteCode(code: string, head = 14, tail = 8): string {
|
||||
if (code.length <= head + tail + 1) return code;
|
||||
return `${code.slice(0, head)}…${code.slice(-tail)}`;
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './normalize.js';
|
||||
export * from './score.js';
|
||||
export * from './nuts.js';
|
||||
export * from './warnings.js';
|
||||
export * from './fedimint.js';
|
||||
|
||||
+38
-1
@@ -26,13 +26,50 @@ function isDisallowedHost(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (hostname.endsWith('.onion')) return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
if (hostname === '::1' || hostname === '[::1]') return true;
|
||||
if (PRIVATE_IPV4.test(hostname)) return true;
|
||||
if (isPrivateIpv6(hostname)) return true;
|
||||
// A bare label with no dot cannot be a public host.
|
||||
if (!hostname.includes('.') && !hostname.includes(':')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6 forms that are loopback, link-local (fe80::/10), unique-local (fc00::/7) or an
|
||||
* IPv4-mapped address whose IPv4 part is private. The URL parser brackets an IPv6
|
||||
* hostname, so both spellings are accepted.
|
||||
*/
|
||||
function isPrivateIpv6(hostname: string): boolean {
|
||||
const bare =
|
||||
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
||||
if (!bare.includes(':')) return false;
|
||||
if (bare === '::' || bare === '::1') return true;
|
||||
if (/^f[cd]/i.test(bare)) return true;
|
||||
if (/^fe[89ab]/i.test(bare)) return true;
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
|
||||
if (mapped?.[1]) return PRIVATE_IPV4.test(mapped[1]) || mapped[1].startsWith('127.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* May the indexer fetch this URL? http(s) only, and never a private or local host.
|
||||
*
|
||||
* For URLs a mint *publishes* rather than the URL it lives at — its `icon_url` above
|
||||
* all. Those never pass through `normalizeMintUrl`, so without this check a mint's
|
||||
* /v1/info could point the indexer at a cloud metadata endpoint or anything else on
|
||||
* the API host's own network. Callers that follow redirects must re-check every hop.
|
||||
*/
|
||||
export function isFetchableUrl(value: URL | string): boolean {
|
||||
let u: URL;
|
||||
try {
|
||||
u = typeof value === 'string' ? new URL(value) : value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
|
||||
const hostname = u.hostname.toLowerCase();
|
||||
return hostname !== '' && !isDisallowedHost(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mint URL as found in a Nostr `u` tag or typed by a user.
|
||||
* Returns null when the input is not a usable public mint URL.
|
||||
|
||||
+89
-7
@@ -11,9 +11,48 @@ import type { Profile } from './types.js';
|
||||
export const KIND_REVIEW = 38000;
|
||||
/** NIP-87 Cashu mint announcement. */
|
||||
export const KIND_MINT_ANNOUNCEMENT = 38172;
|
||||
/** NIP-87 Fedimint federation announcement. */
|
||||
export const KIND_FEDIMINT_ANNOUNCEMENT = 38173;
|
||||
/** Profile metadata. */
|
||||
export const KIND_PROFILE = 0;
|
||||
|
||||
/**
|
||||
* The one place an ecosystem is tied to its announcement kind.
|
||||
*
|
||||
* Everything downstream reads this rather than testing for 38172 or 38173 itself: the
|
||||
* indexer's subscription list, the review resolver, the `k` tag a published review
|
||||
* carries, and the filter on /reviews. Adding a third ecosystem is an entry here plus
|
||||
* a probe strategy and its pages — see the "Adding an ecosystem" section of README.
|
||||
*
|
||||
* `mints.type` holds these keys verbatim, so they are a stored value: never rename one
|
||||
* without a data migration.
|
||||
*/
|
||||
export const ANNOUNCEMENT_KINDS = {
|
||||
cashu: KIND_MINT_ANNOUNCEMENT,
|
||||
fedimint: KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ecosystems in display order. Cashu first: it is what this site was, and what most of
|
||||
* its content still is.
|
||||
*/
|
||||
export const ECOSYSTEMS = Object.keys(ANNOUNCEMENT_KINDS) as Array<keyof typeof ANNOUNCEMENT_KINDS>;
|
||||
|
||||
/** The announcement kind for an ecosystem, or null for one this build does not know. */
|
||||
export function announcementKind(type: string): number | null {
|
||||
return (ANNOUNCEMENT_KINDS as Record<string, number>)[type] ?? null;
|
||||
}
|
||||
|
||||
/** The ecosystem an announcement kind belongs to, or null. */
|
||||
export function ecosystemForKind(kind: number | string | null): string | null {
|
||||
const n = typeof kind === 'string' ? Number.parseInt(kind, 10) : kind;
|
||||
if (n === null || !Number.isFinite(n)) return null;
|
||||
for (const [type, value] of Object.entries(ANNOUNCEMENT_KINDS)) {
|
||||
if (value === n) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
|
||||
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
|
||||
@@ -180,6 +219,45 @@ export function isCashuMintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/** True when the event's `k` tag marks it as being about a Fedimint federation. */
|
||||
export function isFedimintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_FEDIMINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind a review says it is about, from its `k` tag, as a number.
|
||||
*
|
||||
* null when the tag is missing or unparseable, which is the shape most reviews already
|
||||
* on the network have: the old publisher wrote `k` but plenty of other clients do not.
|
||||
* A review with no `k` is treated as Cashu by every resolver here, because that is the
|
||||
* only ecosystem that existed when those events were written.
|
||||
*/
|
||||
export function reviewTargetKind(event: NostrEventLike): number | null {
|
||||
const raw = tagValue(event.tags, 'k');
|
||||
if (!raw) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which ecosystem a review is about: its `k` tag, falling back to Cashu.
|
||||
*
|
||||
* The fallback is not a guess about the future, it is a fact about the past. Every
|
||||
* kind 38000 written before Fedimint support existed is about a Cashu mint, and a great
|
||||
* many of them carry no `k` at all (see NOTES.md). A review whose `k` names a kind this
|
||||
* build does not know returns null and is skipped rather than filed under Cashu.
|
||||
*/
|
||||
export function reviewEcosystem(event: NostrEventLike): string | null {
|
||||
const kind = reviewTargetKind(event);
|
||||
if (kind === null) return 'cashu';
|
||||
return ecosystemForKind(kind);
|
||||
}
|
||||
|
||||
/** The `d` identifier a review points at, verbatim. Resolution is the caller's job. */
|
||||
export function reviewTargetId(event: NostrEventLike): string | null {
|
||||
return tagValue(event.tags, 'd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every spelling of a mint URL that may appear in a `u` tag.
|
||||
*
|
||||
@@ -210,8 +288,12 @@ export const MAX_PROFILE_NAME = 24;
|
||||
* Written as a code point scan rather than a regex because the set is exactly the
|
||||
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
|
||||
* let a name reorder the text around it.
|
||||
*
|
||||
* Exported because a Fedimint announcement's `content` carries a federation name
|
||||
* written by the same kind of stranger, and `fedimint.ts` has no business owning a
|
||||
* second copy of these rules.
|
||||
*/
|
||||
function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
export function sanitizeDisplayText(value: unknown, max: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
let out = '';
|
||||
@@ -238,7 +320,7 @@ function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
* been verified against a domain. Anything not shaped like an address is dropped.
|
||||
*/
|
||||
function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
const text = sanitizeText(value, max);
|
||||
const text = sanitizeDisplayText(value, max);
|
||||
if (!text || text.length > max) return undefined;
|
||||
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
|
||||
// "_@domain" is NIP-05's root form and reads better as the bare domain.
|
||||
@@ -249,7 +331,7 @@ function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
|
||||
* relay has no business being written into an `img src`.
|
||||
*/
|
||||
function sanitizePicture(value: unknown): string | undefined {
|
||||
export function sanitizePictureUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const url = value.trim();
|
||||
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
|
||||
@@ -278,11 +360,11 @@ export function parseProfileContent(pubkey: string, content: string): Profile {
|
||||
|
||||
const profile: Profile = {
|
||||
pubkey,
|
||||
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
|
||||
name: sanitizeDisplayText(meta['name'], MAX_PROFILE_NAME),
|
||||
display_name:
|
||||
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePicture(meta['picture']),
|
||||
sanitizeDisplayText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeDisplayText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePictureUrl(meta['picture']),
|
||||
nip05: sanitizeNip05(meta['nip05'], 64),
|
||||
found: true,
|
||||
};
|
||||
|
||||
+38
-8
@@ -55,18 +55,48 @@ export function bayesianScore(m: ScoreInput, priorMean: number, now: number): nu
|
||||
return Math.round(score * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three tiers, and the middle one exists for federations.
|
||||
*
|
||||
* `announced` is a Fedimint status: nothing has ever confirmed the thing is running, so
|
||||
* it cannot sit with the confirmed-online rows — but it is not evidence of being down
|
||||
* either, so it must not sink to the bottom with the rows a check actually failed on.
|
||||
* Not knowing belongs between knowing and knowing otherwise.
|
||||
*
|
||||
* A Cashu mint is never `announced`, so this is exactly the two-tier order it always
|
||||
* had for them.
|
||||
*/
|
||||
function healthRank(status: string): number {
|
||||
if (status === 'offline') return 2;
|
||||
if (status === 'announced') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort: every online mint before every offline one, then by score descending.
|
||||
* Offline mints must stay findable (people need to reach them to review them), they
|
||||
* just never appear above a live mint.
|
||||
*/
|
||||
export function compareMints<T extends { status: string; score: number; review_count: number }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
const aOff = a.status === 'offline' ? 1 : 0;
|
||||
const bOff = b.status === 'offline' ? 1 : 0;
|
||||
if (aOff !== bOff) return aOff - bOff;
|
||||
export function compareMints<
|
||||
T extends { status: string; score: number; review_count: number; host?: string },
|
||||
>(a: T, b: T): number {
|
||||
const aRank = healthRank(a.status);
|
||||
const bRank = healthRank(b.status);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.review_count - a.review_count;
|
||||
if (b.review_count !== a.review_count) return b.review_count - a.review_count;
|
||||
|
||||
/*
|
||||
* A final tiebreak on the slug, so two indistinguishable rows still have an order.
|
||||
*
|
||||
* Without it the winner is whatever the database happened to return first, which is a
|
||||
* different answer on SQLite and on Postgres — the same data served in two orders, and
|
||||
* a nightly rebuild that reshuffles rows for no reason. It went unnoticed while ties
|
||||
* were rare (two unreviewed mints); federations made it the common case, since every
|
||||
* one that nobody has reviewed scores exactly the prior.
|
||||
*
|
||||
* Optional in the type because the ranking checks compare bare score objects that have
|
||||
* no slug, and there is nothing to tiebreak in a two-element fixture.
|
||||
*/
|
||||
return (a.host ?? '').localeCompare(b.host ?? '');
|
||||
}
|
||||
|
||||
+81
-4
@@ -1,6 +1,24 @@
|
||||
/** Shapes returned by the API. The web app builds against these. */
|
||||
|
||||
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
|
||||
/**
|
||||
* Statuses a listed thing can be in.
|
||||
*
|
||||
* `announced` is the Fedimint addition, and it is deliberately not a synonym for
|
||||
* `unknown`. `unknown` means "we have not checked yet"; `announced` means "there is no
|
||||
* check we can run" — the federation exists on Nostr, nothing has confirmed it since,
|
||||
* and nothing here will claim otherwise. A Cashu mint never carries it.
|
||||
*/
|
||||
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
|
||||
|
||||
/**
|
||||
* Which ecosystem a listing belongs to.
|
||||
*
|
||||
* Deliberately open rather than a closed union: `mints.type` is a stored TEXT column
|
||||
* with no CHECK constraint, so an older build reading a database written by a newer one
|
||||
* has to be able to hold a value it does not have a page for. Compare against
|
||||
* `ANNOUNCEMENT_KINDS` rather than switching exhaustively on this.
|
||||
*/
|
||||
export type MintType = 'cashu' | 'fedimint' | (string & {});
|
||||
|
||||
/** One item of `GET /api/mints`. */
|
||||
export interface MintListItem {
|
||||
@@ -8,6 +26,8 @@ export interface MintListItem {
|
||||
host: string;
|
||||
name: string | null;
|
||||
icon: string | null;
|
||||
/** 'cashu' or 'fedimint'. Rows written before the column existed read as 'cashu'. */
|
||||
type: MintType;
|
||||
status: MintStatus;
|
||||
last_online: number | null;
|
||||
review_count: number;
|
||||
@@ -25,8 +45,13 @@ export interface ProbeSample {
|
||||
|
||||
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
|
||||
|
||||
/** `GET /api/mints/:host`. */
|
||||
export interface MintDetail extends MintListItem {
|
||||
/**
|
||||
* `GET /api/mints/:host`.
|
||||
*
|
||||
* The Fedimint keys are optional and absent on a Cashu mint, which is what keeps the
|
||||
* Cashu payload unchanged. Read them through `FedimintDetail` after checking `type`.
|
||||
*/
|
||||
export interface MintDetail extends MintListItem, Partial<FedimintFields> {
|
||||
description: string | null;
|
||||
pubkey: string | null;
|
||||
info: MintInfo | null;
|
||||
@@ -41,7 +66,49 @@ export interface MintDetail extends MintListItem {
|
||||
probes_recent: ProbeSample[];
|
||||
}
|
||||
|
||||
/** `GET /api/stats`. */
|
||||
/**
|
||||
* What `ecosystem_json` holds for a Fedimint row, and what `GET /api/mints/:host`
|
||||
* spreads across the detail payload for one.
|
||||
*
|
||||
* Flattened rather than nested so a Cashu payload is byte for byte what it always was:
|
||||
* these keys are simply absent on one. A third ecosystem adds its own interface here
|
||||
* and its own optional keys below; nothing existing has to move.
|
||||
*/
|
||||
export interface FedimintFields {
|
||||
/** The `d` tag of the announcement: 64 hex characters. `host` is derived from it. */
|
||||
federation_id: string;
|
||||
/** Every `u` tag that was a usable invite code, in announcement order. */
|
||||
invite_codes: string[];
|
||||
/** The `modules` tag, split: `["ln", "mint", "wallet", "lnv2", "meta"]`. */
|
||||
modules: string[];
|
||||
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
|
||||
network: string | null;
|
||||
/** `created_at` of the newest announcement seen for this federation. */
|
||||
announced_at: number | null;
|
||||
/** Who published that announcement. The `a` tag of a review points back at them. */
|
||||
announcer_pubkey: string | null;
|
||||
/**
|
||||
* Who confirmed the status, when anything did: `"fedimint.observer"` today.
|
||||
*
|
||||
* null means nothing has, and then `status` is `announced` and never `online`. The
|
||||
* page prints this next to the status, because "someone else says it is up" is a
|
||||
* different claim from "we checked".
|
||||
*/
|
||||
status_source: string | null;
|
||||
}
|
||||
|
||||
/** `GET /api/mints/:host` for a Fedimint federation: the detail plus its own fields. */
|
||||
export type FedimintDetail = MintDetail & FedimintFields;
|
||||
|
||||
/**
|
||||
* `GET /api/stats`.
|
||||
*
|
||||
* The four `mints_*` fields count Cashu mints and only Cashu mints, exactly as they did
|
||||
* before federations were indexed: they are read by the pulse ticker, the /mints
|
||||
* description and the home page, and a number that silently changed meaning would be
|
||||
* worse than a new field. `cashu_total` is the same number under a name that says so,
|
||||
* and every ecosystem added later gets its own `*_total` beside `fedimint_total`.
|
||||
*/
|
||||
export interface Stats {
|
||||
mints_total: number;
|
||||
mints_online: number;
|
||||
@@ -50,6 +117,16 @@ export interface Stats {
|
||||
reviews_total: number;
|
||||
last_review_at: number | null;
|
||||
updated_at: number;
|
||||
/** Same value as `mints_total`, named for the ecosystem it counts. */
|
||||
cashu_total: number;
|
||||
fedimint_total: number;
|
||||
/** Federations a real check confirmed were up. Never inferred from an announcement. */
|
||||
fedimint_online: number;
|
||||
fedimint_offline: number;
|
||||
/** Federations announced on Nostr that no check has ever confirmed. */
|
||||
fedimint_announced: number;
|
||||
/** Reviews of federations (`k` = 38173), included in `reviews_total`. */
|
||||
fedimint_reviews: number;
|
||||
}
|
||||
|
||||
/** `GET /api/health`. */
|
||||
|
||||
+133
-1
@@ -8,6 +8,12 @@
|
||||
* `/v1/info` (NUT-04 and NUT-05), plus `status` and `last_online`. The cache is the
|
||||
* point: an offline mint still has its last known configuration, and the copy says
|
||||
* "had" rather than "has" so nobody reads a stale flag as a live one.
|
||||
*
|
||||
* Two branches, chosen by `type` and never mixed. Cashu gets the six states below,
|
||||
* every one of them read out of a `/v1/info` this site fetched itself. Fedimint gets
|
||||
* two, because a federation publishes no capability list to read: a real check said the
|
||||
* guardians are down, or nothing has ever confirmed the federation at all. There is no
|
||||
* Fedimint equivalent of "melt only" and this file does not invent one.
|
||||
*/
|
||||
import type { MintInfo, MintStatus } from './types.js';
|
||||
import { nutEntry } from './nuts.js';
|
||||
@@ -73,12 +79,16 @@ function isLightningMethod(method: unknown): boolean {
|
||||
export type MintWarningSeverity = 'critical' | 'warning';
|
||||
|
||||
export type MintWarningKind =
|
||||
// Cashu
|
||||
| 'gone' // offline 30 days or more, or never reached at all
|
||||
| 'melt-disabled' // sats can get in but not out
|
||||
| 'frozen' // neither in nor out
|
||||
| 'offline-long' // offline 7 to 30 days
|
||||
| 'melt-only' // minting off, melting still works
|
||||
| 'offline'; // offline under 7 days
|
||||
| 'offline' // offline under 7 days
|
||||
// Fedimint
|
||||
| 'fedimint-offline' // a real check reported the guardians down
|
||||
| 'never-confirmed'; // announced on Nostr, and nothing has ever confirmed it
|
||||
|
||||
export interface MintWarning {
|
||||
kind: MintWarningKind;
|
||||
@@ -108,8 +118,18 @@ export interface MintWarning {
|
||||
export type WarningStrings = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export interface MintWarningInput {
|
||||
/**
|
||||
* Which ecosystem this is. Absent reads as `cashu`, so every existing caller keeps
|
||||
* exactly the behaviour it had; a `fedimint` row takes the separate branch below,
|
||||
* which shares the severity machinery and none of the NUT reasoning.
|
||||
*/
|
||||
type?: string;
|
||||
status: MintStatus | string;
|
||||
last_online: number | null;
|
||||
/** Fedimint: `created_at` of the newest announcement, for "announced {date}". */
|
||||
announced_at?: number | null;
|
||||
/** Fedimint: used only to decide whether reviews are the page's one sign of life. */
|
||||
last_review_at?: number | null;
|
||||
/** Cached `/v1/info`, as `GET /api/mints/:host` returns it. */
|
||||
info?: MintInfo | null;
|
||||
/**
|
||||
@@ -183,6 +203,21 @@ export const WARNING_COPY_EN: Record<string, string> = {
|
||||
'offline.body': 'Details were last confirmed then. You can still write a review.',
|
||||
'offline.meta': 'Offline since {date}',
|
||||
|
||||
'fedimintOffline.lead': 'Reported offline.',
|
||||
'fedimintOffline.body.since':
|
||||
'The last check that reached this federation was on {date} ({days} ago). Its guardians have not answered since. Treat funds held there as at risk, and do not join it with new funds.',
|
||||
'fedimintOffline.body.never':
|
||||
'No check of this federation has ever reached it, and the most recent one failed. Treat funds held there as at risk, and do not join it with new funds.',
|
||||
'fedimintOffline.meta.since': 'Reported offline since {date}',
|
||||
'fedimintOffline.meta.never': 'Reported offline, never once reached',
|
||||
|
||||
'neverConfirmed.lead': 'Announced, never confirmed.',
|
||||
'neverConfirmed.body.reviewed':
|
||||
'This federation was announced on Nostr on {date}, {days} ago, and nothing has confirmed since then that it is running. The reviews below are the only sign of life on this page; everything else came from the announcement.',
|
||||
'neverConfirmed.body.quiet':
|
||||
'This federation was announced on Nostr on {date}, {days} ago, nothing has confirmed since then that it is running, and nobody has reviewed it recently. Everything below came from the announcement.',
|
||||
'neverConfirmed.meta': 'Announced {date}, never confirmed',
|
||||
|
||||
'also.meltDisabled': 'Before going offline it had also disabled withdrawals.',
|
||||
'also.mintDisabled': 'Before going offline it had also disabled new minting.',
|
||||
'also.offline': 'It has also been offline since {date} ({days}).',
|
||||
@@ -213,8 +248,23 @@ const englishStrings: WarningStrings = (key, vars) => {
|
||||
/** Severity order, highest first. Index 0 of the returned list is the one to show. */
|
||||
const ORDER: MintWarningKind[] = [
|
||||
'gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline',
|
||||
// Appended rather than interleaved. The Fedimint kinds never share a list with the
|
||||
// Cashu ones (the two branches are exclusive), so their position relative to those
|
||||
// is arbitrary — and appending leaves every existing rank exactly where it was.
|
||||
'fedimint-offline', 'never-confirmed',
|
||||
];
|
||||
|
||||
/**
|
||||
* How stale an unconfirmed announcement has to be before it is worth saying so.
|
||||
*
|
||||
* A federation announced last week that nothing has checked is not news: nothing has
|
||||
* had the chance. A month of silence is.
|
||||
*/
|
||||
export const NEVER_CONFIRMED_AFTER_S = 30 * 24 * 60 * 60;
|
||||
|
||||
/** Reviews inside this window count as the page having a pulse. Matches `reviews_90d`. */
|
||||
const RECENT_REVIEW_S = 90 * 24 * 60 * 60;
|
||||
|
||||
const defaultDate = (unix: number): string =>
|
||||
new Date(unix * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
@@ -232,6 +282,88 @@ const defaultMonth = (unix: number): string =>
|
||||
export function getMintWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
if (mint.type === 'fedimint') return fedimintWarnings(mint, options);
|
||||
return cashuWarnings(mint, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be said about a federation, which is much less than about a mint.
|
||||
*
|
||||
* A federation publishes no capability list, so there is no NUT-04/NUT-05 equivalent to
|
||||
* read and nothing here invents one: the melt-only, withdrawals-disabled and frozen
|
||||
* banners have no Fedimint counterpart and never will until federations publish
|
||||
* something a check can actually read. That leaves two honest things to say.
|
||||
*
|
||||
* The first is that a check reported the guardians down — a real result, from
|
||||
* `status_source`, never inferred from the absence of an announcement. The second is
|
||||
* the soft one: this federation has been sitting on Nostr for a month or more and
|
||||
* nothing has ever confirmed it is running, so everything on its page came from a
|
||||
* stranger's announcement rather than from the federation itself.
|
||||
*
|
||||
* Note what is deliberately missing: a federation that no check reached is `announced`,
|
||||
* not `offline`, and gets the soft banner rather than the "likely gone" one. Not
|
||||
* knowing is not the same as knowing it is dead.
|
||||
*/
|
||||
function fedimintWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const date = options.formatDate ?? defaultDate;
|
||||
const s = options.strings ?? englishStrings;
|
||||
|
||||
const days = (since: number): string =>
|
||||
s('dayCount', { n: Math.max(0, Math.floor((now - since) / 86400)) });
|
||||
|
||||
if (mint.status === 'offline') {
|
||||
const since = mint.last_online;
|
||||
return [
|
||||
{
|
||||
kind: 'fedimint-offline',
|
||||
severity: 'critical',
|
||||
lead: s('fedimintOffline.lead'),
|
||||
body:
|
||||
since !== null
|
||||
? s('fedimintOffline.body.since', { date: date(since), days: days(since) })
|
||||
: s('fedimintOffline.body.never'),
|
||||
meta:
|
||||
since !== null
|
||||
? s('fedimintOffline.meta.since', { date: date(since) })
|
||||
: s('fedimintOffline.meta.never'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Anything a check has confirmed up needs no banner, and a fresh announcement no
|
||||
// check has got to yet has not earned one either.
|
||||
if (mint.status !== 'announced') return [];
|
||||
|
||||
const announced = mint.announced_at ?? mint.first_seen ?? null;
|
||||
if (announced === null || now - announced < NEVER_CONFIRMED_AFTER_S) return [];
|
||||
|
||||
const reviewed =
|
||||
mint.last_review_at !== null &&
|
||||
mint.last_review_at !== undefined &&
|
||||
now - mint.last_review_at <= RECENT_REVIEW_S;
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'never-confirmed',
|
||||
severity: 'warning',
|
||||
lead: s('neverConfirmed.lead'),
|
||||
body: s(`neverConfirmed.body.${reviewed ? 'reviewed' : 'quiet'}`, {
|
||||
date: date(announced),
|
||||
days: days(announced),
|
||||
}),
|
||||
meta: s('neverConfirmed.meta', { date: date(announced) }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function cashuWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const date = options.formatDate ?? defaultDate;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
+5
-1
@@ -5,7 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"og": "node scripts/og/build-og.mjs",
|
||||
"og:fixtures": "node scripts/og/build-og.mjs --fixtures",
|
||||
"build": "node scripts/og/build-og.mjs && astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "astro check",
|
||||
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
|
||||
@@ -21,7 +23,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"boneyard-js": "^1.9.0",
|
||||
"satori": "^0.33.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,10 @@ const DYNAMIC_PREFIXES = [
|
||||
'login.connect.', // CONNECT_COPY, keyed by method
|
||||
'time.ago.', // formatRelative, keyed by unit
|
||||
'time.short.', // formatShortDuration, keyed by unit
|
||||
'fedimint.module.', // localModuleName(module)
|
||||
'fedimint.network.', // localNetworkName(network)
|
||||
'feed.ecosystem.', // the /reviews filter, one option per ECOSYSTEMS entry
|
||||
'reviews.dialog.bodyPlaceholder.', // one prompt per ecosystem, keyed by subject.type
|
||||
];
|
||||
|
||||
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Social image build step. `pnpm og`, and the first half of `pnpm build`.
|
||||
*
|
||||
* Renders one 1200x630 PNG per mint and federation into `public/og/`, plus the
|
||||
* default card every non-mint page shares, from the same API the pages are built
|
||||
* from. satori turns the card template into SVG (text becomes glyph paths, so the
|
||||
* rasteriser needs no fonts), resvg turns the SVG into a PNG.
|
||||
*
|
||||
* Filenames carry a content hash — `mint.example.com.a1b2c3d4e5.png` — and the pages
|
||||
* read the mapping from `src/generated/og-manifest.json`, which this script writes.
|
||||
* The hash is over the card model (see model.mjs), so:
|
||||
*
|
||||
* - a rebuild with unchanged data renders nothing (the manifest says every hash is
|
||||
* already on disk);
|
||||
* - one mint's rating moving regenerates exactly that mint's image, at a new URL,
|
||||
* which is what actually busts scraper caches — Telegram and friends cache an
|
||||
* og:image URL for days and ignore Cache-Control on it;
|
||||
* - the previous file is deleted, so `public/og/` never accumulates history.
|
||||
*
|
||||
* `--fixtures` renders the six edge-case cards from fixtures.mjs into `og-fixtures/`
|
||||
* at a pinned timestamp instead: byte-stable snapshots for the repo, to eyeball after
|
||||
* template changes.
|
||||
*/
|
||||
import '../load-env.mjs';
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
import { renderSvg } from './card.mjs';
|
||||
import { cashuModel, defaultModel, fedimintModel } from './model.mjs';
|
||||
import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(here, '../..');
|
||||
const outDir = path.join(webRoot, 'public/og');
|
||||
const manifestFile = path.join(webRoot, 'src/generated/og-manifest.json');
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8787';
|
||||
|
||||
async function getJson(pathname) {
|
||||
const res = await fetch(`${API_URL}${pathname}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`${pathname} responded ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Ten hex characters of the model hash: the cache-busting part of the filename. */
|
||||
const hashModel = (model) =>
|
||||
createHash('sha256').update(JSON.stringify(model)).digest('hex').slice(0, 10);
|
||||
|
||||
/**
|
||||
* The cached icon as a data URI resvg can decode, plus a digest for the model hash.
|
||||
*
|
||||
* Only PNG and JPEG go in — resvg decodes nothing else, and an image tag it cannot
|
||||
* decode becomes a blank square, which is worse than the gradient tile. The one webp
|
||||
* icon in the index today falls back to the tile; logged so it is a known trade, not
|
||||
* a silent one.
|
||||
*/
|
||||
async function fetchIcon(icon, host) {
|
||||
if (!icon) return null;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}${icon}`);
|
||||
if (!res.ok) throw new Error(`responded ${res.status}`);
|
||||
const bytes = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
const isPng = bytes.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
|
||||
if (!isPng && !isJpeg) {
|
||||
console.warn(`og: ${host}: icon is not PNG/JPEG, using the gradient tile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: `data:image/${isPng ? 'png' : 'jpeg'};base64,${bytes.toString('base64')}`,
|
||||
hash: createHash('sha1').update(bytes).digest('hex').slice(0, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`og: ${host}: icon fetch failed (${error.message}), using the gradient tile`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPng(model, iconData) {
|
||||
const svg = await renderSvg(model, iconData);
|
||||
const resvg = new Resvg(svg, { font: { loadSystemFonts: false } });
|
||||
return resvg.render().asPng();
|
||||
}
|
||||
|
||||
/* ---------- fixtures mode ---------- */
|
||||
|
||||
async function buildFixtures() {
|
||||
const dir = path.join(webRoot, 'og-fixtures');
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
for (const raw of FIXTURES) {
|
||||
const model =
|
||||
raw.type === 'fedimint'
|
||||
? fedimintModel(raw, { now: FIXTURE_NOW })
|
||||
: cashuModel(raw, { now: FIXTURE_NOW });
|
||||
const png = await renderPng(model, null);
|
||||
const file = path.join(dir, `${raw.host}.png`);
|
||||
await writeFile(file, png);
|
||||
console.log(`og: ${path.relative(webRoot, file)} (${(png.length / 1024).toFixed(0)}KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the real build ---------- */
|
||||
|
||||
async function build() {
|
||||
const started = Date.now();
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await mkdir(path.dirname(manifestFile), { recursive: true });
|
||||
|
||||
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
|
||||
const previousImages = previous.images ?? {};
|
||||
|
||||
const [cashu, fedimint, stats] = await Promise.all([
|
||||
getJson('/api/mints?type=cashu'),
|
||||
getJson('/api/mints?type=fedimint'),
|
||||
getJson('/api/stats'),
|
||||
]);
|
||||
|
||||
/*
|
||||
* One clock for the whole run, so two rebuilds inside the same day produce the same
|
||||
* day counts and therefore the same hashes. Day granularity is deliberate: an
|
||||
* offline mint's "Offline 12d" chip regenerates once a day, not once a build.
|
||||
*/
|
||||
const now = Math.floor(Date.now() / 1000 / 86400) * 86400;
|
||||
|
||||
const onDisk = new Set(await readdir(outDir).catch(() => []));
|
||||
const images = {};
|
||||
let rendered = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const listings = [
|
||||
...cashu.map((m) => ({ listing: m, kind: 'cashu' })),
|
||||
...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })),
|
||||
];
|
||||
|
||||
// A handful at a time: satori renders in milliseconds, the icon fetches are local,
|
||||
// and bounded concurrency keeps 60 base64 icons from piling up in memory at once.
|
||||
const queue = [...listings];
|
||||
const workers = Array.from({ length: 8 }, async () => {
|
||||
for (;;) {
|
||||
const next = queue.shift();
|
||||
if (!next) return;
|
||||
const { listing, kind } = next;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getJson(`/api/mints/${encodeURIComponent(listing.host)}`);
|
||||
} catch (error) {
|
||||
console.warn(`og: ${listing.host}: detail fetch failed (${error.message}), skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const icon = await fetchIcon(detail.icon, detail.host);
|
||||
const model =
|
||||
kind === 'fedimint'
|
||||
? fedimintModel(detail, { iconHash: icon?.hash ?? null, now })
|
||||
: cashuModel(detail, { iconHash: icon?.hash ?? null, now });
|
||||
|
||||
const hash = hashModel(model);
|
||||
const file = `${model.slug}.${hash}.png`;
|
||||
images[model.slug] = { file, hash };
|
||||
|
||||
if (previousImages[model.slug]?.hash === hash && onDisk.has(file)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const png = await renderPng(model, icon?.data ?? null);
|
||||
if (png.length > 300 * 1024) {
|
||||
console.warn(`og: ${file} is ${(png.length / 1024).toFixed(0)}KB, over the 300KB budget`);
|
||||
}
|
||||
await writeFile(path.join(outDir, file), png);
|
||||
rendered++;
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
/*
|
||||
* The default card. Stable filename on purpose — it is referenced by every non-mint
|
||||
* page and by mints that failed above, and it changes with the site's own stats,
|
||||
* which is what `Cache-Control: max-age=3600` is for (see README).
|
||||
*/
|
||||
const fallback = defaultModel(stats);
|
||||
const fallbackHash = hashModel(fallback);
|
||||
if (previous.default?.hash !== fallbackHash || !onDisk.has('default.png')) {
|
||||
await writeFile(path.join(outDir, 'default.png'), await renderPng(fallback, null));
|
||||
rendered++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
|
||||
/* Sweep files no current listing produced, so removed mints do not linger. */
|
||||
const keep = new Set([...Object.values(images).map((i) => i.file), 'default.png']);
|
||||
for (const file of await readdir(outDir)) {
|
||||
if (!keep.has(file)) await rm(path.join(outDir, file));
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
manifestFile,
|
||||
JSON.stringify({ default: { file: 'default.png', hash: fallbackHash }, images }, null, 2) + '\n',
|
||||
);
|
||||
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`og: ${rendered} rendered, ${skipped} unchanged, ${Object.keys(images).length} mints+federations, ${seconds}s`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--fixtures')) {
|
||||
await buildFixtures();
|
||||
} else {
|
||||
await build();
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* The satori templates: card model in, 1200x630 SVG out (build-og.mjs rasterises it).
|
||||
*
|
||||
* Design tokens are the site's own (src/styles/global.css): same background, same
|
||||
* card surface, same amber/violet/mint/red, Space Grotesk over Inter over JetBrains
|
||||
* Mono. The card should read as a cropped screenshot of the site, not a poster.
|
||||
*
|
||||
* Everything rendered here comes off the model object and nothing else — the model is
|
||||
* the manifest hash input, so a value the template invented for itself would be a way
|
||||
* for an image to change without its hash noticing.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import satori from 'satori';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/* ---- palette (global.css :root) ---- */
|
||||
const BG = '#0E0D0B';
|
||||
const CARD = '#161512';
|
||||
const LINE = '#2A2720';
|
||||
const LINE_SOFT = '#211F19';
|
||||
const TEXT = '#EDE8DC';
|
||||
const MUTED = '#9A927E';
|
||||
const FAINT = '#6B6555';
|
||||
const AMBER = '#F0A93B';
|
||||
const VIOLET = '#A78BFA';
|
||||
const MINT = '#5FD68F';
|
||||
const RED = '#E06450';
|
||||
|
||||
const GROTESK = 'Space Grotesk';
|
||||
const INTER = 'Inter';
|
||||
const MONO = 'JetBrains Mono';
|
||||
|
||||
/**
|
||||
* Shorthand: satori consumes plain React-shaped objects. `display: flex` is the
|
||||
* default because satori requires it on any div with an element child.
|
||||
*/
|
||||
const h = (type, style = {}, ...children) => ({
|
||||
type,
|
||||
props: {
|
||||
style: { display: 'flex', ...style },
|
||||
children: children.flat().filter((c) => c !== null && c !== false),
|
||||
},
|
||||
});
|
||||
|
||||
/** The pixel moai from Moai.astro, as a data URI so satori can place it as an image. */
|
||||
function moai(width) {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11 13" shape-rendering="crispEdges">' +
|
||||
'<rect x="2" y="0" width="7" height="1" fill="#C9BFA4"/>' +
|
||||
'<rect x="1" y="1" width="9" height="2" fill="#C9BFA4"/>' +
|
||||
'<rect x="1" y="3" width="2" height="7" fill="#A89B7C"/>' +
|
||||
'<rect x="3" y="3" width="6" height="7" fill="#C9BFA4"/>' +
|
||||
'<rect x="3" y="4" width="2" height="1" fill="#5C5340"/>' +
|
||||
'<rect x="7" y="4" width="2" height="1" fill="#5C5340"/>' +
|
||||
'<rect x="5" y="5" width="1" height="3" fill="#A89B7C"/>' +
|
||||
'<rect x="2" y="10" width="8" height="2" fill="#A89B7C"/></svg>';
|
||||
return {
|
||||
type: 'img',
|
||||
props: {
|
||||
src: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
|
||||
width,
|
||||
height: Math.round((width / 11) * 13),
|
||||
style: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const STAR_PATH =
|
||||
'M12 2l2.9 6.22 6.82.63-5.14 4.52 1.51 6.68L12 16.55l-6.09 3.5 1.51-6.68L2.28 8.85l6.82-.63L12 2z';
|
||||
|
||||
/** Five stars, filled to Math.round(rating) — the same rounding starString() uses. */
|
||||
function stars(rating, size, color) {
|
||||
const filled = Math.round(rating);
|
||||
return h(
|
||||
'div',
|
||||
{ display: 'flex', gap: `${Math.round(size * 0.18)}px` },
|
||||
...[1, 2, 3, 4, 5].map((n) => ({
|
||||
type: 'svg',
|
||||
props: {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
style: { display: 'flex' },
|
||||
children: [
|
||||
{
|
||||
type: 'path',
|
||||
props: {
|
||||
d: STAR_PATH,
|
||||
fill: n <= filled ? color : 'none',
|
||||
stroke: n <= filled ? 'none' : FAINT,
|
||||
'stroke-width': 1.6,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** The status chip beside the name. Green only for a status a check actually verified. */
|
||||
function statusChip(model) {
|
||||
const style = (color, bg, border) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '9px',
|
||||
padding: '8px 17px',
|
||||
borderRadius: '999px',
|
||||
border: `1.5px solid ${border}`,
|
||||
backgroundColor: bg,
|
||||
color,
|
||||
fontFamily: INTER,
|
||||
fontWeight: 600,
|
||||
fontSize: '20px',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
const dot = (color) =>
|
||||
h('div', { width: '9px', height: '9px', borderRadius: '999px', backgroundColor: color });
|
||||
|
||||
switch (model.status) {
|
||||
case 'online':
|
||||
return h('div', style(MINT, 'rgba(95,214,143,.08)', 'rgba(95,214,143,.35)'), dot(MINT), 'Online');
|
||||
case 'offline': {
|
||||
const label = model.offlineDays !== null ? `Offline ${model.offlineDays}d` : 'Offline';
|
||||
return h('div', style(RED, 'rgba(224,100,80,.09)', 'rgba(224,100,80,.4)'), dot(RED), label);
|
||||
}
|
||||
case 'announced':
|
||||
// The honest-status rule: an announcement is not a heartbeat, so never green.
|
||||
return h('div', style(AMBER, 'rgba(240,169,59,.07)', 'rgba(240,169,59,.32)'), dot(AMBER), 'Announced');
|
||||
case 'degraded':
|
||||
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Unreachable');
|
||||
default:
|
||||
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Not checked yet');
|
||||
}
|
||||
}
|
||||
|
||||
/** The type badge that makes a federation card unmistakable at thumbnail size. */
|
||||
function fedimintBadge() {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
display: 'flex',
|
||||
padding: '9px 14px',
|
||||
borderRadius: '9px',
|
||||
border: `1.5px solid rgba(167,139,250,.5)`,
|
||||
color: VIOLET,
|
||||
fontFamily: MONO,
|
||||
fontWeight: 500,
|
||||
fontSize: '18px',
|
||||
letterSpacing: '3px',
|
||||
},
|
||||
'FEDIMINT',
|
||||
);
|
||||
}
|
||||
|
||||
/** The icon tile: cached icon when the indexer has one, the site's gradient tile when not. */
|
||||
function iconTile(model, iconData) {
|
||||
const size = 116;
|
||||
const shared = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '28px',
|
||||
border: '1.5px solid rgba(255,255,255,.14)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
if (iconData) {
|
||||
return {
|
||||
type: 'img',
|
||||
props: { src: iconData, width: size, height: size, style: { ...shared, objectFit: 'cover' } },
|
||||
};
|
||||
}
|
||||
const [c1, c2] = model.gradient;
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
...shared,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `linear-gradient(135deg, ${c1}, ${c2})`,
|
||||
color: BG,
|
||||
fontFamily: GROTESK,
|
||||
fontWeight: 700,
|
||||
fontSize: '52px',
|
||||
},
|
||||
model.initials,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit the name on one line by stepping the size down; extreme names get two lines,
|
||||
* and satori's lineClamp guarantees nothing ever leaves the box regardless.
|
||||
*/
|
||||
function nameSize(name, maxWidth) {
|
||||
for (const size of [58, 50, 42]) {
|
||||
if (name.length * size * 0.58 <= maxWidth) return { size, clamp: 1 };
|
||||
}
|
||||
return { size: 38, clamp: 2 };
|
||||
}
|
||||
|
||||
/** Middle-out truncation for a domain that will not fit the identity row. */
|
||||
function fitMono(value, maxChars) {
|
||||
if (value.length <= maxChars) return value;
|
||||
const head = Math.ceil((maxChars - 1) * 0.62);
|
||||
const tail = maxChars - 1 - head;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function cellLabel(text, color = FAINT) {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
fontFamily: INTER,
|
||||
fontWeight: 600,
|
||||
fontSize: '15px',
|
||||
letterSpacing: '2px',
|
||||
color,
|
||||
marginBottom: '16px',
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rating + stars + sentiment split, or the honest "no reviews yet". */
|
||||
function ratingCell(model) {
|
||||
const children = [cellLabel('COMMUNITY RATING')];
|
||||
|
||||
if (model.reviewCount === 0 || model.rating === null) {
|
||||
children.push(
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '30px', color: MUTED, marginTop: '14px' }, 'No reviews yet'),
|
||||
);
|
||||
} else {
|
||||
const starColor = model.rating < 3 ? RED : AMBER;
|
||||
children.push(
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'flex-end', gap: '18px' },
|
||||
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, model.rating.toFixed(1)),
|
||||
h('div', { display: 'flex', marginBottom: '7px' }, stars(model.rating, 27, starColor)),
|
||||
),
|
||||
);
|
||||
if (model.sentiment) {
|
||||
const { pos, neg } = model.sentiment;
|
||||
const neutral = Math.max(0, 100 - pos - neg);
|
||||
children.push(
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', width: '250px', height: '8px', borderRadius: '4px', marginTop: '22px', overflow: 'hidden' },
|
||||
pos > 0 && h('div', { width: `${pos}%`, backgroundColor: MINT }),
|
||||
neutral > 0 && h('div', { width: `${neutral}%`, backgroundColor: LINE }),
|
||||
neg > 0 && h('div', { width: `${neg}%`, backgroundColor: RED }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return h('div', { display: 'flex', flexDirection: 'column', flex: 1.25, padding: '30px 36px' }, ...children);
|
||||
}
|
||||
|
||||
function reviewsCell(model) {
|
||||
return h(
|
||||
'div',
|
||||
{ display: 'flex', flexDirection: 'column', flex: 0.9, padding: '30px 36px', borderLeft: `1px solid ${LINE_SOFT}` },
|
||||
cellLabel('REVIEWS'),
|
||||
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, String(model.reviewCount)),
|
||||
// Absolute month/year on purpose: "3d ago" goes stale inside a static PNG.
|
||||
model.lastReviewMonth !== null &&
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, `Last review ${model.lastReviewMonth}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Third cell, Cashu: published limits in mono, or the warning that replaces them. */
|
||||
function thirdCellCashu(model) {
|
||||
const base = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1.1,
|
||||
padding: '30px 36px',
|
||||
borderLeft: `1px solid ${LINE_SOFT}`,
|
||||
};
|
||||
|
||||
if (model.warning) {
|
||||
const color = model.warning.severity === 'critical' ? RED : AMBER;
|
||||
return h(
|
||||
'div',
|
||||
{ ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
|
||||
cellLabel('WARNING', color),
|
||||
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '38px', lineHeight: 1.05, color }, model.warning.label),
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
|
||||
);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
if (model.limits) {
|
||||
const { min, max, unit } = model.limits;
|
||||
if (min !== null) lines.push(['min', `${compact(min)} ${unit}`]);
|
||||
if (max !== null) lines.push(['max', `${compact(max)} ${unit}`]);
|
||||
}
|
||||
return h(
|
||||
'div',
|
||||
base,
|
||||
cellLabel('LIMITS'),
|
||||
lines.length === 0
|
||||
? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
|
||||
: h(
|
||||
'div',
|
||||
{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
|
||||
...lines.map(([k, v]) =>
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'baseline', gap: '14px' },
|
||||
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
|
||||
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function compact(n) {
|
||||
if (n >= 1_000_000) return trimNum(n / 1_000_000) + 'M';
|
||||
if (n >= 1_000) return trimNum(n / 1_000) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
function trimNum(n) {
|
||||
const s = n.toFixed(1);
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||
}
|
||||
|
||||
/** Third cell, Fedimint: modules and network — never limits, never a software version. */
|
||||
function thirdCellFedimint(model) {
|
||||
const base = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1.1,
|
||||
padding: '30px 36px',
|
||||
borderLeft: `1px solid ${LINE_SOFT}`,
|
||||
};
|
||||
|
||||
if (model.warning) {
|
||||
return h(
|
||||
'div',
|
||||
{ ...base, backgroundColor: 'rgba(224,100,80,.06)' },
|
||||
cellLabel('WARNING', RED),
|
||||
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '36px', lineHeight: 1.05, color: RED }, model.warning.label),
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
|
||||
);
|
||||
}
|
||||
|
||||
const list = model.modules.length > 0 ? fitMono(model.modules.join(', '), 40) : 'Not announced';
|
||||
const mainnet = model.network === 'mainnet';
|
||||
return h(
|
||||
'div',
|
||||
base,
|
||||
cellLabel('MODULES'),
|
||||
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '24px', lineHeight: 1.4, color: model.modules.length > 0 ? TEXT : MUTED, maxWidth: '300px' }, list),
|
||||
model.network &&
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', marginTop: '16px' },
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
display: 'flex',
|
||||
padding: '6px 13px',
|
||||
borderRadius: '8px',
|
||||
border: `1.5px solid ${mainnet ? 'rgba(95,214,143,.4)' : 'rgba(240,169,59,.4)'}`,
|
||||
color: mainnet ? MINT : AMBER,
|
||||
fontFamily: MONO,
|
||||
fontWeight: 500,
|
||||
fontSize: '17px',
|
||||
letterSpacing: '1px',
|
||||
},
|
||||
model.network,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
|
||||
function glow(model) {
|
||||
const danger =
|
||||
model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical');
|
||||
const color = danger
|
||||
? 'rgba(224,100,80,.13)'
|
||||
: model.type === 'fedimint'
|
||||
? 'rgba(167,139,250,.11)'
|
||||
: 'rgba(240,169,59,.10)';
|
||||
return h('div', {
|
||||
position: 'absolute',
|
||||
top: '-260px',
|
||||
right: '-180px',
|
||||
width: '760px',
|
||||
height: '760px',
|
||||
borderRadius: '999px',
|
||||
backgroundImage: `radial-gradient(circle at center, ${color} 0%, rgba(14,13,11,0) 62%)`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Brand row, shared by every variant. */
|
||||
function brandRow(tagline) {
|
||||
return h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'center', gap: '16px' },
|
||||
moai(30),
|
||||
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '29px', color: TEXT, letterSpacing: '-0.5px' }, 'Cashumints.space'),
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '23px', color: FAINT, marginTop: '2px' }, `· ${tagline}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** One mint or federation card. `iconData` is a data: URI or null. */
|
||||
export function mintCard(model, iconData) {
|
||||
const isFed = model.type === 'fedimint';
|
||||
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
|
||||
|
||||
// Room beside the icon: canvas minus padding, icon, gaps and the chips.
|
||||
const chipRoom = 240 + (isFed ? 180 : 0);
|
||||
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
width: '1200px',
|
||||
height: '630px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: BG,
|
||||
padding: '56px 64px',
|
||||
position: 'relative',
|
||||
fontFamily: INTER,
|
||||
},
|
||||
glow(model),
|
||||
brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
|
||||
iconTile(model, iconData),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0 },
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'center', gap: '20px', flexWrap: 'wrap' },
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
fontFamily: GROTESK,
|
||||
fontWeight: 700,
|
||||
fontSize: `${size}px`,
|
||||
lineHeight: 1.08,
|
||||
color: TEXT,
|
||||
letterSpacing: '-1px',
|
||||
maxWidth: '760px',
|
||||
lineClamp: clamp,
|
||||
},
|
||||
model.name,
|
||||
),
|
||||
statusChip(model),
|
||||
isFed && fedimintBadge(),
|
||||
),
|
||||
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine),
|
||||
// The Fedimint soft warning is a muted line, deliberately not a red cell.
|
||||
isFed &&
|
||||
model.announcedNote !== null &&
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '20px', color: 'rgba(240,169,59,.75)', marginTop: '10px' }, model.announcedNote),
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
display: 'flex',
|
||||
backgroundColor: CARD,
|
||||
border: `1px solid ${LINE}`,
|
||||
borderRadius: '20px',
|
||||
minHeight: '196px',
|
||||
},
|
||||
ratingCell(model),
|
||||
reviewsCell(model),
|
||||
isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The default card: brand, tagline, and the network's numbers. Used site-wide. */
|
||||
export function defaultCard(model) {
|
||||
const stat = (n, label) =>
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' },
|
||||
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '54px', lineHeight: 1, color: TEXT }, String(n)),
|
||||
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '21px', color: MUTED }, label),
|
||||
);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
width: '1200px',
|
||||
height: '630px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: BG,
|
||||
position: 'relative',
|
||||
fontFamily: INTER,
|
||||
},
|
||||
h('div', {
|
||||
position: 'absolute',
|
||||
top: '-300px',
|
||||
left: '220px',
|
||||
width: '760px',
|
||||
height: '760px',
|
||||
borderRadius: '999px',
|
||||
backgroundImage: 'radial-gradient(circle at center, rgba(240,169,59,.11) 0%, rgba(14,13,11,0) 62%)',
|
||||
}),
|
||||
moai(64),
|
||||
h(
|
||||
'div',
|
||||
{ fontFamily: GROTESK, fontWeight: 700, fontSize: '66px', color: TEXT, letterSpacing: '-1.5px', marginTop: '30px' },
|
||||
'Cashumints.space',
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
|
||||
'Cashu mint and Fedimint reviews, signed on Nostr',
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', gap: '86px', marginTop: '64px' },
|
||||
stat(model.mints, 'Cashu mints'),
|
||||
stat(model.federations, 'federations'),
|
||||
stat(model.reviews, 'signed reviews'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- fonts ---------- */
|
||||
|
||||
let fontsPromise = null;
|
||||
|
||||
/**
|
||||
* The six static faces satori embeds as glyph paths. Static TTF instances rather than
|
||||
* the site's variable woff2 files, because satori reads neither woff2 nor variable
|
||||
* axes; the weights are the ones the mockup uses. A missing file is a failed build:
|
||||
* satori silently falling back to another face is exactly the "serif in the PNG"
|
||||
* failure the acceptance criteria forbid.
|
||||
*/
|
||||
export function loadFonts() {
|
||||
fontsPromise ??= Promise.all(
|
||||
[
|
||||
[GROTESK, 600, 'space-grotesk-600.ttf'],
|
||||
[GROTESK, 700, 'space-grotesk-700.ttf'],
|
||||
[INTER, 500, 'inter-500.ttf'],
|
||||
[INTER, 600, 'inter-600.ttf'],
|
||||
[MONO, 400, 'jetbrains-mono-400.ttf'],
|
||||
[MONO, 500, 'jetbrains-mono-500.ttf'],
|
||||
].map(async ([name, weight, file]) => ({
|
||||
name,
|
||||
weight,
|
||||
style: 'normal',
|
||||
data: await readFile(path.join(here, 'fonts', file)),
|
||||
})),
|
||||
);
|
||||
return fontsPromise;
|
||||
}
|
||||
|
||||
/* ---------- glyphs outside Latin ---------- */
|
||||
|
||||
const assetCache = new Map();
|
||||
const cacheDir = path.join(here, 'fonts', 'cache');
|
||||
|
||||
/**
|
||||
* Satori calls this for graphemes the six faces cannot draw (one indexed mint has a
|
||||
* CJK name today). The Noto subset for exactly those characters is fetched once from
|
||||
* Google Fonts and cached on disk, so repeat builds are offline and deterministic.
|
||||
* If the fetch fails the glyphs are skipped — a name missing three characters beats
|
||||
* a failed build, and the failure is logged by the caller.
|
||||
*/
|
||||
async function loadAdditionalAsset(code, segment) {
|
||||
if (code === 'emoji') return []; // no emoji font is embedded; skip rather than tofu
|
||||
const family = { 'ja-JP': 'Noto Sans JP', 'ko-KR': 'Noto Sans KR', 'zh-CN': 'Noto Sans SC', 'zh-TW': 'Noto Sans TC', 'th-TH': 'Noto Sans Thai' }[code] ?? 'Noto Sans SC';
|
||||
|
||||
const key = createHash('sha1').update(`${family}:${segment}`).digest('hex');
|
||||
if (assetCache.has(key)) return assetCache.get(key);
|
||||
|
||||
const file = path.join(cacheDir, `${key}.ttf`);
|
||||
let data = await readFile(file).catch(() => null);
|
||||
|
||||
if (!data) {
|
||||
try {
|
||||
const css = await fetch(
|
||||
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}&text=${encodeURIComponent(segment)}`,
|
||||
{ headers: { 'User-Agent': 'curl/7.68' } },
|
||||
).then((r) => r.text());
|
||||
// The subsetted file comes back as `url(https://fonts.gstatic.com/l/font?kit=…)
|
||||
// format('truetype')` — a kit URL, not a .ttf path.
|
||||
const url = /url\((https:[^)]+)\)\s*format\('truetype'\)/.exec(css)?.[1];
|
||||
if (!url) throw new Error('no truetype url in css response');
|
||||
data = Buffer.from(await fetch(url).then((r) => r.arrayBuffer()));
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(file, data);
|
||||
} catch (error) {
|
||||
console.warn(`og: no glyphs for "${segment}" (${error.message}); rendering without`);
|
||||
assetCache.set(key, []);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const fonts = [{ name: family, data, weight: 400, style: 'normal' }];
|
||||
assetCache.set(key, fonts);
|
||||
return fonts;
|
||||
}
|
||||
|
||||
/** Render one card model to SVG. */
|
||||
export async function renderSvg(model, iconData) {
|
||||
const element = model.type === 'default' ? defaultCard(model) : mintCard(model, iconData);
|
||||
return satori(element, {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: await loadFonts(),
|
||||
loadAdditionalAsset,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The edge cases the template must survive, as synthetic API payloads.
|
||||
*
|
||||
* Run through the same cashuModel/fedimintModel derivation as real data — a fixture
|
||||
* that bypassed the model would test a card no build can produce. `NOW` is pinned so
|
||||
* the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as
|
||||
* eyeball snapshots.
|
||||
*/
|
||||
|
||||
/** 1 Aug 2026 00:00 UTC. Day counts and month stamps in fixtures derive from this. */
|
||||
export const NOW = 1_785_542_400;
|
||||
|
||||
const dist = (five = 0, four = 0, three = 0, two = 0, one = 0) => ({
|
||||
5: five, 4: four, 3: three, 2: two, 1: one,
|
||||
});
|
||||
|
||||
const base = {
|
||||
type: 'cashu',
|
||||
status: 'online',
|
||||
last_online: NOW - 300,
|
||||
last_probe: NOW - 300,
|
||||
first_seen: NOW - 400 * 86400,
|
||||
review_count: 26,
|
||||
rating_avg: 4.6,
|
||||
rating_distribution: dist(18, 5, 2, 0, 1),
|
||||
last_review_at: NOW - 12 * 86400,
|
||||
info: {
|
||||
nuts: {
|
||||
4: { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 100, max_amount: 500000 }], disabled: false },
|
||||
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FIXTURES = [
|
||||
{
|
||||
// A name at the 30-character mark: must step down, never clip.
|
||||
...base,
|
||||
host: 'fixture-long-name',
|
||||
url: 'https://mint.veryserious.example',
|
||||
name: 'The Extremely Serious Mint Co.',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
// No cached icon: the deterministic initial-on-gradient tile.
|
||||
...base,
|
||||
host: 'fixture-no-icon',
|
||||
url: 'https://mint.plain.example',
|
||||
name: 'Plain Mint',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
// Zero reviews: "No reviews yet" in the rating cell, no stars, no sentiment bar.
|
||||
...base,
|
||||
host: 'fixture-zero-reviews',
|
||||
url: 'https://mint.unreviewed.example',
|
||||
name: 'Fresh Mint',
|
||||
icon: null,
|
||||
review_count: 0,
|
||||
rating_avg: null,
|
||||
rating_distribution: dist(),
|
||||
last_review_at: null,
|
||||
},
|
||||
{
|
||||
// Offline 12 days: red glow, "Offline 12d" chip, warning cell instead of limits.
|
||||
...base,
|
||||
host: 'fixture-offline',
|
||||
url: 'https://mint.gone.example',
|
||||
name: 'Vanished Mint',
|
||||
icon: null,
|
||||
status: 'offline',
|
||||
last_online: NOW - 12 * 86400,
|
||||
rating_avg: 2.4,
|
||||
rating_distribution: dist(1, 1, 2, 4, 6),
|
||||
review_count: 14,
|
||||
},
|
||||
{
|
||||
// Melt only but ONLINE: green chip stays, warning cell appears anyway.
|
||||
...base,
|
||||
host: 'fixture-melt-only',
|
||||
url: 'https://mint.windingdown.example',
|
||||
name: 'Winding Down Mint',
|
||||
icon: null,
|
||||
info: {
|
||||
nuts: {
|
||||
4: { methods: [], disabled: true },
|
||||
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A federation announced 3 months ago that nothing ever confirmed.
|
||||
type: 'fedimint',
|
||||
host: 'fixture-fed-announced',
|
||||
url: 'fed11qfixture',
|
||||
name: 'Quiet Corner Federation',
|
||||
icon: null,
|
||||
status: 'announced',
|
||||
last_online: null,
|
||||
last_probe: null,
|
||||
first_seen: NOW - 95 * 86400,
|
||||
announced_at: NOW - 92 * 86400,
|
||||
review_count: 3,
|
||||
rating_avg: 4.3,
|
||||
rating_distribution: dist(1, 2),
|
||||
last_review_at: NOW - 40 * 86400,
|
||||
federation_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||
modules: ['ln', 'mint', 'wallet', 'meta'],
|
||||
network: 'mainnet',
|
||||
status_source: null,
|
||||
},
|
||||
];
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The card model: everything one social image renders, and nothing else.
|
||||
*
|
||||
* This object is the hash input for the manifest, so it must be derived purely from
|
||||
* API data plus the day the build runs on. Two rules follow from that:
|
||||
*
|
||||
* - No relative times finer than a day. "3 minutes ago" in a static PNG is a lie by
|
||||
* the time anyone sees it; day counts and month/year stamps go stale slowly enough
|
||||
* to survive until the next scheduled rebuild regenerates them.
|
||||
* - Everything the template touches is in the model, and the template touches
|
||||
* nothing but the model. A design change is a TEMPLATE_VERSION bump, which busts
|
||||
* every hash at once; a data change busts exactly the mints it happened to.
|
||||
*
|
||||
* The warning logic is imported from shared/, not restated: the image must never
|
||||
* disagree with the page it decorates about whether a mint is frozen or gone.
|
||||
*/
|
||||
import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared';
|
||||
|
||||
/** Bump when the template's layout or copy changes, to regenerate every image. */
|
||||
export const TEMPLATE_VERSION = 1;
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
/** English month/year ("Mar 2026") — the images are one English render for all locales. */
|
||||
export function monthYear(unix) {
|
||||
return new Date(unix * 1000).toLocaleDateString('en-GB', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/** "3 Aug 2026", matching f.date() on the pages. */
|
||||
export function shortDate(unix) {
|
||||
return new Date(unix * 1000).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- mirrors of web/src/lib/format.ts, which is TypeScript this script cannot load.
|
||||
Same algorithms, asserted by eyeball against the site's own fallback tiles. ---- */
|
||||
|
||||
export function displayDomain(url) {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** The site's deterministic gradient, resolved to hex pairs satori understands. */
|
||||
export function iconGradient(seed) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
const hue = hash % 360;
|
||||
const hue2 = (hue + 40) % 360;
|
||||
return [hsl(hue, 45, 55), hsl(hue2, 45, 45)];
|
||||
}
|
||||
|
||||
function hsl(h, s, l) {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const k = (n) => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||
const hex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
|
||||
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
|
||||
}
|
||||
|
||||
export function initials(name) {
|
||||
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
|
||||
const first = cleaned.trim()[0] ?? '?';
|
||||
if (/\d/.test(first)) return cleaned.slice(0, 2);
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
export function truncateMiddle(value, head = 10, tail = 8) {
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
/** 4–5 star share vs 1–2 star share, as the site's sentiment bar splits them. */
|
||||
export function sentiment(dist) {
|
||||
const positive = (dist['5'] ?? 0) + (dist['4'] ?? 0);
|
||||
const negative = (dist['2'] ?? 0) + (dist['1'] ?? 0);
|
||||
const total = positive + negative + (dist['3'] ?? 0);
|
||||
if (total === 0) return null;
|
||||
return {
|
||||
pos: Math.round((positive / total) * 100),
|
||||
neg: Math.round((negative / total) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/** "500k", "1M", "100" — sat amounts at stats-bar size. */
|
||||
export function compactSats(n) {
|
||||
if (n >= 1_000_000) return trim(n / 1_000_000) + 'M';
|
||||
if (n >= 1_000) return trim(n / 1_000) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function trim(n) {
|
||||
const s = n.toFixed(1);
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||
}
|
||||
|
||||
/** The warning cell's two lines, from the same warning object the page banner uses. */
|
||||
function warningCell(warning, mint, now) {
|
||||
if (!warning) return null;
|
||||
|
||||
const since = mint.last_online;
|
||||
const label = {
|
||||
'gone': 'Likely gone',
|
||||
'melt-disabled': 'No withdrawals',
|
||||
'frozen': 'Frozen',
|
||||
'melt-only': 'Melt only',
|
||||
'offline-long': 'Offline',
|
||||
'offline': 'Offline',
|
||||
'fedimint-offline': 'Reported offline',
|
||||
}[warning.kind];
|
||||
if (!label) return null;
|
||||
|
||||
let sub;
|
||||
switch (warning.kind) {
|
||||
case 'gone':
|
||||
case 'offline-long':
|
||||
case 'offline':
|
||||
case 'fedimint-offline':
|
||||
sub = since !== null ? `Since ${shortDate(since)}` : 'Never reached';
|
||||
break;
|
||||
case 'melt-only':
|
||||
sub = 'Deposits disabled';
|
||||
break;
|
||||
case 'melt-disabled':
|
||||
sub = 'Deposits still open';
|
||||
break;
|
||||
case 'frozen':
|
||||
sub = 'Nothing moves in or out';
|
||||
break;
|
||||
}
|
||||
|
||||
// The mint is also offline while a capability warning outranks that fact: the cell
|
||||
// still has to say so, the way the page banner folds it into its last sentence.
|
||||
if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
|
||||
sub = `Offline since ${shortDate(since)}`;
|
||||
}
|
||||
|
||||
return { kind: warning.kind, severity: warning.severity, label, sub };
|
||||
}
|
||||
|
||||
/**
|
||||
* One Cashu mint's card model, from its detail payload.
|
||||
*
|
||||
* `iconHash` arrives from the caller (a digest of the cached icon bytes), so a mint
|
||||
* that changes its logo behind the same URL regenerates. `now` is passed in rather
|
||||
* than read here so fixtures render the same PNG forever.
|
||||
*/
|
||||
export function cashuModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||
const domain = displayDomain(mint.url);
|
||||
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
|
||||
const warning = getMintWarnings(mint, { now })[0] ?? null;
|
||||
const offline = mint.status === 'offline';
|
||||
const offlineDays =
|
||||
offline && mint.last_online !== null
|
||||
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
|
||||
: null;
|
||||
|
||||
const limits = parseLimits(mint.info?.nuts, 4);
|
||||
const caps = readCapabilities(mint.info?.nuts);
|
||||
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'cashu',
|
||||
slug: mint.host,
|
||||
name,
|
||||
domain,
|
||||
status: mint.status,
|
||||
offlineDays,
|
||||
rating: mint.rating_avg,
|
||||
reviewCount: mint.review_count,
|
||||
sentiment: sentiment(mint.rating_distribution ?? {}),
|
||||
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
|
||||
limits:
|
||||
limits && (limits.min !== null || limits.max !== null) && !caps.mintDisabled
|
||||
? { min: limits.min, max: limits.max, unit: limits.unit }
|
||||
: null,
|
||||
warning: warningCell(warning, mint, now),
|
||||
iconHash,
|
||||
gradient: iconHash ? null : iconGradient(domain),
|
||||
initials: iconHash ? null : initials(name),
|
||||
};
|
||||
}
|
||||
|
||||
/** One federation's card model. Same skeleton, only honest fields. */
|
||||
export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||
const federationId = fed.federation_id ?? '';
|
||||
const name = (fed.name ?? '').trim() || 'Unnamed federation';
|
||||
const warning = getMintWarnings(fed, { now })[0] ?? null;
|
||||
const announced = fed.status === 'announced';
|
||||
const offlineDays =
|
||||
fed.status === 'offline' && fed.last_online !== null
|
||||
? Math.max(0, Math.floor((now - fed.last_online) / DAY))
|
||||
: null;
|
||||
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'fedimint',
|
||||
slug: fed.host,
|
||||
name,
|
||||
/** `fed11…` prefix plus the id tail: what the identity row shows where a domain sits. */
|
||||
shortId: federationId ? `fed11…${federationId.slice(-6)}` : fed.host,
|
||||
status: fed.status,
|
||||
offlineDays,
|
||||
rating: fed.rating_avg,
|
||||
reviewCount: fed.review_count,
|
||||
sentiment: sentiment(fed.rating_distribution ?? {}),
|
||||
lastReviewMonth: fed.last_review_at ? monthYear(fed.last_review_at) : null,
|
||||
modules: fed.modules ?? [],
|
||||
network: fed.network,
|
||||
/** The soft warning: never a red cell, just this muted line under the identity row. */
|
||||
announcedNote:
|
||||
announced && warning?.kind === 'never-confirmed' && fed.announced_at
|
||||
? `Announced ${shortDate(fed.announced_at)}, never confirmed`
|
||||
: null,
|
||||
warning: fed.status === 'offline' ? warningCell(warning, fed, now) : null,
|
||||
iconHash,
|
||||
gradient: iconHash ? null : iconGradient(federationId || fed.host),
|
||||
initials: iconHash ? null : initials(name),
|
||||
};
|
||||
}
|
||||
|
||||
/** The default card: brand, tagline, and the network's own numbers. */
|
||||
export function defaultModel(stats) {
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'default',
|
||||
slug: 'default',
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
reviews: stats.reviews_total,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const t = useI18n(locale);
|
||||
*/
|
||||
const nav = [
|
||||
{ path: '/mints', label: t('footer.allMints') },
|
||||
{ path: '/fedimints', label: t('footer.allFedimints') },
|
||||
{ path: '/reviews', label: t('footer.allReviews') },
|
||||
{ path: '/wallets', label: t('footer.wallets') },
|
||||
{ path: '/about', label: t('footer.about') },
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
/**
|
||||
* The invite codes block on a federation page.
|
||||
*
|
||||
* An invite code is the whole point of the page: it is what a wallet needs to join, it
|
||||
* is 150 to 300 characters long, and it is opaque. So it gets the two affordances that
|
||||
* length demands and nothing else — one click to copy the whole thing, and a QR to
|
||||
* point a phone at.
|
||||
*
|
||||
* Both are prerendered. The code is truncated in the middle by CSS-free string maths
|
||||
* (`shortInviteCode`) rather than by an ellipsis rule, so the tail is visible: two codes
|
||||
* for the same federation differ at the end, and a row that cuts there shows two
|
||||
* identical rows. The QR is encoded at build time like the mint page's, so opening it
|
||||
* costs no JavaScript beyond the toggle itself and works on a page whose island never
|
||||
* loaded — the panel is a `<details>`, which opens on its own.
|
||||
*
|
||||
* Most federations publish exactly one code. Some publish several (different guardian
|
||||
* address sets for the same federation), and every one of them is equally valid, so
|
||||
* they are all listed rather than one being picked.
|
||||
*/
|
||||
import { shortInviteCode } from '@cashumints/shared';
|
||||
import { qrSvg } from '../lib/qr';
|
||||
import { useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
codes: string[];
|
||||
/** Named in the QR's caption, so a screenshot of it says which federation it joins. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const { codes, name } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
/*
|
||||
* Encoded once per code, at build time.
|
||||
*
|
||||
* An invite code is long enough that its QR runs to a high version with small modules,
|
||||
* which is exactly why the frame below is given as much width as the panel has: a
|
||||
* 300 character code at 180px is not readable by a phone camera.
|
||||
*/
|
||||
const rows = codes.map((code) => ({
|
||||
code,
|
||||
short: shortInviteCode(code),
|
||||
qr: qrSvg(code),
|
||||
}));
|
||||
---
|
||||
|
||||
<div class="panel invite-panel" data-reveal>
|
||||
<h2 class="side-title">
|
||||
{t('fedimint.invite.title')}
|
||||
{rows.length > 1 && <span class="invite-count">{t('fedimint.invite.count', { n: rows.length })}</span>}
|
||||
</h2>
|
||||
<p class="invite-note">{t('fedimint.invite.note')}</p>
|
||||
|
||||
{
|
||||
rows.map((row, index) => (
|
||||
<div class="invite">
|
||||
<div class="invite-row">
|
||||
{/*
|
||||
The code is the copy control itself, not a label beside one: it is the only
|
||||
thing on this page anybody copies, and hiding that behind a small icon
|
||||
button would be making the primary action the least visible.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
class="cid invite-code"
|
||||
data-copy-id={row.code}
|
||||
title={row.code}
|
||||
aria-label={t('common.copyFull', { kind: t('fedimint.invite.kind') })}
|
||||
>
|
||||
<span class="cid-text mono">{row.short}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="invite-qr-btn"
|
||||
data-invite-qr={index}
|
||||
aria-expanded="false"
|
||||
aria-controls={`invite-qr-${index}`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="5" height="5" rx="1" /><rect x="16" y="3" width="5" height="5" rx="1" />
|
||||
<rect x="3" y="16" width="5" height="5" rx="1" />
|
||||
<path d="M21 16h-3a2 2 0 0 0-2 2v3M21 21v.01M12 7v3a2 2 0 0 1-2 2H7M12 3h.01M12 16v.01M16 12h1M21 12v.01M12 21v-1" />
|
||||
</svg>
|
||||
<span>{t('fedimint.invite.qr')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Hidden rather than absent, so the toggle has something to reveal without a
|
||||
round trip and the code is in the markup for anything that reads it.
|
||||
*/}
|
||||
<div class="invite-qr" id={`invite-qr-${index}`} hidden>
|
||||
<div class="qr-frame">
|
||||
<Fragment set:html={row.qr} />
|
||||
</div>
|
||||
<p class="invite-qr-cap">{t('fedimint.invite.qrCaption', { name })}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.invite-panel { padding: 20px; }
|
||||
.side-title { display: flex; align-items: baseline; gap: 8px; }
|
||||
.invite-count { font-family: var(--mono); font-size: 11px; color: var(--faint); letter-spacing: 0; text-transform: none; }
|
||||
.invite-note { font-size: 12.5px; color: var(--muted); line-height: 1.55; margin-bottom: 12px; }
|
||||
|
||||
.invite + .invite { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.invite-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* The code fills the row and the QR button keeps its own width beside it. */
|
||||
.invite-code {
|
||||
flex: 1; min-width: 0; justify-content: flex-start;
|
||||
background: var(--card2); border: 1px solid var(--line-soft); border-radius: 9px;
|
||||
padding: 9px 11px; font-size: 12.5px; color: var(--text);
|
||||
}
|
||||
.invite-code:hover { color: var(--amber); border-color: #38342A; }
|
||||
.invite-code .cid-text { overflow-wrap: anywhere; }
|
||||
|
||||
.invite-qr-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
|
||||
font-family: var(--body); font-size: 12.5px; font-weight: 500; color: var(--muted);
|
||||
background: none; border: 1px solid var(--line); border-radius: 9px;
|
||||
padding: 9px 12px; cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.invite-qr-btn:hover { color: var(--text); border-color: #38342A; }
|
||||
.invite-qr-btn[aria-expanded='true'] { color: var(--amber); border-color: var(--amber-dim); }
|
||||
|
||||
.invite-qr { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
/* `display: flex` above outranks the `hidden` attribute's own `display: none`, so
|
||||
the code opened on page load until this said otherwise. */
|
||||
.invite-qr[hidden] { display: none; }
|
||||
/*
|
||||
* The white plate is the quiet zone a camera needs, so it stays white in every theme.
|
||||
* Wider than the mint page's 236px: an invite code encodes to a much denser code, and
|
||||
* at that size the modules are too small for a phone to resolve.
|
||||
*/
|
||||
.invite-qr .qr-frame {
|
||||
background: #FFFFFF; border-radius: 12px; padding: 12px;
|
||||
width: 100%; max-width: 300px; aspect-ratio: 1; box-sizing: border-box;
|
||||
}
|
||||
.invite-qr .qr-frame :global(svg) { display: block; }
|
||||
.invite-qr-cap { font-size: 11.5px; color: var(--faint); text-align: center; }
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Two controls on one row stop fitting; the code keeps the full width. */
|
||||
.invite-row { flex-wrap: wrap; }
|
||||
.invite-qr-btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../lib/client';
|
||||
import { onReady, prefersReducedMotion } from '../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
// The codes are ordinary copyable identifiers; one delegated handler drives every
|
||||
// one of them, the same one the npubs on a review card use.
|
||||
wireCopyableIds();
|
||||
|
||||
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-invite-qr]')) {
|
||||
if (button.dataset['qrWired']) continue;
|
||||
button.dataset['qrWired'] = '1';
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const panel = document.getElementById(button.getAttribute('aria-controls') ?? '');
|
||||
if (!panel) return;
|
||||
const open = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!open));
|
||||
panel.hidden = open;
|
||||
// The code arrives rather than appearing, which reads as a response to the
|
||||
// press. Nothing moves under it: the panel is the last thing in its block.
|
||||
if (!open && !prefersReducedMotion()) {
|
||||
panel.classList.add('appearing');
|
||||
panel.addEventListener('animationend', () => panel.classList.remove('appearing'), { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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 =
|
||||
*/}
|
||||
<a
|
||||
class:list={['mint-card', { 'is-offline': offline }]}
|
||||
href={localePath(`/mint/${mint.host}`, locale)}
|
||||
href={href}
|
||||
data-reveal
|
||||
data-reveal-delay={revealDelay}
|
||||
data-mint-card
|
||||
@@ -126,7 +147,7 @@ const bar =
|
||||
}
|
||||
<span class="mc-id">
|
||||
<span class="mc-name" data-vt-name={transitionName('name', mint.host)}>{name}</span>
|
||||
<span class="mc-domain">{domain}</span>
|
||||
<span class:list={['mc-domain', { mono: fedimint }]}>{domain}</span>
|
||||
</span>
|
||||
{rank !== undefined && <span class:list={['mc-rank', { gold: rank === 1 }]}>#{rank}</span>}
|
||||
</div>
|
||||
@@ -164,13 +185,20 @@ const bar =
|
||||
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
|
||||
<span class="last">
|
||||
{
|
||||
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')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLElement>('[data-live-modules]');
|
||||
if (modules) {
|
||||
modules.innerHTML = moduleRowsHtml(mint.modules ?? [], f);
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = readCapabilities(mint.info?.nuts);
|
||||
|
||||
const limits = document.querySelector<HTMLElement>('[data-live-limits]');
|
||||
@@ -112,7 +131,7 @@ const { host } = Astro.props;
|
||||
const chip = document.querySelector<HTMLElement>('[data-live-status]');
|
||||
const label = document.querySelector<HTMLElement>('[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<HTMLElement>('[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) })
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
|
||||
@@ -85,7 +106,7 @@ function escapeText(value: string): string {
|
||||
<span class="nip-badge">NIP-87</span>
|
||||
<div class="filters" role="group" aria-label={t('reviews.filter.group')}>
|
||||
<button class="filter" type="button" data-filter="all" aria-pressed="true">
|
||||
{t('reviews.filter.all')} · <span data-count="all">{f.number(mint.review_count)}</span>
|
||||
{t('reviews.filter.all')} · <span data-count="all">{f.number(reviewCount)}</span>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="5" aria-pressed="false">
|
||||
{t('reviews.filter.five')} · <span data-count="5">{f.number(fiveStar)}</span>
|
||||
@@ -111,7 +132,7 @@ function escapeText(value: string): string {
|
||||
counts above are prerendered and stay put. `data-boneyard` marks the capture
|
||||
target for `pnpm bones`, which snapshots this element's single child.
|
||||
*/}
|
||||
<div data-review-list data-review-count={mint.review_count}>
|
||||
<div data-review-list data-review-count={reviewCount}>
|
||||
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
|
||||
<div data-review-items>
|
||||
{
|
||||
@@ -123,16 +144,16 @@ function escapeText(value: string): string {
|
||||
))}
|
||||
</div>
|
||||
<p class="sr-only" role="status">
|
||||
{t('reviews.loading', { n: mint.review_count })}
|
||||
{t('reviews.loading', { n: reviewCount })}
|
||||
</p>
|
||||
<Fragment set:html={noscriptFallback} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<p class="rev-status" data-review-status>
|
||||
{
|
||||
mint.review_count === 0
|
||||
reviewCount === 0
|
||||
? t('reviews.empty')
|
||||
: `${t('reviews.loading', { n: mint.review_count })}…`
|
||||
: `${t('reviews.loading', { n: reviewCount })}…`
|
||||
}
|
||||
</p>
|
||||
)
|
||||
@@ -162,7 +183,7 @@ function escapeText(value: string): string {
|
||||
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
|
||||
<div class="modal-head">
|
||||
{/* The mint's name is data, dropped into a translated sentence. */}
|
||||
<h2 id="write-title">{t('reviews.dialog.title', { name: mint.name ?? mint.host })}</h2>
|
||||
<h2 id="write-title">{t('reviews.dialog.title', { name })}</h2>
|
||||
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
@@ -197,7 +218,7 @@ function escapeText(value: string): string {
|
||||
name="content"
|
||||
rows="5"
|
||||
aria-describedby="review-count"
|
||||
placeholder={t('reviews.dialog.bodyPlaceholder')}></textarea>
|
||||
placeholder={bodyPlaceholder}></textarea>
|
||||
<span class="write-count" id="review-count" data-char-count>
|
||||
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
|
||||
</span>
|
||||
@@ -232,6 +253,7 @@ function escapeText(value: string): string {
|
||||
import { formatters } from '../i18n/format';
|
||||
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
|
||||
import { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
|
||||
import type { ReviewSubject } from '../lib/review-subject';
|
||||
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
|
||||
// The identity row is the same identity the header shows, drawn by the same helpers.
|
||||
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
|
||||
@@ -256,9 +278,34 @@ function escapeText(value: string): string {
|
||||
const f = formatters(t);
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-reviews-panel]');
|
||||
if (root) {
|
||||
const mintUrl = root.dataset['mintUrl'] ?? '';
|
||||
const mintPubkey = root.dataset['mintPubkey'] || null;
|
||||
|
||||
/*
|
||||
* The subject crosses from the page as JSON in a data attribute rather than as
|
||||
* separate attributes per field. It is one object with one meaning, the page and the
|
||||
* island are the same build, and a malformed one is a bug rather than user input —
|
||||
* but it is still parsed defensively, because a panel that threw here would take the
|
||||
* write dialog down with it.
|
||||
*
|
||||
* A function returning a const rather than a `let` assigned in a try: the closures
|
||||
* below capture it, and TypeScript will not carry a narrowing into a closure over a
|
||||
* mutable binding.
|
||||
*/
|
||||
const readSubject = (): ReviewSubject | null => {
|
||||
try {
|
||||
return JSON.parse(root?.dataset['subject'] ?? 'null') as ReviewSubject | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parsedSubject = readSubject();
|
||||
|
||||
if (root && parsedSubject) {
|
||||
// Re-bound inside the guard as a non-nullable const. `load` below is a hoisted
|
||||
// function declaration, and TypeScript will not carry the guard's narrowing into
|
||||
// one of those; this is the local it can.
|
||||
const subject: ReviewSubject = parsedSubject;
|
||||
|
||||
const listWrap = root.querySelector<HTMLElement>('[data-review-list]')!;
|
||||
const list = root.querySelector<HTMLElement>('[data-review-items]')!;
|
||||
const summarySlot = root.querySelector<HTMLElement>('[data-rating-summary]')!;
|
||||
@@ -464,7 +511,7 @@ function escapeText(value: string): string {
|
||||
|
||||
let loaded: LoadedReview[];
|
||||
try {
|
||||
loaded = await withTimeout(loadReviews(mintUrl, mintPubkey), RELAY_TIMEOUT_MS);
|
||||
loaded = await withTimeout(loadReviews(subject), RELAY_TIMEOUT_MS);
|
||||
} catch {
|
||||
showRelayError();
|
||||
return;
|
||||
@@ -589,7 +636,7 @@ function escapeText(value: string): string {
|
||||
* is the life of the intention. Nothing here is secret, but nothing here belongs
|
||||
* on the machine a week later either.
|
||||
*/
|
||||
const DRAFT_KEY = `cashumints:draft:${mintUrl}`;
|
||||
const DRAFT_KEY = `cashumints:draft:${subject.key}`;
|
||||
|
||||
function saveDraft(): void {
|
||||
if (!form) return;
|
||||
@@ -900,8 +947,7 @@ function escapeText(value: string): string {
|
||||
|
||||
const result = await publishReview({
|
||||
signer,
|
||||
mintUrl,
|
||||
mintPubkey,
|
||||
subject,
|
||||
rating,
|
||||
content,
|
||||
onSigned: (signed) => {
|
||||
@@ -928,7 +974,7 @@ function escapeText(value: string): string {
|
||||
created_at: result.event.created_at,
|
||||
rating,
|
||||
content: content.trim(),
|
||||
mint_url: mintUrl,
|
||||
mint_url: subject.key,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
current?: 'mints' | 'reviews' | 'wallets' | 'about';
|
||||
current?: 'mints' | 'fedimints' | 'reviews' | 'wallets' | 'about';
|
||||
/** Shows the compact search affordance next to the brand (mint pages, /mints). */
|
||||
mintCount?: number;
|
||||
}
|
||||
@@ -18,8 +18,18 @@ const t = useI18n(locale);
|
||||
* Route slugs stay English in every language, so the href is the English path run
|
||||
* through `localePath`. See src/i18n/routing.ts for why that trade was made.
|
||||
*/
|
||||
/*
|
||||
* "Cashu mints", not "Mints".
|
||||
*
|
||||
* Two ecosystems are listed now, so a bare "Mints" beside "Fedimints" reads as though
|
||||
* federations were something other than mints — or as though the first link covered
|
||||
* both. The extra word costs ~45px in the widest nav row and buys a label that is
|
||||
* unambiguous at a glance. It is the same wording on /mints' own heading, in the
|
||||
* breadcrumb and in the footer, so nothing calls the same page two things.
|
||||
*/
|
||||
const links = [
|
||||
{ path: '/mints', label: t('nav.mints'), key: 'mints' },
|
||||
{ path: '/fedimints', label: t('nav.fedimints'), key: 'fedimints' },
|
||||
{ path: '/reviews', label: t('nav.reviews'), key: 'reviews' },
|
||||
{ path: '/wallets', label: t('nav.wallets'), key: 'wallets' },
|
||||
{ path: '/about', label: t('nav.about'), key: 'about' },
|
||||
|
||||
@@ -52,6 +52,15 @@ translate `en.json` with it beside you.
|
||||
| mint URL | la URL del mint | de mint-URL | |
|
||||
| bunker link | el enlace bunker | de bunker-link | `bunker://` itself is never translated. |
|
||||
| seed phrase | la frase semilla | de seed phrase | |
|
||||
| Fedimint | Fedimint | Fedimint | The project's name. Never inflected, never translated. |
|
||||
| federation | la federación | de federatie | The thing a Fedimint is. Not "el fedimint". |
|
||||
| guardian | el guardián | de guardian | Who runs a federation. Dutch keeps the loanword, as with signer. |
|
||||
| invite code | el código de invitación | de invite code | The `fed11…` string. Spanish translates it, Dutch does not: NL users say "invite code". |
|
||||
| federation id | el id de la federación | het federatie-id | The 64 hex characters. Never "identificador". |
|
||||
| module | el módulo | de module | `ln`, `mint`, `wallet`. The short name itself is never translated. |
|
||||
| announced (a federation) | anunciada | aangekondigd | Its own status. NOT a synonym for unknown: see the note below. |
|
||||
| confirmed (a federation) | confirmada | bevestigd | Something outside this site checked and it answered. |
|
||||
| network (mainnet, signet) | la red | het netwerk | The network names themselves stay English. |
|
||||
|
||||
## Two decisions worth knowing
|
||||
|
||||
@@ -65,6 +74,13 @@ not need this: `minten` and `melten` are what NL and BE Nostr users actually wri
|
||||
review, "portemonnee", "ondertekenaar") reads as stiff or, worse, as a different concept.
|
||||
Spanish translates more, because Spanish tech writing does.
|
||||
|
||||
**"Announced" is not "unknown", in any language.** A Cashu mint that has not been probed
|
||||
yet is `status.unknown`: we have not looked. A federation that is `status.announced` is
|
||||
one nobody *can* look at — there is no check to run, and there may never be. Translating
|
||||
both with the same word throws away the only distinction the page is making, so pick two
|
||||
words and keep them apart: `Sin comprobar aún` against `Solo anunciada`, `Nog niet
|
||||
gecontroleerd` against `Alleen aangekondigd`.
|
||||
|
||||
## Safety copy
|
||||
|
||||
The warning banners (`mint.warnings.*`) and the disclaimer are the copy someone reads
|
||||
|
||||
@@ -75,6 +75,11 @@ export const CLIENT_NAMESPACES = [
|
||||
'mints',
|
||||
'mint',
|
||||
'nut',
|
||||
// The federation index's own count line, and the module names the live island
|
||||
// redraws when a fresh announcement disagrees with the build.
|
||||
'fedimints',
|
||||
'fedimint',
|
||||
'card',
|
||||
'reviews',
|
||||
'feed',
|
||||
'login',
|
||||
|
||||
+114
-21
@@ -33,7 +33,7 @@
|
||||
"status.unknown": "Not checked yet",
|
||||
"nav.skip": "Skip to content",
|
||||
"nav.main": "Main",
|
||||
"nav.mints": "Mints",
|
||||
"nav.mints": "Cashu mints",
|
||||
"nav.reviews": "Reviews",
|
||||
"nav.wallets": "Wallets",
|
||||
"nav.about": "About",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lang.label": "Language",
|
||||
"lang.switcher": "Choose a language",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "All mints",
|
||||
"footer.allMints": "All Cashu mints",
|
||||
"footer.allReviews": "All reviews",
|
||||
"footer.wallets": "Wallets",
|
||||
"footer.about": "About",
|
||||
@@ -91,7 +91,7 @@
|
||||
"pulse.stale": "stale",
|
||||
"mints.title": "All Cashu mints · Cashumints.space",
|
||||
"mints.description": "All {total} Cashu mints indexed from the Nostr network, {online} online and {offline} offline, with community ratings, uptime and mint software. Search and sort the full list.",
|
||||
"mints.heading": "All mints",
|
||||
"mints.heading": "All Cashu mints",
|
||||
"mints.lede": "Every mint discovered on the Nostr network, including the ones that are gone. Offline mints stay listed so you can still find and review them.",
|
||||
"mints.search.label": "Search mints by name or domain",
|
||||
"mints.search.placeholder": "Search by name or domain…",
|
||||
@@ -114,14 +114,8 @@
|
||||
"card.reviewed": "reviewed {when}",
|
||||
"card.noReviews": "no reviews yet",
|
||||
"mint.title": "{name} · Cashu mint reviews",
|
||||
"mint.description.fallback": "{name} is a Cashu mint at {domain}.",
|
||||
"mint.description.rated.one": "Rated {rating} out of 5 from {n} review signed on Nostr.",
|
||||
"mint.description.rated.other": "Rated {rating} out of 5 from {n} reviews signed on Nostr.",
|
||||
"mint.description.noReviews": "No community reviews yet.",
|
||||
"mint.description.online": "Currently online.",
|
||||
"mint.description.unreachable": "Currently unreachable.",
|
||||
"mint.breadcrumb": "Breadcrumb",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Copy mint URL",
|
||||
"mint.qrButton": "Mint QR",
|
||||
"mint.writeReview": "Write a review",
|
||||
@@ -256,7 +250,7 @@
|
||||
"reviews.filter.recent": "Recent",
|
||||
"reviews.loading.one": "Loading {n} review from Nostr relays",
|
||||
"reviews.loading.other": "Loading {n} reviews from Nostr relays",
|
||||
"reviews.empty": "No reviews yet. Be the first to review this mint.",
|
||||
"reviews.empty": "No reviews yet. Be the first to write one.",
|
||||
"reviews.emptyWritten": "No written reviews here yet. Be the first to write one.",
|
||||
"reviews.emptyFilter": "No reviews match this filter.",
|
||||
"reviews.noscript": "Reviews are signed on Nostr and load in your browser, so this list needs JavaScript. The rating and counts above come from the site itself.",
|
||||
@@ -292,7 +286,6 @@
|
||||
"reviews.dialog.stars.4": "good",
|
||||
"reviews.dialog.stars.5": "works great",
|
||||
"reviews.dialog.bodyLabel": "What should other people know?",
|
||||
"reviews.dialog.bodyPlaceholder": "How did minting, melting and support work out? Be specific.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Sign and publish",
|
||||
"reviews.dialog.signing": "Signing…",
|
||||
@@ -318,14 +311,11 @@
|
||||
"reviews.validate.rating": "Choose a rating from 1 to 5 stars.",
|
||||
"reviews.validate.short": "Write at least {n} characters so the review is useful.",
|
||||
"reviews.validate.long": "Reviews are limited to {n} characters.",
|
||||
"feed.title": "Latest Cashu mint reviews · Cashumints.space",
|
||||
"feed.description": "Every Cashu mint review as it is published: {reviews} reviews across {mints} mints, signed on Nostr and readable here newest first. Filter by rating or by mint.",
|
||||
"feed.heading": "Latest Cashu mint reviews",
|
||||
"feed.lede": "Every review of every mint, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
|
||||
"feed.title": "Latest mint and federation reviews · Cashumints.space",
|
||||
"feed.description": "Every mint and federation review as it is published: {reviews} reviews across {mints} Cashu mints and {federations} Fedimint federations, signed on Nostr and readable here newest first. Filter by ecosystem, by rating, or by name.",
|
||||
"feed.heading": "Latest reviews",
|
||||
"feed.lede": "Every review of every Cashu mint and Fedimint federation, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
|
||||
"feed.label": "Reviews",
|
||||
"feed.filterByMint": "Filter by mint",
|
||||
"feed.anyMint": "Any mint",
|
||||
"feed.clearMint": "Clear the mint filter",
|
||||
"feed.showingBuild.one": "Showing the most recent review from the last build",
|
||||
"feed.showingBuild.other": "Showing the {n} most recent reviews from the last build",
|
||||
"feed.noscript": "Reviews are signed on Nostr and load in your browser, so filtering and the full list need JavaScript. The {n} reviews below were read from the relays when this page was built.",
|
||||
@@ -543,8 +533,111 @@
|
||||
"reviews.dialog.identity.extensionGone": "Your browser extension is not answering. Log in again, or use a remote signer.",
|
||||
"seo.orgDescription": "A directory of Cashu mints, with live status from each mint and community reviews signed on Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: find a Cashu mint you can trust.",
|
||||
"seo.mint.title.one": "{name} reviews: rated {rating} from {n} review | Cashumints.space",
|
||||
"seo.mint.title.other": "{name} reviews: rated {rating} from {n} reviews | Cashumints.space",
|
||||
"seo.mint.titleOffline.one": "{name}: offline for {n} day | Cashumints.space",
|
||||
"seo.mint.titleOffline.other": "{name}: offline for {n} days | Cashumints.space",
|
||||
"seo.mint.titleNoReviews": "{name}: Cashu mint info and reviews | Cashumints.space",
|
||||
"seo.fedimint.titleNoReviews": "{name}: Fedimint federation info and reviews | Cashumints.space",
|
||||
"seo.desc.fallback": "Community reviews for {name} ({domain}).",
|
||||
"seo.desc.fallbackDomain": "Community reviews for the Cashu mint at {domain}.",
|
||||
"seo.desc.rated.one": "Rated {rating}/5 from {n} signed Nostr review.",
|
||||
"seo.desc.rated.other": "Rated {rating}/5 from {n} signed Nostr reviews.",
|
||||
"seo.desc.noReviews": "No reviews yet.",
|
||||
"seo.desc.status": "{status}.",
|
||||
"seo.desc.statusLimits": "{status}, limits {limits}.",
|
||||
"seo.desc.fedModules": "{status}. Modules: {modules}.",
|
||||
"seo.desc.fedModulesNetwork": "{status}. Modules: {modules} on {network}.",
|
||||
"seo.mint.ogAlt.one": "{name}: {rating} stars from {n} review on Cashumints.space",
|
||||
"seo.mint.ogAlt.other": "{name}: {rating} stars from {n} reviews on Cashumints.space",
|
||||
"seo.mint.ogAltNoReviews": "{name} on Cashumints.space",
|
||||
"nav.home": "Home",
|
||||
"mint.description.fallbackDomain": "A Cashu mint running at {domain}.",
|
||||
"mint.more.title": "More mints",
|
||||
"mint.more.all": "See all mints"
|
||||
"mint.more.all": "See all mints",
|
||||
"nav.fedimints": "Fedimints",
|
||||
"status.announced": "Announced only",
|
||||
"card.unnamedFederation": "Unnamed federation",
|
||||
"card.notChecked": "never checked",
|
||||
"fedimints.title": "All Fedimint federations · Cashumints.space",
|
||||
"fedimints.description": "All {total} Fedimint federations announced on the Nostr network: {online} confirmed running by an outside check, {announced} announced and never confirmed. Community ratings, modules and invite codes for each.",
|
||||
"fedimints.heading": "All federations",
|
||||
"fedimints.lede": "Every Fedimint federation announced on the Nostr network. A federation has no public status endpoint of its own, so some of these are confirmed running by an outside check and the rest are listed exactly as they were announced.",
|
||||
"fedimints.search.label": "Search federations by name or id",
|
||||
"fedimints.search.placeholder": "Search by name or federation id…",
|
||||
"fedimints.sort.label": "Sort federations",
|
||||
"fedimints.hideOffline": "Hide offline",
|
||||
"fedimints.showing": "Showing {total} federations ({online} confirmed up, {announced} announced only)",
|
||||
"fedimints.showingFiltered": "Showing {shown} of {total} federations ({online} confirmed up, {announced} announced only)",
|
||||
"fedimints.announcedNote.one": "One of these was announced on Nostr and nothing has confirmed since that it is running. It is listed as announced rather than as online or offline, because neither would be true.",
|
||||
"fedimints.announcedNote.other": "{n} of these were announced on Nostr and nothing has confirmed since that they are running. They are listed as announced rather than as online or offline, because neither would be true.",
|
||||
"fedimints.noResults": "No federations match. Try a name, or paste a federation id.",
|
||||
"fedimints.emptyIndex": "No federations have been announced on the relays this site reads. The list fills itself as announcements arrive.",
|
||||
"fedimint.crumb": "Fedimints",
|
||||
"fedimint.breadcrumb": "Breadcrumb",
|
||||
"fedimint.unnamed": "Unnamed federation",
|
||||
"fedimint.idLabel": "Federation",
|
||||
"fedimint.kind.federationId": "federation id",
|
||||
"fedimint.moduleCount.one": "{n} module",
|
||||
"fedimint.moduleCount.other": "{n} modules",
|
||||
"fedimint.copyInvite": "Copy invite code",
|
||||
"fedimint.confirmed.label": "Confirmed",
|
||||
"fedimint.confirmed.never": "Never",
|
||||
"fedimint.confirmed.by": "by {source}",
|
||||
"fedimint.confirmed.unattributed": "last check",
|
||||
"fedimint.announcedOn": "announced {when}",
|
||||
"fedimint.announcedUnknown": "no announcement date",
|
||||
"fedimint.modules.title": "Modules",
|
||||
"fedimint.modules.none": "This federation's announcement lists no modules.",
|
||||
"fedimint.modules.note": "Modules are the parts a federation runs. The announcement says which are present, and nothing about whether any of them is accepting deposits.",
|
||||
"fedimint.modules.notListed": "not listed",
|
||||
"fedimint.invite.title": "Invite code",
|
||||
"fedimint.invite.count.one": "{n} code",
|
||||
"fedimint.invite.count.other": "{n} codes",
|
||||
"fedimint.invite.note": "Paste this into a Fedimint wallet to join. It carries the federation id and its guardians' addresses, and it is meant to be shared.",
|
||||
"fedimint.invite.kind": "invite code",
|
||||
"fedimint.invite.qr": "QR",
|
||||
"fedimint.invite.qrCaption": "Scan to join {name}",
|
||||
"fedimint.details.federationId": "Federation id",
|
||||
"fedimint.details.network": "Network",
|
||||
"fedimint.details.announcedBy": "Announced by",
|
||||
"fedimint.details.announced": "Announced",
|
||||
"fedimint.more.title": "More federations",
|
||||
"fedimint.more.all": "See all federations",
|
||||
"fedimint.disclaimer": "Joining a federation means trusting its guardians with your money. Ratings are community opinions, and confirmed running means reachable, not trustworthy.",
|
||||
"fedimint.module.lightning": "Lightning",
|
||||
"fedimint.module.mint": "Ecash mint",
|
||||
"fedimint.module.wallet": "On-chain wallet",
|
||||
"fedimint.module.meta": "Metadata",
|
||||
"fedimint.module.stability_pool": "Stability pool",
|
||||
"fedimint.module.multi_sig_stability_pool": "Stability pool (multisig)",
|
||||
"fedimint.module.fedi-social": "Social recovery",
|
||||
"fedimint.network.mainnet": "Bitcoin mainnet",
|
||||
"fedimint.network.signet": "Signet",
|
||||
"fedimint.network.testnet": "Testnet",
|
||||
"fedimint.network.testnet4": "Testnet4",
|
||||
"fedimint.network.regtest": "Regtest",
|
||||
"feed.ecosystem.group": "Ecosystem",
|
||||
"feed.ecosystem.all": "All",
|
||||
"feed.ecosystem.cashu": "Cashu",
|
||||
"feed.ecosystem.fedimint": "Fedimint",
|
||||
"feed.filterBySubject": "Filter by mint or federation",
|
||||
"feed.anySubject": "Any mint or federation",
|
||||
"feed.clearSubject": "Clear the name filter",
|
||||
"home.fedimint.title": "Fedimint federations",
|
||||
"home.fedimint.body": "{total} announced on Nostr, {online} confirmed running, {announced} never confirmed.",
|
||||
"home.fedimint.link": "Browse federations",
|
||||
"reviews.validate.subject": "There is nothing to point this review at.",
|
||||
"mint.warnings.fedimintOffline.lead": "Reported offline.",
|
||||
"mint.warnings.fedimintOffline.body.since": "The last check that reached this federation was on {date} ({days} ago). Its guardians have not answered since. Treat funds held there as at risk, and do not join it with new funds.",
|
||||
"mint.warnings.fedimintOffline.body.never": "No check of this federation has ever reached it, and the most recent one failed. Treat funds held there as at risk, and do not join it with new funds.",
|
||||
"mint.warnings.fedimintOffline.meta.since": "Reported offline since {date}",
|
||||
"mint.warnings.fedimintOffline.meta.never": "Reported offline, never once reached",
|
||||
"mint.warnings.neverConfirmed.lead": "Announced, never confirmed.",
|
||||
"mint.warnings.neverConfirmed.body.reviewed": "This federation was announced on Nostr on {date}, {days} ago, and nothing has confirmed since then that it is running. The reviews below are the only sign of life on this page; everything else came from the announcement.",
|
||||
"mint.warnings.neverConfirmed.body.quiet": "This federation was announced on Nostr on {date}, {days} ago, nothing has confirmed since then that it is running, and nobody has reviewed it recently. Everything below came from the announcement.",
|
||||
"mint.warnings.neverConfirmed.meta": "Announced {date}, never confirmed",
|
||||
"footer.allFedimints": "All federations",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "How did minting, melting and support work out? Be specific.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "How did joining, Lightning payments and support work out? Be specific.",
|
||||
"fedimint.details.title": "Federation details"
|
||||
}
|
||||
|
||||
+114
-21
@@ -37,7 +37,7 @@
|
||||
"status.unknown": "Sin comprobar aún",
|
||||
"nav.skip": "Saltar al contenido",
|
||||
"nav.main": "Principal",
|
||||
"nav.mints": "Mints",
|
||||
"nav.mints": "Mints Cashu",
|
||||
"nav.reviews": "Reseñas",
|
||||
"nav.wallets": "Monederos",
|
||||
"nav.about": "Acerca de",
|
||||
@@ -45,7 +45,7 @@
|
||||
"lang.label": "Idioma",
|
||||
"lang.switcher": "Elige un idioma",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "Todos los mints",
|
||||
"footer.allMints": "Todos los mints Cashu",
|
||||
"footer.allReviews": "Todas las reseñas",
|
||||
"footer.wallets": "Monederos",
|
||||
"footer.about": "Acerca de",
|
||||
@@ -95,7 +95,7 @@
|
||||
"pulse.stale": "desactualizado",
|
||||
"mints.title": "Todos los mints Cashu · Cashumints.space",
|
||||
"mints.description": "Los {total} mints Cashu indexados desde la red Nostr, {online} en línea y {offline} sin conexión, con valoraciones de la comunidad, disponibilidad y software del mint. Busca y ordena la lista completa.",
|
||||
"mints.heading": "Todos los mints",
|
||||
"mints.heading": "Todos los mints Cashu",
|
||||
"mints.lede": "Todos los mints descubiertos en la red Nostr, incluidos los que ya no están. Los mints sin conexión siguen en la lista para que puedas encontrarlos y reseñarlos.",
|
||||
"mints.search.label": "Buscar mints por nombre o dominio",
|
||||
"mints.search.placeholder": "Busca por nombre o dominio…",
|
||||
@@ -118,14 +118,8 @@
|
||||
"card.reviewed": "reseñado {when}",
|
||||
"card.noReviews": "sin reseñas aún",
|
||||
"mint.title": "{name} · Reseñas del mint Cashu",
|
||||
"mint.description.fallback": "{name} es un mint Cashu en {domain}.",
|
||||
"mint.description.rated.one": "Valorado con {rating} sobre 5 a partir de {n} reseña firmada en Nostr.",
|
||||
"mint.description.rated.other": "Valorado con {rating} sobre 5 a partir de {n} reseñas firmadas en Nostr.",
|
||||
"mint.description.noReviews": "Todavía no hay reseñas de la comunidad.",
|
||||
"mint.description.online": "Ahora mismo en línea.",
|
||||
"mint.description.unreachable": "Ahora mismo no responde.",
|
||||
"mint.breadcrumb": "Ruta de navegación",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.crumbMints": "Mints Cashu",
|
||||
"mint.copyUrl": "Copiar la URL del mint",
|
||||
"mint.qrButton": "QR del mint",
|
||||
"mint.writeReview": "Escribir una reseña",
|
||||
@@ -260,7 +254,7 @@
|
||||
"reviews.filter.recent": "Recientes",
|
||||
"reviews.loading.one": "Cargando {n} reseña de los relays de Nostr",
|
||||
"reviews.loading.other": "Cargando {n} reseñas de los relays de Nostr",
|
||||
"reviews.empty": "Todavía no hay reseñas. Sé quien escriba la primera.",
|
||||
"reviews.empty": "Todavía no hay reseñas. Sé la primera persona en escribir una.",
|
||||
"reviews.emptyWritten": "Todavía no hay reseñas escritas aquí. Sé quien escriba la primera.",
|
||||
"reviews.emptyFilter": "Ninguna reseña coincide con este filtro.",
|
||||
"reviews.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que esta lista necesita JavaScript. La valoración y los recuentos de arriba vienen del propio sitio.",
|
||||
@@ -296,7 +290,6 @@
|
||||
"reviews.dialog.stars.4": "bien",
|
||||
"reviews.dialog.stars.5": "funciona genial",
|
||||
"reviews.dialog.bodyLabel": "¿Qué debería saber la gente?",
|
||||
"reviews.dialog.bodyPlaceholder": "¿Qué tal fue acuñar, fundir y el soporte? Sé concreto.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Firmar y publicar",
|
||||
"reviews.dialog.signing": "Firmando…",
|
||||
@@ -322,14 +315,11 @@
|
||||
"reviews.validate.rating": "Elige una valoración de 1 a 5 estrellas.",
|
||||
"reviews.validate.short": "Escribe al menos {n} caracteres para que la reseña sirva de algo.",
|
||||
"reviews.validate.long": "Las reseñas están limitadas a {n} caracteres.",
|
||||
"feed.title": "Últimas reseñas de mints Cashu · Cashumints.space",
|
||||
"feed.description": "Cada reseña de un mint Cashu según se publica: {reviews} reseñas de {mints} mints, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por valoración o por mint.",
|
||||
"feed.heading": "Últimas reseñas de mints Cashu",
|
||||
"feed.lede": "Todas las reseñas de todos los mints, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
|
||||
"feed.title": "Últimas reseñas de mints y federaciones · Cashumints.space",
|
||||
"feed.description": "Cada reseña de un mint o una federación según se publica: {reviews} reseñas de {mints} mints Cashu y {federations} federaciones Fedimint, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por ecosistema, por valoración o por nombre.",
|
||||
"feed.heading": "Últimas reseñas",
|
||||
"feed.lede": "Todas las reseñas de todos los mints Cashu y federaciones Fedimint, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
|
||||
"feed.label": "Reseñas",
|
||||
"feed.filterByMint": "Filtrar por mint",
|
||||
"feed.anyMint": "Cualquier mint",
|
||||
"feed.clearMint": "Borrar el filtro de mint",
|
||||
"feed.showingBuild.one": "Mostrando la reseña más reciente de la última compilación",
|
||||
"feed.showingBuild.other": "Mostrando las {n} reseñas más recientes de la última compilación",
|
||||
"feed.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que los filtros y la lista completa necesitan JavaScript. Las {n} reseñas de abajo se leyeron de los relays cuando se compiló esta página.",
|
||||
@@ -547,8 +537,111 @@
|
||||
"reviews.dialog.identity.extensionGone": "La extensión de tu navegador no responde. Vuelve a iniciar sesión, o usa un firmante remoto.",
|
||||
"seo.orgDescription": "Un directorio de mints Cashu, con el estado en vivo de cada mint y reseñas de la comunidad firmadas en Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: encuentra un mint Cashu en el que puedas confiar.",
|
||||
"seo.mint.title.one": "Reseñas de {name}: {rating} de {n} reseña | Cashumints.space",
|
||||
"seo.mint.title.other": "Reseñas de {name}: {rating} de {n} reseñas | Cashumints.space",
|
||||
"seo.mint.titleOffline.one": "{name}: sin conexión desde hace {n} día | Cashumints.space",
|
||||
"seo.mint.titleOffline.other": "{name}: sin conexión desde hace {n} días | Cashumints.space",
|
||||
"seo.mint.titleNoReviews": "{name}: información y reseñas del mint Cashu | Cashumints.space",
|
||||
"seo.fedimint.titleNoReviews": "{name}: información y reseñas de la federación Fedimint | Cashumints.space",
|
||||
"seo.desc.fallback": "Reseñas de la comunidad sobre {name} ({domain}).",
|
||||
"seo.desc.fallbackDomain": "Reseñas de la comunidad sobre el mint Cashu en {domain}.",
|
||||
"seo.desc.rated.one": "Valorado {rating}/5 en {n} reseña firmada en Nostr.",
|
||||
"seo.desc.rated.other": "Valorado {rating}/5 en {n} reseñas firmadas en Nostr.",
|
||||
"seo.desc.noReviews": "Aún sin reseñas.",
|
||||
"seo.desc.status": "{status}.",
|
||||
"seo.desc.statusLimits": "{status}, límites {limits}.",
|
||||
"seo.desc.fedModules": "{status}. Módulos: {modules}.",
|
||||
"seo.desc.fedModulesNetwork": "{status}. Módulos: {modules} en {network}.",
|
||||
"seo.mint.ogAlt.one": "{name}: {rating} estrellas de {n} reseña en Cashumints.space",
|
||||
"seo.mint.ogAlt.other": "{name}: {rating} estrellas de {n} reseñas en Cashumints.space",
|
||||
"seo.mint.ogAltNoReviews": "{name} en Cashumints.space",
|
||||
"nav.home": "Inicio",
|
||||
"mint.description.fallbackDomain": "Un mint Cashu que funciona en {domain}.",
|
||||
"mint.more.title": "Más mints",
|
||||
"mint.more.all": "Ver todos los mints"
|
||||
"mint.more.all": "Ver todos los mints",
|
||||
"nav.fedimints": "Fedimints",
|
||||
"status.announced": "Solo anunciada",
|
||||
"card.unnamedFederation": "Federación sin nombre",
|
||||
"card.notChecked": "sin comprobar nunca",
|
||||
"fedimints.title": "Todas las federaciones Fedimint · Cashumints.space",
|
||||
"fedimints.description": "Las {total} federaciones Fedimint anunciadas en la red Nostr: {online} confirmadas en funcionamiento por una comprobación externa, {announced} anunciadas y nunca confirmadas. Valoraciones de la comunidad, módulos y códigos de invitación de cada una.",
|
||||
"fedimints.heading": "Todas las federaciones",
|
||||
"fedimints.lede": "Cada federación Fedimint anunciada en la red Nostr. Una federación no tiene un endpoint público de estado, así que algunas de estas están confirmadas en funcionamiento por una comprobación externa y el resto aparecen tal y como se anunciaron.",
|
||||
"fedimints.search.label": "Buscar federaciones por nombre o id",
|
||||
"fedimints.search.placeholder": "Busca por nombre o id de federación…",
|
||||
"fedimints.sort.label": "Ordenar federaciones",
|
||||
"fedimints.hideOffline": "Ocultar sin conexión",
|
||||
"fedimints.showing": "Mostrando {total} federaciones ({online} confirmadas, {announced} solo anunciadas)",
|
||||
"fedimints.showingFiltered": "Mostrando {shown} de {total} federaciones ({online} confirmadas, {announced} solo anunciadas)",
|
||||
"fedimints.announcedNote.one": "Una de estas se anunció en Nostr y nada ha confirmado desde entonces que esté funcionando. Aparece como anunciada, no como en línea ni sin conexión, porque ninguna de las dos sería cierta.",
|
||||
"fedimints.announcedNote.other": "{n} de estas se anunciaron en Nostr y nada ha confirmado desde entonces que estén funcionando. Aparecen como anunciadas, no como en línea ni sin conexión, porque ninguna de las dos sería cierta.",
|
||||
"fedimints.noResults": "Ninguna federación coincide. Prueba con un nombre, o pega un id de federación.",
|
||||
"fedimints.emptyIndex": "No se ha anunciado ninguna federación en los relays que lee este sitio. La lista se llena sola según van llegando anuncios.",
|
||||
"fedimint.crumb": "Fedimints",
|
||||
"fedimint.breadcrumb": "Ruta de navegación",
|
||||
"fedimint.unnamed": "Federación sin nombre",
|
||||
"fedimint.idLabel": "Federación",
|
||||
"fedimint.kind.federationId": "id de la federación",
|
||||
"fedimint.moduleCount.one": "{n} módulo",
|
||||
"fedimint.moduleCount.other": "{n} módulos",
|
||||
"fedimint.copyInvite": "Copiar código de invitación",
|
||||
"fedimint.confirmed.label": "Confirmada",
|
||||
"fedimint.confirmed.never": "Nunca",
|
||||
"fedimint.confirmed.by": "por {source}",
|
||||
"fedimint.confirmed.unattributed": "última comprobación",
|
||||
"fedimint.announcedOn": "anunciada {when}",
|
||||
"fedimint.announcedUnknown": "sin fecha de anuncio",
|
||||
"fedimint.modules.title": "Módulos",
|
||||
"fedimint.modules.none": "El anuncio de esta federación no lista ningún módulo.",
|
||||
"fedimint.modules.note": "Los módulos son las piezas que ejecuta una federación. El anuncio dice cuáles están presentes, y nada sobre si alguno acepta depósitos.",
|
||||
"fedimint.modules.notListed": "no listado",
|
||||
"fedimint.invite.title": "Código de invitación",
|
||||
"fedimint.invite.count.one": "{n} código",
|
||||
"fedimint.invite.count.other": "{n} códigos",
|
||||
"fedimint.invite.note": "Pega esto en un monedero Fedimint para unirte. Lleva el id de la federación y las direcciones de sus guardianes, y está pensado para compartirse.",
|
||||
"fedimint.invite.kind": "código de invitación",
|
||||
"fedimint.invite.qr": "QR",
|
||||
"fedimint.invite.qrCaption": "Escanea para unirte a {name}",
|
||||
"fedimint.details.federationId": "Id de la federación",
|
||||
"fedimint.details.network": "Red",
|
||||
"fedimint.details.announcedBy": "Anunciada por",
|
||||
"fedimint.details.announced": "Anunciada",
|
||||
"fedimint.more.title": "Más federaciones",
|
||||
"fedimint.more.all": "Ver todas las federaciones",
|
||||
"fedimint.disclaimer": "Unirte a una federación significa confiar tu dinero a sus guardianes. Las valoraciones son opiniones de la comunidad, y confirmada en funcionamiento significa accesible, no fiable.",
|
||||
"fedimint.module.lightning": "Lightning",
|
||||
"fedimint.module.mint": "Mint de ecash",
|
||||
"fedimint.module.wallet": "Monedero on-chain",
|
||||
"fedimint.module.meta": "Metadatos",
|
||||
"fedimint.module.stability_pool": "Pool de estabilidad",
|
||||
"fedimint.module.multi_sig_stability_pool": "Pool de estabilidad (multifirma)",
|
||||
"fedimint.module.fedi-social": "Recuperación social",
|
||||
"fedimint.network.mainnet": "Bitcoin mainnet",
|
||||
"fedimint.network.signet": "Signet",
|
||||
"fedimint.network.testnet": "Testnet",
|
||||
"fedimint.network.testnet4": "Testnet4",
|
||||
"fedimint.network.regtest": "Regtest",
|
||||
"feed.ecosystem.group": "Ecosistema",
|
||||
"feed.ecosystem.all": "Todo",
|
||||
"feed.ecosystem.cashu": "Cashu",
|
||||
"feed.ecosystem.fedimint": "Fedimint",
|
||||
"feed.filterBySubject": "Filtrar por mint o federación",
|
||||
"feed.anySubject": "Cualquier mint o federación",
|
||||
"feed.clearSubject": "Borrar el filtro por nombre",
|
||||
"home.fedimint.title": "Federaciones Fedimint",
|
||||
"home.fedimint.body": "{total} anunciadas en Nostr, {online} confirmadas en funcionamiento, {announced} nunca confirmadas.",
|
||||
"home.fedimint.link": "Ver federaciones",
|
||||
"reviews.validate.subject": "No hay nada a lo que apuntar esta reseña.",
|
||||
"mint.warnings.fedimintOffline.lead": "Reportada sin conexión.",
|
||||
"mint.warnings.fedimintOffline.body.since": "La última comprobación que llegó a esta federación fue el {date} (hace {days}). Sus guardianes no responden desde entonces. Da por perdido el dinero que tengas ahí y no te unas con dinero nuevo.",
|
||||
"mint.warnings.fedimintOffline.body.never": "Ninguna comprobación ha llegado nunca a esta federación, y la más reciente falló. Da por perdido el dinero que tengas ahí y no te unas con dinero nuevo.",
|
||||
"mint.warnings.fedimintOffline.meta.since": "Reportada sin conexión desde el {date}",
|
||||
"mint.warnings.fedimintOffline.meta.never": "Reportada sin conexión, nunca accesible",
|
||||
"mint.warnings.neverConfirmed.lead": "Anunciada, nunca confirmada.",
|
||||
"mint.warnings.neverConfirmed.body.reviewed": "Esta federación se anunció en Nostr el {date}, hace {days}, y nada ha confirmado desde entonces que esté funcionando. Las reseñas de abajo son la única señal de vida de esta página; todo lo demás viene del anuncio.",
|
||||
"mint.warnings.neverConfirmed.body.quiet": "Esta federación se anunció en Nostr el {date}, hace {days}, nada ha confirmado desde entonces que esté funcionando, y nadie la ha reseñado recientemente. Todo lo de abajo viene del anuncio.",
|
||||
"mint.warnings.neverConfirmed.meta": "Anunciada el {date}, nunca confirmada",
|
||||
"footer.allFedimints": "Todas las federaciones",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "¿Qué tal fueron la acuñación, el fundido y el soporte? Sé concreto.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "¿Qué tal fueron unirte, los pagos Lightning y el soporte? Sé concreto.",
|
||||
"fedimint.details.title": "Datos de la federación"
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface Formatters {
|
||||
serverTime(unix: number): string;
|
||||
/** Grouped: 1,000,000 in English, 1.000.000 in Spanish and Dutch. */
|
||||
number(n: number): string;
|
||||
/** Compact: "500K" / "500 mil", for a limit quoted inside a meta description. */
|
||||
compact(n: number): string;
|
||||
/** A rating to one decimal, with the locale's own decimal separator. */
|
||||
decimal(n: number, digits?: number): string;
|
||||
}
|
||||
@@ -159,6 +161,9 @@ export function formatters(t: Translator): Formatters {
|
||||
|
||||
number: (n) => numberFormat(tag).format(n),
|
||||
|
||||
compact: (n) =>
|
||||
new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 }).format(n),
|
||||
|
||||
decimal: (n, digits = 1) =>
|
||||
new Intl.NumberFormat(tag, {
|
||||
minimumFractionDigits: digits,
|
||||
|
||||
+114
-21
@@ -33,7 +33,7 @@
|
||||
"status.unknown": "Nog niet gecontroleerd",
|
||||
"nav.skip": "Naar de inhoud",
|
||||
"nav.main": "Hoofdmenu",
|
||||
"nav.mints": "Mints",
|
||||
"nav.mints": "Cashu mints",
|
||||
"nav.reviews": "Reviews",
|
||||
"nav.wallets": "Wallets",
|
||||
"nav.about": "Over",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lang.label": "Taal",
|
||||
"lang.switcher": "Kies een taal",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "Alle mints",
|
||||
"footer.allMints": "Alle Cashu mints",
|
||||
"footer.allReviews": "Alle reviews",
|
||||
"footer.wallets": "Wallets",
|
||||
"footer.about": "Over",
|
||||
@@ -91,7 +91,7 @@
|
||||
"pulse.stale": "verouderd",
|
||||
"mints.title": "Alle Cashu mints · Cashumints.space",
|
||||
"mints.description": "Alle {total} Cashu mints die op het Nostr-netwerk zijn geïndexeerd, {online} online en {offline} offline, met beoordelingen uit de community, uptime en mint-software. Doorzoek en sorteer de volledige lijst.",
|
||||
"mints.heading": "Alle mints",
|
||||
"mints.heading": "Alle Cashu mints",
|
||||
"mints.lede": "Elke mint die op het Nostr-netwerk gevonden is, ook de mints die er niet meer zijn. Offline mints blijven in de lijst staan zodat je ze nog kunt vinden en reviewen.",
|
||||
"mints.search.label": "Zoek mints op naam of domein",
|
||||
"mints.search.placeholder": "Zoek op naam of domein…",
|
||||
@@ -114,14 +114,8 @@
|
||||
"card.reviewed": "laatste review {when}",
|
||||
"card.noReviews": "nog geen reviews",
|
||||
"mint.title": "{name} · Reviews van deze Cashu mint",
|
||||
"mint.description.fallback": "{name} is een Cashu mint op {domain}.",
|
||||
"mint.description.rated.one": "Beoordeeld met {rating} van de 5 op basis van {n} review, ondertekend op Nostr.",
|
||||
"mint.description.rated.other": "Beoordeeld met {rating} van de 5 op basis van {n} reviews, ondertekend op Nostr.",
|
||||
"mint.description.noReviews": "Nog geen reviews uit de community.",
|
||||
"mint.description.online": "Nu online.",
|
||||
"mint.description.unreachable": "Nu onbereikbaar.",
|
||||
"mint.breadcrumb": "Kruimelpad",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Mint-URL kopiëren",
|
||||
"mint.qrButton": "Mint-QR",
|
||||
"mint.writeReview": "Schrijf een review",
|
||||
@@ -256,7 +250,7 @@
|
||||
"reviews.filter.recent": "Recent",
|
||||
"reviews.loading.one": "{n} review van Nostr-relays laden",
|
||||
"reviews.loading.other": "{n} reviews van Nostr-relays laden",
|
||||
"reviews.empty": "Nog geen reviews. Wees de eerste die deze mint reviewt.",
|
||||
"reviews.empty": "Nog geen reviews. Schrijf als eerste er een.",
|
||||
"reviews.emptyWritten": "Hier staan nog geen geschreven reviews. Wees de eerste die er een schrijft.",
|
||||
"reviews.emptyFilter": "Geen enkele review past bij dit filter.",
|
||||
"reviews.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus deze lijst heeft JavaScript nodig. De beoordeling en de aantallen hierboven komen van de site zelf.",
|
||||
@@ -292,7 +286,6 @@
|
||||
"reviews.dialog.stars.4": "goed",
|
||||
"reviews.dialog.stars.5": "werkt prima",
|
||||
"reviews.dialog.bodyLabel": "Wat moeten anderen weten?",
|
||||
"reviews.dialog.bodyPlaceholder": "Hoe ging het minten, het melten en de support? Wees concreet.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Ondertekenen en publiceren",
|
||||
"reviews.dialog.signing": "Ondertekenen…",
|
||||
@@ -318,14 +311,11 @@
|
||||
"reviews.validate.rating": "Kies een beoordeling van 1 tot 5 sterren.",
|
||||
"reviews.validate.short": "Schrijf minstens {n} tekens, anders heeft niemand iets aan de review.",
|
||||
"reviews.validate.long": "Reviews mogen maximaal {n} tekens lang zijn.",
|
||||
"feed.title": "Laatste reviews van Cashu mints · Cashumints.space",
|
||||
"feed.description": "Elke review van een Cashu mint zodra hij verschijnt: {reviews} reviews over {mints} mints, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op beoordeling of op mint.",
|
||||
"feed.heading": "Laatste reviews van Cashu mints",
|
||||
"feed.lede": "Elke review van elke mint, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
|
||||
"feed.title": "Laatste reviews van mints en federaties · Cashumints.space",
|
||||
"feed.description": "Elke review van een mint of federatie zodra hij verschijnt: {reviews} reviews over {mints} Cashu mints en {federations} Fedimint-federaties, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op ecosysteem, op beoordeling of op naam.",
|
||||
"feed.heading": "Laatste reviews",
|
||||
"feed.lede": "Elke review van elke Cashu mint en elke Fedimint-federatie, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
|
||||
"feed.label": "Reviews",
|
||||
"feed.filterByMint": "Filteren op mint",
|
||||
"feed.anyMint": "Elke mint",
|
||||
"feed.clearMint": "Mintfilter wissen",
|
||||
"feed.showingBuild.one": "De meest recente review van de laatste build te zien",
|
||||
"feed.showingBuild.other": "De {n} meest recente reviews van de laatste build te zien",
|
||||
"feed.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus filteren en de volledige lijst hebben JavaScript nodig. De {n} reviews hieronder zijn van de relays gelezen toen deze pagina gebouwd werd.",
|
||||
@@ -543,8 +533,111 @@
|
||||
"reviews.dialog.identity.extensionGone": "Je browserextensie antwoordt niet. Log opnieuw in, of gebruik een signer op afstand.",
|
||||
"seo.orgDescription": "Een overzicht van Cashu-mints, met de actuele status van elke mint en reviews uit de gemeenschap, ondertekend op Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: vind een Cashu-mint die je kunt vertrouwen.",
|
||||
"seo.mint.title.one": "Reviews van {name}: {rating} uit {n} review | Cashumints.space",
|
||||
"seo.mint.title.other": "Reviews van {name}: {rating} uit {n} reviews | Cashumints.space",
|
||||
"seo.mint.titleOffline.one": "{name}: al {n} dag offline | Cashumints.space",
|
||||
"seo.mint.titleOffline.other": "{name}: al {n} dagen offline | Cashumints.space",
|
||||
"seo.mint.titleNoReviews": "{name}: info en reviews over deze Cashu-mint | Cashumints.space",
|
||||
"seo.fedimint.titleNoReviews": "{name}: info en reviews over deze Fedimint-federatie | Cashumints.space",
|
||||
"seo.desc.fallback": "Reviews uit de gemeenschap over {name} ({domain}).",
|
||||
"seo.desc.fallbackDomain": "Reviews uit de gemeenschap over de Cashu-mint op {domain}.",
|
||||
"seo.desc.rated.one": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende review.",
|
||||
"seo.desc.rated.other": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende reviews.",
|
||||
"seo.desc.noReviews": "Nog geen reviews.",
|
||||
"seo.desc.status": "{status}.",
|
||||
"seo.desc.statusLimits": "{status}, limieten {limits}.",
|
||||
"seo.desc.fedModules": "{status}. Modules: {modules}.",
|
||||
"seo.desc.fedModulesNetwork": "{status}. Modules: {modules} op {network}.",
|
||||
"seo.mint.ogAlt.one": "{name}: {rating} sterren uit {n} review op Cashumints.space",
|
||||
"seo.mint.ogAlt.other": "{name}: {rating} sterren uit {n} reviews op Cashumints.space",
|
||||
"seo.mint.ogAltNoReviews": "{name} op Cashumints.space",
|
||||
"nav.home": "Home",
|
||||
"mint.description.fallbackDomain": "Een Cashu-mint die draait op {domain}.",
|
||||
"mint.more.title": "Meer mints",
|
||||
"mint.more.all": "Bekijk alle mints"
|
||||
"mint.more.all": "Bekijk alle mints",
|
||||
"nav.fedimints": "Fedimints",
|
||||
"status.announced": "Alleen aangekondigd",
|
||||
"card.unnamedFederation": "Naamloze federatie",
|
||||
"card.notChecked": "nooit gecontroleerd",
|
||||
"fedimints.title": "Alle Fedimint-federaties · Cashumints.space",
|
||||
"fedimints.description": "Alle {total} Fedimint-federaties die op het Nostr-netwerk zijn aangekondigd: {online} door een externe controle bevestigd draaiend, {announced} aangekondigd en nooit bevestigd. Beoordelingen uit de community, modules en invite codes van elke federatie.",
|
||||
"fedimints.heading": "Alle federaties",
|
||||
"fedimints.lede": "Elke Fedimint-federatie die op het Nostr-netwerk is aangekondigd. Een federatie heeft zelf geen publiek status-endpoint, dus een deel hiervan is door een externe controle bevestigd draaiend en de rest staat er precies zoals hij is aangekondigd.",
|
||||
"fedimints.search.label": "Zoek federaties op naam of id",
|
||||
"fedimints.search.placeholder": "Zoek op naam of federatie-id…",
|
||||
"fedimints.sort.label": "Federaties sorteren",
|
||||
"fedimints.hideOffline": "Offline verbergen",
|
||||
"fedimints.showing": "{total} federaties te zien ({online} bevestigd, {announced} alleen aangekondigd)",
|
||||
"fedimints.showingFiltered": "{shown} van {total} federaties te zien ({online} bevestigd, {announced} alleen aangekondigd)",
|
||||
"fedimints.announcedNote.one": "Eén hiervan is op Nostr aangekondigd en niets heeft sindsdien bevestigd dat hij draait. Hij staat er als aangekondigd, niet als online of offline, want geen van beide zou kloppen.",
|
||||
"fedimints.announcedNote.other": "{n} hiervan zijn op Nostr aangekondigd en niets heeft sindsdien bevestigd dat ze draaien. Ze staan er als aangekondigd, niet als online of offline, want geen van beide zou kloppen.",
|
||||
"fedimints.noResults": "Geen enkele federatie komt overeen. Probeer een naam, of plak een federatie-id.",
|
||||
"fedimints.emptyIndex": "Er is geen enkele federatie aangekondigd op de relays die deze site leest. De lijst vult zichzelf zodra er aankondigingen binnenkomen.",
|
||||
"fedimint.crumb": "Fedimints",
|
||||
"fedimint.breadcrumb": "Kruimelpad",
|
||||
"fedimint.unnamed": "Naamloze federatie",
|
||||
"fedimint.idLabel": "Federatie",
|
||||
"fedimint.kind.federationId": "federatie-id",
|
||||
"fedimint.moduleCount.one": "{n} module",
|
||||
"fedimint.moduleCount.other": "{n} modules",
|
||||
"fedimint.copyInvite": "Invite code kopiëren",
|
||||
"fedimint.confirmed.label": "Bevestigd",
|
||||
"fedimint.confirmed.never": "Nooit",
|
||||
"fedimint.confirmed.by": "door {source}",
|
||||
"fedimint.confirmed.unattributed": "laatste controle",
|
||||
"fedimint.announcedOn": "aangekondigd {when}",
|
||||
"fedimint.announcedUnknown": "geen datum van aankondiging",
|
||||
"fedimint.modules.title": "Modules",
|
||||
"fedimint.modules.none": "De aankondiging van deze federatie noemt geen modules.",
|
||||
"fedimint.modules.note": "Modules zijn de onderdelen die een federatie draait. De aankondiging zegt welke er zijn, en niets over of een ervan stortingen aanneemt.",
|
||||
"fedimint.modules.notListed": "niet genoemd",
|
||||
"fedimint.invite.title": "Invite code",
|
||||
"fedimint.invite.count.one": "{n} code",
|
||||
"fedimint.invite.count.other": "{n} codes",
|
||||
"fedimint.invite.note": "Plak dit in een Fedimint-wallet om mee te doen. Hij bevat het federatie-id en de adressen van de guardians, en hij is bedoeld om te delen.",
|
||||
"fedimint.invite.kind": "invite code",
|
||||
"fedimint.invite.qr": "QR",
|
||||
"fedimint.invite.qrCaption": "Scan om mee te doen met {name}",
|
||||
"fedimint.details.federationId": "Federatie-id",
|
||||
"fedimint.details.network": "Netwerk",
|
||||
"fedimint.details.announcedBy": "Aangekondigd door",
|
||||
"fedimint.details.announced": "Aangekondigd",
|
||||
"fedimint.more.title": "Meer federaties",
|
||||
"fedimint.more.all": "Alle federaties bekijken",
|
||||
"fedimint.disclaimer": "Meedoen met een federatie betekent dat je jouw geld toevertrouwt aan haar guardians. Beoordelingen zijn meningen uit de community, en bevestigd draaiend betekent bereikbaar, niet betrouwbaar.",
|
||||
"fedimint.module.lightning": "Lightning",
|
||||
"fedimint.module.mint": "Ecash mint",
|
||||
"fedimint.module.wallet": "On-chain wallet",
|
||||
"fedimint.module.meta": "Metadata",
|
||||
"fedimint.module.stability_pool": "Stability pool",
|
||||
"fedimint.module.multi_sig_stability_pool": "Stability pool (multisig)",
|
||||
"fedimint.module.fedi-social": "Social recovery",
|
||||
"fedimint.network.mainnet": "Bitcoin mainnet",
|
||||
"fedimint.network.signet": "Signet",
|
||||
"fedimint.network.testnet": "Testnet",
|
||||
"fedimint.network.testnet4": "Testnet4",
|
||||
"fedimint.network.regtest": "Regtest",
|
||||
"feed.ecosystem.group": "Ecosysteem",
|
||||
"feed.ecosystem.all": "Alles",
|
||||
"feed.ecosystem.cashu": "Cashu",
|
||||
"feed.ecosystem.fedimint": "Fedimint",
|
||||
"feed.filterBySubject": "Filter op mint of federatie",
|
||||
"feed.anySubject": "Elke mint of federatie",
|
||||
"feed.clearSubject": "Het naamfilter wissen",
|
||||
"home.fedimint.title": "Fedimint-federaties",
|
||||
"home.fedimint.body": "{total} aangekondigd op Nostr, {online} bevestigd draaiend, {announced} nooit bevestigd.",
|
||||
"home.fedimint.link": "Federaties bekijken",
|
||||
"reviews.validate.subject": "Er is niets om deze review aan te koppelen.",
|
||||
"mint.warnings.fedimintOffline.lead": "Gemeld als offline.",
|
||||
"mint.warnings.fedimintOffline.body.since": "De laatste controle die deze federatie bereikte was op {date} ({days} geleden). Haar guardians reageren sindsdien niet meer. Beschouw geld dat je er hebt staan als verloren, en doe er niet met nieuw geld mee.",
|
||||
"mint.warnings.fedimintOffline.body.never": "Geen enkele controle heeft deze federatie ooit bereikt, en de laatste mislukte. Beschouw geld dat je er hebt staan als verloren, en doe er niet met nieuw geld mee.",
|
||||
"mint.warnings.fedimintOffline.meta.since": "Gemeld als offline sinds {date}",
|
||||
"mint.warnings.fedimintOffline.meta.never": "Gemeld als offline, nooit bereikt",
|
||||
"mint.warnings.neverConfirmed.lead": "Aangekondigd, nooit bevestigd.",
|
||||
"mint.warnings.neverConfirmed.body.reviewed": "Deze federatie is op {date} op Nostr aangekondigd, {days} geleden, en niets heeft sindsdien bevestigd dat hij draait. De reviews hieronder zijn het enige teken van leven op deze pagina; al het andere komt uit de aankondiging.",
|
||||
"mint.warnings.neverConfirmed.body.quiet": "Deze federatie is op {date} op Nostr aangekondigd, {days} geleden, niets heeft sindsdien bevestigd dat hij draait, en niemand heeft hem recent gereviewd. Alles hieronder komt uit de aankondiging.",
|
||||
"mint.warnings.neverConfirmed.meta": "Aangekondigd op {date}, nooit bevestigd",
|
||||
"footer.allFedimints": "Alle federaties",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Hoe gingen minten, melten en de support? Wees concreet.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Hoe gingen meedoen, Lightning-betalingen en de support? Wees concreet.",
|
||||
"fedimint.details.title": "Gegevens van de federatie"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import '../styles/global.css';
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
current?: 'mints' | 'reviews' | 'wallets' | 'about';
|
||||
current?: 'mints' | 'fedimints' | 'reviews' | 'wallets' | 'about';
|
||||
mintCount?: number;
|
||||
ogType?: string;
|
||||
/**
|
||||
@@ -57,14 +57,20 @@ interface Props {
|
||||
schema?: SchemaNode[];
|
||||
/**
|
||||
* Social card. A site-relative path, resolved to the absolute URL `og:image` requires.
|
||||
* Defaults to the shared 1200x630 card.
|
||||
* Defaults to the shared 1200x630 card that `pnpm og` generates.
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* Alt text for the card. Mint pages pass their own ("{name}: 4.9 stars from 54
|
||||
* reviews…"); everything else gets the sitewide line, matching the default card.
|
||||
*/
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title, description, current, mintCount, ogType = 'website',
|
||||
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
||||
imageAlt,
|
||||
} = Astro.props;
|
||||
|
||||
/*
|
||||
@@ -217,7 +223,7 @@ const noscriptMotion =
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
|
||||
<meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} />
|
||||
<meta property="og:image:alt" content={t('seo.ogImageAlt')} />
|
||||
<meta property="og:image:alt" content={imageAlt ?? t('seo.ogImageAlt')} />
|
||||
|
||||
{/*
|
||||
`summary_large_image`, now that there is an image worth the space. With no
|
||||
@@ -227,7 +233,7 @@ const noscriptMotion =
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="twitter:image:alt" content={t('seo.ogImageAlt')} />
|
||||
<meta name="twitter:image:alt" content={imageAlt ?? t('seo.ogImageAlt')} />
|
||||
|
||||
{/*
|
||||
Two faces, and only two: Inter 400 is every paragraph on the site and Space
|
||||
|
||||
+13
-2
@@ -1,4 +1,4 @@
|
||||
import type { Health, MintDetail, MintListItem, Stats } from '@cashumints/shared';
|
||||
import type { FedimintDetail, Health, MintDetail, MintListItem, Stats } from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* Build-time API base. Islands use PUBLIC_API_BASE (see api-client.ts) so the browser
|
||||
@@ -15,10 +15,21 @@ async function get<T>(path: string): Promise<T> {
|
||||
/**
|
||||
* Build-time fetches. A failure here fails the build on purpose: shipping a site with
|
||||
* an empty mint list would be worse than not shipping.
|
||||
*
|
||||
* `fetchMints` asks for `type=cashu` rather than for everything, which is what keeps
|
||||
* /mints, the mint pages, the home page grid and the sitemap's mint entries meaning
|
||||
* exactly what they meant before federations were indexed. Federations are their own
|
||||
* call, their own pages and their own count; nothing has to filter a mixed list and
|
||||
* nothing silently grew.
|
||||
*/
|
||||
export const fetchMints = (): Promise<MintListItem[]> => get<MintListItem[]>('/api/mints');
|
||||
export const fetchMints = (): Promise<MintListItem[]> => get<MintListItem[]>('/api/mints?type=cashu');
|
||||
export const fetchFedimints = (): Promise<MintListItem[]> =>
|
||||
get<MintListItem[]>('/api/mints?type=fedimint');
|
||||
export const fetchMint = (host: string): Promise<MintDetail> =>
|
||||
get<MintDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
/** The same endpoint, typed for a federation's flattened fields. */
|
||||
export const fetchFedimint = (host: string): Promise<FedimintDetail> =>
|
||||
get<FedimintDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
export const fetchStats = (): Promise<Stats> => get<Stats>('/api/stats');
|
||||
|
||||
/** Health is allowed to be down at build time: the ticker just renders without it. */
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The federation page's Modules panel, as HTML strings.
|
||||
*
|
||||
* The Cashu counterpart is `nutRowsHtml` in mint-state.ts, and this is deliberately its
|
||||
* twin: same three-column row, same check and same grey "not published", so a reader
|
||||
* who has learned to scan one panel can scan the other without relearning it. The
|
||||
* classes are the same too, which is why there is no second stylesheet here.
|
||||
*
|
||||
* Built as strings rather than as markup in the page for the same reason that file
|
||||
* gives: two things render this panel — the page at build time from the last
|
||||
* announcement, and the live island from a fresh read of the API — and two
|
||||
* implementations would drift.
|
||||
*
|
||||
* What is deliberately *not* twinned: there is no `limitsHtml` here and no disabled
|
||||
* state. A federation's `modules` tag says which modules it runs, and nothing in the
|
||||
* announcement says whether any of them is switched off, accepting deposits, or
|
||||
* paying out. A red "disabled" row would be an invention, so a module is either listed
|
||||
* or it is not.
|
||||
*/
|
||||
import {
|
||||
HIGHLIGHT_MODULES, MODULE_ALIASES, MODULE_NAMES_EN, highlightModuleFor,
|
||||
type HighlightModule,
|
||||
} from '@cashumints/shared';
|
||||
import type { Formatters } from '../i18n/format';
|
||||
|
||||
const CHECK_ICON =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's plain-language name in this locale.
|
||||
*
|
||||
* Same rule as `localNutName`: the catalog first, `shared`'s English table second, and
|
||||
* the module's own short name last. A federation running something this build has never
|
||||
* heard of renders as `stability_pool` rather than as a guess, which is a true label.
|
||||
*/
|
||||
export function localModuleName(module: string, f: Formatters): string {
|
||||
const key = `fedimint.module.${module}`;
|
||||
if (f.t.has(key)) return f.t(key);
|
||||
return MODULE_NAMES_EN[module] ?? module;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three named rows: Lightning, ecash mint, on-chain wallet.
|
||||
*
|
||||
* The short name in the mono column is the alias this federation actually runs, so a
|
||||
* federation on `lnv2` says `lnv2` rather than being relabelled `ln`. A row nothing
|
||||
* satisfies falls back to the canonical spelling, since there is no real value to show.
|
||||
*/
|
||||
export function moduleRowsHtml(modules: readonly string[], f: Formatters): string {
|
||||
const present = modules.map((module) => module.toLowerCase());
|
||||
|
||||
return HIGHLIGHT_MODULES.map((key: HighlightModule) => {
|
||||
const aliases = MODULE_ALIASES[key] ?? [];
|
||||
const running = present.find((module) => aliases.includes(module));
|
||||
|
||||
const state = running
|
||||
? `<span class="nut-state ok">${CHECK_ICON}</span>`
|
||||
: `<span class="nut-state none">${escape(f.t('fedimint.modules.notListed'))}</span>`;
|
||||
|
||||
return (
|
||||
`<div class="nut-row${running ? '' : ' off'}" data-module-row="${escape(key)}">` +
|
||||
`<span class="nut-num">${escape(running ?? aliases[0] ?? key)}</span>` +
|
||||
`<span class="nut-name">${escape(localModuleName(key, f))}</span>` +
|
||||
`${state}</div>`
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** Modules with no named row of their own, for the chip list under the three. */
|
||||
export function otherModules(modules: readonly string[]): string[] {
|
||||
return modules.filter((module) => highlightModuleFor(module) === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A network's name in this locale, or the value the federation published.
|
||||
*
|
||||
* Publishing `n: "liquidtestnet"` is not something this build has a word for, and
|
||||
* showing the raw value says exactly as much as it should.
|
||||
*/
|
||||
export function localNetworkName(network: string, f: Formatters): string {
|
||||
const key = `fedimint.network.${network}`;
|
||||
return f.t.has(key) ? f.t(key) : network;
|
||||
}
|
||||
+31
-41
@@ -1,39 +1,35 @@
|
||||
/**
|
||||
* Browser-side global review feed: every mint's reviews in one list.
|
||||
*
|
||||
* The mint page asks the relays about one mint (`loadReviews` in reviews-client.ts).
|
||||
* This asks for kind 38000 with no mint filter at all and resolves each event back to
|
||||
* a mint the site knows about, which is the only way to build a feed: relays cannot
|
||||
* be asked for "reviews of any of these 58 URLs" without a filter per spelling per
|
||||
* mint, and that is hundreds of filters.
|
||||
* A mint or federation page asks the relays about one subject (`loadReviews` in
|
||||
* reviews-client.ts). This asks for kind 38000 with no subject filter at all and
|
||||
* resolves each event afterwards, which is the only way to build a feed: relays cannot
|
||||
* be asked for "reviews of any of these seventy things" without a filter per spelling
|
||||
* per subject, and that is hundreds of filters.
|
||||
*
|
||||
* Events that resolve to no known mint are dropped rather than shown. A review of a
|
||||
* mint this site has never indexed has nowhere to link to, and a card that says
|
||||
* nothing about which mint it is about is not worth a row.
|
||||
* Both ecosystems are in one list, newest first, and the page filters between them.
|
||||
* Resolution lives in `feed-resolve.ts` so the build-time prerender and this agree.
|
||||
*/
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import {
|
||||
KIND_REVIEW,
|
||||
cleanReviewContent,
|
||||
isCashuMintReview,
|
||||
mintUrlsFromEvent,
|
||||
normalizeMintUrl,
|
||||
parseRating,
|
||||
} from '@cashumints/shared';
|
||||
import { KIND_REVIEW, cleanReviewContent, parseRating } from '@cashumints/shared';
|
||||
import { buildFeedIndex, resolveFeedTarget, type FeedTarget } from './feed-resolve';
|
||||
import { RELAYS, lastPublish, safeNpub, type LoadedReview } from './reviews-client';
|
||||
|
||||
/** The mint list the page was built with: enough to resolve and link a review. */
|
||||
export interface FeedMint {
|
||||
url: string;
|
||||
host: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
/**
|
||||
* The listing the page was built with: enough to resolve and link a review.
|
||||
*
|
||||
* This is `FeedTarget` under its old name, kept because the page's JSON payload and its
|
||||
* island both spell it. Mints and federations are the same shape here on purpose — the
|
||||
* feed mixes them, and every difference between them is one field.
|
||||
*/
|
||||
export type FeedMint = FeedTarget;
|
||||
|
||||
export interface FeedReview extends LoadedReview {
|
||||
mint_host: string;
|
||||
mint_name: string;
|
||||
mint_domain: string;
|
||||
/** `cashu` or `fedimint`: picks the card's link, and drives the ecosystem filter. */
|
||||
mint_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +77,7 @@ function usable(entry: { at: number; feed: FeedReview[] }): boolean {
|
||||
export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
if (cache && usable(cache)) return cache.feed;
|
||||
|
||||
const byUrl = new Map(mints.map((mint) => [mint.url, mint]));
|
||||
const index = buildFeedIndex(mints);
|
||||
|
||||
const events = await getPool().querySync(
|
||||
RELAYS,
|
||||
@@ -89,24 +85,17 @@ export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
{ maxWait: RELAY_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Kind 38000 is addressable: a later event from the same author about the same mint
|
||||
// replaces the earlier one. Keyed on author plus mint, so one person reviewing three
|
||||
// mints keeps three reviews.
|
||||
// Kind 38000 is addressable: a later event from the same author about the same
|
||||
// subject replaces the earlier one. Keyed on author plus subject, so one person
|
||||
// reviewing three mints and a federation keeps four reviews.
|
||||
const newest = new Map<string, FeedReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
const target = resolveFeedTarget(event, index);
|
||||
if (!target) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const mint = byUrl.get(normalized.url);
|
||||
if (!mint) continue;
|
||||
|
||||
const key = `${event.pubkey}:${mint.host}`;
|
||||
const key = `${event.pubkey}:${target.host}`;
|
||||
const existing = newest.get(key);
|
||||
if (existing && existing.created_at >= event.created_at) continue;
|
||||
|
||||
@@ -117,10 +106,11 @@ export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: mint.url,
|
||||
mint_host: mint.host,
|
||||
mint_name: mint.name,
|
||||
mint_domain: mint.domain,
|
||||
mint_url: target.url,
|
||||
mint_host: target.host,
|
||||
mint_name: target.name,
|
||||
mint_domain: target.domain,
|
||||
mint_type: target.type,
|
||||
profile: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Which listing a review event is about, for the two feeds that read kind 38000 with no
|
||||
* subject filter at all: the /reviews island and the build-time prerender behind it.
|
||||
*
|
||||
* A per-subject read asks the relays a filter per mint or per federation. A feed cannot:
|
||||
* "reviews of any of these seventy things" is hundreds of filters, so it asks for recent
|
||||
* 38000 wholesale and works out afterwards what each event points at. That resolution is
|
||||
* this file, shared by the browser and the build so a card cannot resolve one way at
|
||||
* build time and another way a second later when the island redraws it.
|
||||
*
|
||||
* Events that resolve to nothing are dropped rather than shown. A review of a mint or a
|
||||
* federation this site has never indexed has nowhere to link to, and a card that cannot
|
||||
* say what it is about is not worth a row.
|
||||
*
|
||||
* No `t()` here: `check-i18n.mjs` treats every `.ts` under `lib/` as island code, and
|
||||
* there is nothing to translate in a lookup anyway.
|
||||
*/
|
||||
import {
|
||||
federationIdFromKey, federationIdFromSlug, mintUrlsFromEvent, normalizeMintUrl,
|
||||
reviewEcosystem, reviewTargetId, type MintListItem, type NostrEventLike,
|
||||
} from '@cashumints/shared';
|
||||
import { displayDomain } from './format';
|
||||
|
||||
/** One indexed listing, with enough to resolve a review to it and then link to it. */
|
||||
export interface FeedTarget {
|
||||
/** Row key: a mint URL, or `fedimint:<id>`. */
|
||||
url: string;
|
||||
/** Routing slug. */
|
||||
host: string;
|
||||
name: string;
|
||||
/** The label a card prints: a domain, or a shortened federation id. */
|
||||
domain: string;
|
||||
/** `cashu` or `fedimint`. Picks both the resolver and the link's route. */
|
||||
type: string;
|
||||
/**
|
||||
* The `d` identifier reviews of this thing carry, when that is how it is found.
|
||||
*
|
||||
* A federation id. null for a mint, which is resolved through its `u` tag instead —
|
||||
* that is how every legacy review points at one, and changing it would strand them.
|
||||
*/
|
||||
ref: string | null;
|
||||
}
|
||||
|
||||
/** Both lookups a feed needs, built once per read rather than per event. */
|
||||
export interface FeedIndex {
|
||||
byUrl: Map<string, FeedTarget>;
|
||||
byRef: Map<string, FeedTarget>;
|
||||
}
|
||||
|
||||
export function buildFeedIndex(targets: readonly FeedTarget[]): FeedIndex {
|
||||
const byUrl = new Map<string, FeedTarget>();
|
||||
const byRef = new Map<string, FeedTarget>();
|
||||
for (const target of targets) {
|
||||
byUrl.set(target.url, target);
|
||||
if (target.ref) byRef.set(target.ref.toLowerCase(), target);
|
||||
}
|
||||
return { byUrl, byRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* The listing a review points at, or null.
|
||||
*
|
||||
* The `k` tag picks the resolver, and it has to: a federation id and a mint's pubkey are
|
||||
* both 64 hex characters, so a `d`-first resolver with no ecosystem check could file a
|
||||
* federation review against a mint. An event with no `k` is Cashu — every one of those
|
||||
* predates this site listing anything else — and one naming a kind this build has no
|
||||
* ecosystem for belongs to neither and is dropped.
|
||||
*/
|
||||
export function resolveFeedTarget(event: NostrEventLike, index: FeedIndex): FeedTarget | null {
|
||||
const ecosystem = reviewEcosystem(event);
|
||||
|
||||
if (ecosystem === 'fedimint') {
|
||||
const d = reviewTargetId(event);
|
||||
return d ? index.byRef.get(d.toLowerCase()) ?? null : null;
|
||||
}
|
||||
|
||||
if (ecosystem !== 'cashu') return null;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) return null;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) return null;
|
||||
return index.byUrl.get(normalized.url) ?? null;
|
||||
}
|
||||
|
||||
/** The page a card links to. One route per ecosystem, decided in one place. */
|
||||
export function targetPath(target: Pick<FeedTarget, 'type' | 'host'>): string {
|
||||
return `/${target.type === 'fedimint' ? 'fedimint' : 'mint'}/${target.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the two list payloads into what a feed resolves against.
|
||||
*
|
||||
* A federation's `ref` is not fetched from anywhere: its row key already *is*
|
||||
* `fedimint:<federation id>`, so the full 64-character id a review's `d` tag carries is
|
||||
* sitting in the list payload. That is worth knowing, because the routing slug holds
|
||||
* only the first sixteen and matching on a prefix would have been the obvious wrong
|
||||
* answer here.
|
||||
*
|
||||
* Order is preserved and mints come first, which is the order the caller passes and the
|
||||
* order /reviews shows before its own sort.
|
||||
*/
|
||||
export function feedTargets(...lists: Array<readonly MintListItem[]>): FeedTarget[] {
|
||||
return lists.flat().map((item) => {
|
||||
const federationId = federationIdFromKey(item.url);
|
||||
const domain = federationId ? federationIdFromSlug(item.host) : displayDomain(item.url);
|
||||
return {
|
||||
url: item.url,
|
||||
host: item.host,
|
||||
name: item.name ?? domain.split('/')[0] ?? domain,
|
||||
domain,
|
||||
type: item.type,
|
||||
ref: federationId,
|
||||
};
|
||||
});
|
||||
}
|
||||
+27
-31
@@ -15,14 +15,12 @@ import {
|
||||
KIND_REVIEW,
|
||||
PROFILE_RELAYS,
|
||||
cleanReviewContent,
|
||||
isCashuMintReview,
|
||||
normalizeMintUrl,
|
||||
mintUrlsFromEvent,
|
||||
parseProfileContent,
|
||||
parseRating,
|
||||
WRITTEN_MIN_CHARS,
|
||||
type Profile,
|
||||
} from '@cashumints/shared';
|
||||
import { buildFeedIndex, resolveFeedTarget, type FeedTarget } from './feed-resolve';
|
||||
|
||||
const list = (value: string | undefined): string[] | null => {
|
||||
const parsed = value?.split(',').map((relay) => relay.trim()).filter(Boolean);
|
||||
@@ -47,6 +45,8 @@ export interface BuildReview {
|
||||
content: string;
|
||||
mint_url: string;
|
||||
mint_host: string;
|
||||
/** `cashu` or `fedimint`: which route the card links to, and which filter shows it. */
|
||||
mint_type: string;
|
||||
profile: Profile | null;
|
||||
}
|
||||
|
||||
@@ -186,15 +186,19 @@ function readReviewEvents(): Promise<NostrEvent[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest review events that are worth showing, resolved against the mints the API
|
||||
* knows about. `hostByUrl` maps normalized mint URL to routing slug.
|
||||
* Newest review events that are worth showing, resolved against everything the API
|
||||
* knows about — mints and federations both, since the strip's heading is "latest
|
||||
* reviews" and not "latest mint reviews". In practice federations rarely reach it: the
|
||||
* junk filter above wants a written body, and almost every 38173 review on the network
|
||||
* is a bare `[5/5]`.
|
||||
*/
|
||||
export async function fetchLatestReviews(
|
||||
hostByUrl: Map<string, string>,
|
||||
targets: readonly FeedTarget[],
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
const index = buildFeedIndex(targets);
|
||||
|
||||
const candidates: BuildReview[] = [];
|
||||
const seenAuthors = new Set<string>();
|
||||
@@ -202,15 +206,9 @@ export async function fetchLatestReviews(
|
||||
for (const event of [...events].sort((a, b) => b.created_at - a.created_at)) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (!qualifies(content)) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
const target = resolveFeedTarget(event, index);
|
||||
if (!target) continue;
|
||||
|
||||
// One card per author, so the strip is not three posts from the same npub.
|
||||
if (seenAuthors.has(event.pubkey)) continue;
|
||||
@@ -223,8 +221,9 @@ export async function fetchLatestReviews(
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: normalized.url,
|
||||
mint_host: host,
|
||||
mint_url: target.url,
|
||||
mint_host: target.host,
|
||||
mint_type: target.type,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
@@ -261,7 +260,7 @@ export async function fetchLatestReviews(
|
||||
}
|
||||
|
||||
/**
|
||||
* The global feed: newest written reviews across every mint.
|
||||
* The global feed: newest written reviews across every mint and federation.
|
||||
*
|
||||
* Same source and same parsing as the home page strip, three differences:
|
||||
* every author may appear more than once (once per mint they reviewed), the junk
|
||||
@@ -275,33 +274,29 @@ export async function fetchLatestReviews(
|
||||
* visitor, only for a crawler.
|
||||
*/
|
||||
export async function fetchReviewFeed(
|
||||
hostByUrl: Map<string, string>,
|
||||
targets: readonly FeedTarget[],
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
const index = buildFeedIndex(targets);
|
||||
|
||||
/*
|
||||
* Kind 38000 is addressable: a later event from the same author about the same
|
||||
* mint replaces the earlier one, exactly as the mint page treats it. Keyed on
|
||||
* author plus mint, so one person reviewing three mints keeps three reviews.
|
||||
* subject replaces the earlier one, exactly as a subject page treats it. Keyed on
|
||||
* author plus subject, so one person reviewing three mints and a federation keeps
|
||||
* four reviews.
|
||||
*/
|
||||
const newest = new Map<string, BuildReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (content.length < WRITTEN_MIN_CHARS) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
const target = resolveFeedTarget(event, index);
|
||||
if (!target) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
|
||||
const key = `${event.pubkey}:${host}`;
|
||||
const key = `${event.pubkey}:${target.host}`;
|
||||
const existing = newest.get(key);
|
||||
if (existing && existing.created_at >= event.created_at) continue;
|
||||
|
||||
@@ -312,8 +307,9 @@ export async function fetchReviewFeed(
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: normalized.url,
|
||||
mint_host: host,
|
||||
mint_url: target.url,
|
||||
mint_host: target.host,
|
||||
mint_type: target.type,
|
||||
profile: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,6 +334,10 @@ async function reachBunker(connect: ConnectInfo): Promise<BunkerSigner> {
|
||||
* never gets its approval and the request times out with a message.
|
||||
*/
|
||||
function openAuthWindow(url: string): void {
|
||||
// The URL comes from the remote signer, which is a stranger until this pairing
|
||||
// succeeds. Anything but http(s) — javascript:, data:, a custom scheme — has no
|
||||
// business being opened from here, noopener or not.
|
||||
if (!/^https?:\/\//i.test(url)) return;
|
||||
status('waiting', signerText('signer.status.approveWindow'));
|
||||
window.open(url, 'cashumints-signer-auth', 'width=420,height=640,noopener,noreferrer');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The social-image manifest, as the pages read it.
|
||||
*
|
||||
* `pnpm og` (the first half of `pnpm build`) renders one PNG per mint and federation
|
||||
* into `public/og/` and writes the slug-to-filename map to
|
||||
* `src/generated/og-manifest.json`. Filenames carry a content hash
|
||||
* (`mint.example.com.a1b2c3d4e5.png`), so a mint whose card changed gets a new URL —
|
||||
* which is the part that actually busts scraper caches, since the big link-preview
|
||||
* crawlers cache an og:image URL itself and ignore its response headers.
|
||||
*
|
||||
* Read with `fs` rather than imported, because the file is generated: an import would
|
||||
* make `astro dev` unrunnable before the first `pnpm og`. Missing manifest (or a slug
|
||||
* that failed to render) falls back to the shared default card, so a page never
|
||||
* points at an image that is not on disk.
|
||||
*
|
||||
* Build-time only. Never import this from an island: it reads the filesystem.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface OgManifest {
|
||||
default?: { file: string };
|
||||
images?: Record<string, { file: string }>;
|
||||
}
|
||||
|
||||
// Resolved from the working directory (astro runs with cwd = web/) rather than from
|
||||
// `import.meta.url`, which points into the bundled output once the build compiles this.
|
||||
const manifestFile = path.resolve(process.cwd(), 'src/generated/og-manifest.json');
|
||||
|
||||
let manifest: OgManifest | null = null;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestFile, 'utf8')) as OgManifest;
|
||||
} catch {
|
||||
manifest = null;
|
||||
}
|
||||
|
||||
/** Site-relative path of one mint's card, or null when no card was generated for it. */
|
||||
export function ogImageFor(slug: string): string | null {
|
||||
const file = manifest?.images?.[slug]?.file;
|
||||
return file ? `/og/${file}` : null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* What a review is *about*, in the one shape both ecosystems fit.
|
||||
*
|
||||
* The reviews panel used to take a mint: it read `mint.url` and `mint.pubkey`, built a
|
||||
* `#u` filter out of URL spellings, and published a `d` tag holding a mint's own
|
||||
* pubkey. None of that has a Fedimint meaning — a federation has no URL, no `/v1/info`
|
||||
* and no pubkey of its own — but the *shape* is identical in both cases, because NIP-87
|
||||
* says so: a review points at an announcement of kind `k` by its `d` identifier, and
|
||||
* optionally repeats the thing's address in `u`.
|
||||
*
|
||||
* So the panel takes one of these instead, and asks nothing about which ecosystem it
|
||||
* came from. A third ecosystem needs a builder here and nothing at all in the panel,
|
||||
* the card, the publisher or the dialog.
|
||||
*
|
||||
* It crosses from the page into the island as a JSON blob in a data attribute, so it
|
||||
* has to stay plain data — no functions, no class instances.
|
||||
*/
|
||||
import {
|
||||
KIND_FEDIMINT_ANNOUNCEMENT, KIND_MINT_ANNOUNCEMENT, PRIMARY_RELAY, mintUrlSpellings,
|
||||
type FedimintDetail, type MintDetail,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
export interface ReviewSubject {
|
||||
/** `cashu` or `fedimint`. Only used for labelling; no logic branches on it. */
|
||||
type: string;
|
||||
/** The announcement kind, written to the review's `k` tag: 38172 or 38173. */
|
||||
kind: number;
|
||||
/**
|
||||
* The `d` identifier of the announcement being reviewed, and the value the `#d` relay
|
||||
* filter asks for. A mint's own pubkey from `/v1/info`, or a federation id.
|
||||
*
|
||||
* null for a Cashu mint that has never answered a probe: there is no pubkey to point
|
||||
* at, so such a review is matched by `u` alone. That is the pre-existing behaviour
|
||||
* and it is why this is nullable at all — a federation always has one.
|
||||
*/
|
||||
d: string | null;
|
||||
/**
|
||||
* Every spelling of the thing's address that may appear in a `u` tag.
|
||||
*
|
||||
* Six URL spellings for a mint (the old site published all of them, see NOTES.md);
|
||||
* the invite codes for a federation.
|
||||
*/
|
||||
u: string[];
|
||||
/**
|
||||
* The third element of the `u` tag on a published review, which NIP-87 leaves as a
|
||||
* free-form marker. The old publisher wrote `cashu` and this keeps writing it.
|
||||
*/
|
||||
marker: string;
|
||||
/**
|
||||
* Who published the announcement, for the `a` tag's pubkey.
|
||||
*
|
||||
* A federation's announcement is published by a person, and `a` is an address into
|
||||
* that person's replaceable events, so this is genuinely required to build a correct
|
||||
* pointer. See `aTag` for why the Cashu side does not use it.
|
||||
*/
|
||||
announcer: string | null;
|
||||
/**
|
||||
* Cache and draft key. The row's primary key: a mint URL, or `fedimint:<id>`.
|
||||
* Never rendered.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* How many `u` values a relay filter is allowed to carry.
|
||||
*
|
||||
* A mint's six URL spellings are short. A federation's invite code is 150+ characters
|
||||
* and two of the five relays answer `ERROR: bad req: filter item too large` and
|
||||
* nothing else, so a federation asks by `d` only and this is 0 for one.
|
||||
*/
|
||||
filterByU: boolean;
|
||||
}
|
||||
|
||||
/** A Cashu mint, reviewed exactly as it has always been. */
|
||||
export function mintSubject(mint: Pick<MintDetail, 'url' | 'pubkey'>): ReviewSubject {
|
||||
return {
|
||||
type: 'cashu',
|
||||
kind: KIND_MINT_ANNOUNCEMENT,
|
||||
d: mint.pubkey ?? null,
|
||||
u: mintUrlSpellings(mint.url),
|
||||
marker: 'cashu',
|
||||
announcer: null,
|
||||
key: mint.url,
|
||||
filterByU: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** A Fedimint federation, identified by the id in its announcement's `d` tag. */
|
||||
export function fedimintSubject(
|
||||
federation: Pick<FedimintDetail, 'url' | 'federation_id' | 'invite_codes' | 'announcer_pubkey'>,
|
||||
): ReviewSubject {
|
||||
return {
|
||||
type: 'fedimint',
|
||||
kind: KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
d: federation.federation_id,
|
||||
u: federation.invite_codes ?? [],
|
||||
marker: 'fedimint',
|
||||
announcer: federation.announcer_pubkey ?? null,
|
||||
key: federation.url,
|
||||
filterByU: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a published review carries, per NIP-87.
|
||||
*
|
||||
* `k` names the kind being recommended, `d` repeats the announcement's identifier so
|
||||
* the review replaces the author's previous one about the same thing, `u` carries the
|
||||
* address, and `a` points at the announcement event itself with a relay hint.
|
||||
*
|
||||
* The `a` tag differs by ecosystem, and deliberately:
|
||||
*
|
||||
* - Cashu writes `38172:<mint pubkey>:<mint pubkey>`. That is not what an `a` tag
|
||||
* means — the middle field is the *author* of the addressed event, not the subject
|
||||
* — but it is byte for byte what this site has always published, other clients
|
||||
* read it, and changing it would orphan every review already on the network from
|
||||
* the ones written after. NOTES.md has the history.
|
||||
* - Fedimint writes `38173:<announcer pubkey>:<federation id>`, which is the correct
|
||||
* form, because there is no legacy to preserve and the announcer is known.
|
||||
*
|
||||
* A subject with neither `d` nor any `u` value cannot be pointed at, and the caller
|
||||
* refuses to publish rather than sign an event nothing can resolve.
|
||||
*/
|
||||
export function reviewTags(subject: ReviewSubject): string[][] {
|
||||
const tags: string[][] = [['k', String(subject.kind)]];
|
||||
|
||||
const address = subject.u[0];
|
||||
if (address) tags.push(['u', address, subject.marker]);
|
||||
|
||||
if (subject.d) {
|
||||
tags.push(['d', subject.d]);
|
||||
const author = subject.announcer ?? subject.d;
|
||||
tags.push(['a', `${subject.kind}:${author}:${subject.d}`, PRIMARY_RELAY]);
|
||||
} else if (address) {
|
||||
// No announcement identifier to point at. The address becomes the `d`, which is
|
||||
// what the old publisher did for a mint that had never answered a probe.
|
||||
tags.push(['d', address]);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Everything a published review needs to resolve to something. */
|
||||
export function isPublishable(subject: ReviewSubject): boolean {
|
||||
return Boolean(subject.d) || subject.u.length > 0;
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
* Browser-side Nostr: read review content from relays, resolve reviewer profiles,
|
||||
* and publish a new review signed by whichever signer the reader is using.
|
||||
*
|
||||
* Kinds, tags and relays are exactly what the old site used (see NOTES.md), so this
|
||||
* reads and writes the same events any other client sees.
|
||||
* Kinds, tags and relays for a Cashu mint are exactly what the old site used (see
|
||||
* NOTES.md), so this reads and writes the same events any other client sees. Which
|
||||
* kinds and tags those are is not decided here: the caller hands in a `ReviewSubject`
|
||||
* and this file publishes whatever that says, which is how one panel, one dialog and
|
||||
* one card serve both mints and federations.
|
||||
*/
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
@@ -11,15 +14,15 @@ import type { Event as NostrEvent } from 'nostr-tools';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
DEFAULT_RELAYS,
|
||||
KIND_MINT_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
PRIMARY_RELAY,
|
||||
cleanReviewContent,
|
||||
mintUrlSpellings,
|
||||
parseRating,
|
||||
tagValue,
|
||||
tagValues,
|
||||
type Profile,
|
||||
type Review,
|
||||
} from '@cashumints/shared';
|
||||
import { isPublishable, reviewTags, type ReviewSubject } from './review-subject';
|
||||
|
||||
/**
|
||||
* Relays reviews are read from and published to.
|
||||
@@ -52,7 +55,7 @@ function getPool(): SimplePool {
|
||||
/* ---------- the read cache ---------- */
|
||||
|
||||
/**
|
||||
* How long a mint's reviews stay good for this tab.
|
||||
* How long one subject's reviews stay good for this tab.
|
||||
*
|
||||
* The panel calls `loadReviews` on every arrival, and the router counts a back button
|
||||
* as an arrival, so leaving a mint page and returning to it used to mean another
|
||||
@@ -74,13 +77,13 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map<string, { at: number; reviews: LoadedReview[] }>();
|
||||
|
||||
/**
|
||||
* Forget what the relays said about a mint, so the next read asks them again.
|
||||
* Forget what the relays said about one subject, so the next read asks them again.
|
||||
*
|
||||
* Called after publishing: an author who has just written a review has to see it in
|
||||
* the list, and a cached answer from thirty seconds ago does not contain it.
|
||||
*/
|
||||
export function invalidateReviews(mintUrl: string): void {
|
||||
cache.delete(mintUrl);
|
||||
export function invalidateReviews(key: string): void {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
let publishedAt = 0;
|
||||
@@ -97,29 +100,41 @@ export function lastPublish(): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch reviews for one mint.
|
||||
* Fetch reviews about one subject: a mint, or a federation.
|
||||
*
|
||||
* Two filters, OR'd, matching the old site's read path:
|
||||
* - `#d` = the mint's own pubkey from /v1/info, with `#k` = 38172 (proper NIP-87)
|
||||
* - `#u` = the mint URL in every spelling the old site published
|
||||
* The URL filter is what finds legacy reviews, and dropping it would lose most of them.
|
||||
* - `#d` = the announcement's identifier, with `#k` = its kind (proper NIP-87). For a
|
||||
* mint that is the pubkey from /v1/info; for a federation, the federation id.
|
||||
* - `#u` = the address, in every spelling the old site published
|
||||
*
|
||||
* The URL filter is what finds legacy mint reviews, and dropping it would lose most of
|
||||
* them. A federation sets `filterByU: false` and is asked by `#d` alone — its `u` value
|
||||
* is a 150 character invite code, and relays reject a filter item that long outright,
|
||||
* so sending it costs round trips and returns nothing. See `review-subject.ts`.
|
||||
*/
|
||||
export async function loadReviews(mintUrl: string, mintPubkey: string | null): Promise<LoadedReview[]> {
|
||||
const hit = cache.get(mintUrl);
|
||||
/** Longest body a card will render. Anything real fits; a stunt event is cut. */
|
||||
const MAX_RENDERED_CHARS = 4000;
|
||||
|
||||
export async function loadReviews(subject: ReviewSubject): Promise<LoadedReview[]> {
|
||||
const hit = cache.get(subject.key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.reviews;
|
||||
|
||||
const filters: Record<string, unknown>[] = [];
|
||||
|
||||
if (mintPubkey) {
|
||||
if (subject.d) {
|
||||
filters.push({
|
||||
kinds: [KIND_REVIEW],
|
||||
'#d': [mintPubkey],
|
||||
'#k': [String(KIND_MINT_ANNOUNCEMENT)],
|
||||
'#d': [subject.d],
|
||||
'#k': [String(subject.kind)],
|
||||
limit: 500,
|
||||
});
|
||||
}
|
||||
|
||||
filters.push({ kinds: [KIND_REVIEW], '#u': mintUrlSpellings(mintUrl), limit: 500 });
|
||||
if (subject.filterByU && subject.u.length > 0) {
|
||||
filters.push({ kinds: [KIND_REVIEW], '#u': subject.u, limit: 500 });
|
||||
}
|
||||
|
||||
if (filters.length === 0) return [];
|
||||
|
||||
const events = await Promise.all(
|
||||
filters.map((filter) =>
|
||||
@@ -129,10 +144,30 @@ export async function loadReviews(mintUrl: string, mintPubkey: string | null): P
|
||||
),
|
||||
);
|
||||
|
||||
/*
|
||||
* The pool verifies signatures, but which events come back is the relay's word: a
|
||||
* misbehaving relay can answer a filter with events that do not match it, and
|
||||
* rendering those would file someone's review of one mint under another. So every
|
||||
* event is re-checked against the subject's own tags, mirroring the two filters.
|
||||
*/
|
||||
const uSpellings = new Set(subject.u);
|
||||
const aboutSubject = (event: NostrEvent): boolean => {
|
||||
if (event.kind !== KIND_REVIEW) return false;
|
||||
if (
|
||||
subject.d &&
|
||||
tagValue(event.tags, 'd') === subject.d &&
|
||||
tagValue(event.tags, 'k') === String(subject.kind)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return subject.filterByU && tagValues(event.tags, 'u').some((u) => uSpellings.has(u));
|
||||
};
|
||||
|
||||
// One review per author, newest wins: kind 38000 is addressable, so a later event
|
||||
// from the same npub replaces the earlier one.
|
||||
const byAuthor = new Map<string, NostrEvent>();
|
||||
for (const event of events.flat()) {
|
||||
if (!aboutSubject(event)) continue;
|
||||
const existing = byAuthor.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) byAuthor.set(event.pubkey, event);
|
||||
}
|
||||
@@ -145,15 +180,17 @@ export async function loadReviews(mintUrl: string, mintPubkey: string | null): P
|
||||
npub: safeNpub(event.pubkey),
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content: cleanReviewContent(event.content),
|
||||
mint_url: mintUrl,
|
||||
// Publishing caps a review at 2000 characters, but legacy and foreign clients
|
||||
// did not; an unbounded body would be an unbounded card.
|
||||
content: cleanReviewContent(event.content).slice(0, MAX_RENDERED_CHARS),
|
||||
mint_url: subject.key,
|
||||
profile: null,
|
||||
}));
|
||||
|
||||
// Nothing back is not the same as no reviews: the pool swallows per relay failures
|
||||
// and resolves empty, which is why the panel treats an empty answer as a relay
|
||||
// error. Caching that would turn its retry button into a no-op for five minutes.
|
||||
if (reviews.length > 0) cache.set(mintUrl, { at: Date.now(), reviews });
|
||||
if (reviews.length > 0) cache.set(subject.key, { at: Date.now(), reviews });
|
||||
|
||||
return reviews;
|
||||
}
|
||||
@@ -185,6 +222,7 @@ export function safeNpub(pubkey: string): string {
|
||||
import type { NostrSigner } from './nostr-signer';
|
||||
|
||||
export { getExtension, hasExtension, activeSigner } from './nostr-signer';
|
||||
export { mintSubject, fedimintSubject, type ReviewSubject } from './review-subject';
|
||||
export type { NostrSigner, SignerMethod } from './nostr-signer';
|
||||
|
||||
export interface PublishResult {
|
||||
@@ -197,8 +235,8 @@ export interface PublishResult {
|
||||
|
||||
export interface PublishOptions {
|
||||
signer: NostrSigner;
|
||||
mintUrl: string;
|
||||
mintPubkey: string | null;
|
||||
/** What is being reviewed. Decides `k`, `d`, `u` and `a` — see `review-subject.ts`. */
|
||||
subject: ReviewSubject;
|
||||
rating: number;
|
||||
content: string;
|
||||
/**
|
||||
@@ -219,10 +257,14 @@ const PUBLISH_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* Sign and broadcast a review.
|
||||
*
|
||||
* The event is built exactly as the old publisher built it (kind 38000, `[N/5]`
|
||||
* A Cashu review is built exactly as the old publisher built it (kind 38000, `[N/5]`
|
||||
* content prefix, k/u/d tags), plus the `rating` and `a` tags its dead code path
|
||||
* intended. Adding those is safe: readers that only understand the content prefix
|
||||
* still parse it, and readers that prefer the tag get a cleaner value.
|
||||
* intended. Adding those was safe and stays safe: readers that only understand the
|
||||
* content prefix still parse it, and readers that prefer the tag get a cleaner value.
|
||||
*
|
||||
* A Fedimint review is the same event with `k` = 38173, `d` = the federation id and
|
||||
* `u` = an invite code. The pointer tags come from the subject rather than from
|
||||
* anything here, so this function has no idea which ecosystem it is publishing about.
|
||||
*
|
||||
* One relay accepting is a success: the review is on the network and the others will
|
||||
* see it. How many took it is returned either way, because "published to 2 of 4" is
|
||||
@@ -233,7 +275,7 @@ const MIN_REVIEW_CHARS = 10;
|
||||
const MAX_REVIEW_CHARS = 2000;
|
||||
|
||||
export async function publishReview(options: PublishOptions): Promise<PublishResult> {
|
||||
const { signer, mintUrl, mintPubkey, rating, content, onSigned, onProgress } = options;
|
||||
const { signer, subject, rating, content, onSigned, onProgress } = options;
|
||||
|
||||
/*
|
||||
* These three are the last line of defence, not the reader's usual path: the dialog
|
||||
@@ -252,19 +294,16 @@ export async function publishReview(options: PublishOptions): Promise<PublishRes
|
||||
throw new Error(t('reviews.validate.long', { n: MAX_REVIEW_CHARS }));
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['k', String(KIND_MINT_ANNOUNCEMENT)],
|
||||
['u', mintUrl, 'cashu'],
|
||||
['rating', String(rating)],
|
||||
];
|
||||
if (mintPubkey) {
|
||||
tags.push(['d', mintPubkey]);
|
||||
tags.push(['a', `${KIND_MINT_ANNOUNCEMENT}:${mintPubkey}:${mintPubkey}`, PRIMARY_RELAY]);
|
||||
} else {
|
||||
// Without the mint's own pubkey there is no NIP-33 identifier to use, so fall back
|
||||
// to the mint URL. The `u` tag is what the reader matches on anyway.
|
||||
tags.push(['d', mintUrl]);
|
||||
}
|
||||
// A review nothing can resolve is worse than no review: it would be signed, accepted
|
||||
// by relays and invisible on every page including the one it was written on.
|
||||
if (!isPublishable(subject)) throw new Error(t('reviews.validate.subject'));
|
||||
|
||||
const tags = reviewTags(subject);
|
||||
// The `rating` tag goes immediately after `u`, which is where this site has always
|
||||
// written it. Nothing reads tag order, but an event that differs from its
|
||||
// predecessors only in field order is a needless difference to explain later.
|
||||
const afterU = tags.findIndex((tag) => tag[0] === 'u');
|
||||
tags.splice(afterU === -1 ? tags.length : afterU + 1, 0, ['rating', String(rating)]);
|
||||
|
||||
const signed = (await signer.signEvent({
|
||||
kind: KIND_REVIEW,
|
||||
@@ -307,7 +346,7 @@ export async function publishReview(options: PublishOptions): Promise<PublishRes
|
||||
|
||||
// The author's own review has to be there the next time either list is read, and a
|
||||
// cached answer from before they wrote it is not.
|
||||
invalidateReviews(mintUrl);
|
||||
invalidateReviews(subject.key);
|
||||
publishedAt = Date.now();
|
||||
|
||||
return { event: signed, accepted, total };
|
||||
|
||||
+34
-14
@@ -15,14 +15,15 @@
|
||||
* with the visible page is worse than none: it teaches a crawler that neither can be
|
||||
* trusted.
|
||||
*
|
||||
* Two absences are deliberate:
|
||||
* One absence is deliberate:
|
||||
*
|
||||
* - **No `SearchAction` on `WebSite`.** The search on /mints is an island filtering
|
||||
* markup already in the page. There is no `?q=` URL to hand a crawler, and declaring
|
||||
* one would promise a query endpoint that does not exist.
|
||||
* - **No `Review` nodes.** Review bodies are fetched from Nostr relays in the browser,
|
||||
* so they are not in the prerendered HTML. Marking up text a crawler cannot see in
|
||||
* the page is the same mismatch.
|
||||
*
|
||||
* The `SearchAction` on `WebSite` exists because /mints honours `?q=`: the search
|
||||
* island reads the parameter on load (it is how the home page's quick filters deep
|
||||
* link), so the URL the graph hands a crawler really does open a filtered list.
|
||||
*/
|
||||
|
||||
/** A node in the graph. Loose by design: schema.org shapes vary per type. */
|
||||
@@ -118,6 +119,19 @@ export function siteNodes(o: {
|
||||
url: `${o.origin}/`,
|
||||
inLanguage: o.locale,
|
||||
publisher: { '@id': orgId(o.origin) },
|
||||
/*
|
||||
* The English /mints URL for every locale: hreflang on the target page routes a
|
||||
* visitor on from there, and one canonical action beats three that differ only
|
||||
* by prefix.
|
||||
*/
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: `${o.origin}/mints?q={search_term_string}`,
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -195,16 +209,20 @@ export function itemListNode(o: {
|
||||
/**
|
||||
* A mint, as the thing its page is about.
|
||||
*
|
||||
* `Service` with an explicit `serviceType`, which is what a Cashu mint is: something a
|
||||
* third party runs and you can use. Not a `Product` (nothing is sold here), not an
|
||||
* `Organization` (the operator is usually anonymous and this site knows nothing about
|
||||
* them beyond a URL), not a `SoftwareApplication` (the software is named in `version`,
|
||||
* but this page is about one deployment, not about cdk-mintd in general).
|
||||
* `Service` is the honest base type: something a third party runs and you can use.
|
||||
* Not an `Organization` — the operator is usually anonymous, this site knows nothing
|
||||
* about them beyond a URL, and Google shows no review snippets for reviews an
|
||||
* Organization node carries about itself. Not a `SoftwareApplication` — the software
|
||||
* is named in `version`, but this page is about one deployment.
|
||||
*
|
||||
* `Service` is not on Google's review-snippet list, so this will not draw stars in a
|
||||
* search result. That is the accepted trade: `Product` would be eligible and would also
|
||||
* be a lie about what a custodial mint is, on a site whose whole premise is not
|
||||
* overclaiming.
|
||||
* `Service` alone is not on Google's review-snippet list, so a rated mint *also*
|
||||
* carries `Product` (a dual-typed node), which is the eligible type. The reviews
|
||||
* behind the `AggregateRating` are third-party Nostr events about someone else's
|
||||
* mint, not the site reviewing itself, so the self-serving-review policy is not the
|
||||
* problem here — but review-snippet eligibility remains entirely at Google's
|
||||
* discretion, which is why the `Product` half sits behind `SEO_PRODUCT_JSONLD`
|
||||
* (`site.ts`) and can be switched off without touching a template. With the flag off,
|
||||
* or with no ratings to carry, the node is exactly the plain `Service` it always was.
|
||||
*/
|
||||
export function mintServiceNode(o: {
|
||||
canonical: string;
|
||||
@@ -213,9 +231,11 @@ export function mintServiceNode(o: {
|
||||
mintUrl: string;
|
||||
icon?: string | null;
|
||||
rating: { value: number; ratingCount: number; reviewCount: number } | null;
|
||||
/** Add the review-snippet-eligible `Product` type. Only honoured when rated. */
|
||||
product?: boolean;
|
||||
}): SchemaNode {
|
||||
const node: SchemaNode = {
|
||||
'@type': 'Service',
|
||||
'@type': o.product && o.rating ? ['Product', 'Service'] : 'Service',
|
||||
'@id': `${o.canonical}#mint`,
|
||||
name: text(o.name, 120),
|
||||
description: text(o.description, 300),
|
||||
|
||||
@@ -90,3 +90,46 @@ export function clampWords(value: string, max: number): string {
|
||||
const lastSpace = cut.lastIndexOf(' ');
|
||||
return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* A mint's own description, made fit to open a meta description.
|
||||
*
|
||||
* It is operator-written text headed for a `content` attribute and a link preview, so
|
||||
* everything that does not belong in either comes out: HTML tags and control
|
||||
* characters are stripped, whitespace collapses, and only the first sentence
|
||||
* survives — a meta description quoting three sentences of marketing leaves no room
|
||||
* for the rating and status that make the snippet useful. (Astro does the actual
|
||||
* attribute escaping; this removes what escaping would merely neutralise.)
|
||||
*
|
||||
* Two judgement calls:
|
||||
*
|
||||
* - SHOUTING is normalised to sentence case. A description that is ≥80% capitals
|
||||
* reads as noise in a search result, and the mint's page still shows the original.
|
||||
* - The sentence split requires whitespace after the terminator, so "mint.example.com"
|
||||
* inside a sentence does not end it.
|
||||
*
|
||||
* Returns null when nothing usable is left, and the caller falls back to its own copy.
|
||||
*/
|
||||
export function cleanMintDescription(value: string | null | undefined, max = 70): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
let clean = value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (clean === '') return null;
|
||||
|
||||
const sentence = /^.*?[.!?](?=\s|$)/.exec(clean);
|
||||
clean = (sentence?.[0] ?? clean).trim();
|
||||
|
||||
const letters = clean.replace(/[^\p{L}]/gu, '');
|
||||
if (letters.length >= 8 && letters.replace(/\p{Ll}/gu, '').length / letters.length > 0.8) {
|
||||
clean = clean.toLowerCase();
|
||||
clean = clean[0]!.toUpperCase() + clean.slice(1);
|
||||
}
|
||||
|
||||
clean = clampWords(clean, max);
|
||||
if (!/[.!?]$/.test(clean)) clean += '.';
|
||||
return clean;
|
||||
}
|
||||
|
||||
+15
-2
@@ -17,11 +17,24 @@ export const GITHUB_URL = 'https://github.com/Azzamo-net/cashumints.space';
|
||||
export const AZZAMO_URL = 'https://azzamo.net';
|
||||
|
||||
/**
|
||||
* The default social card, served from `public/`.
|
||||
* The default social card: brand, tagline and the network's stats, rendered by
|
||||
* `pnpm og` into `public/og/` alongside the per-mint cards. Home, the list pages and
|
||||
* any mint whose own card failed to render all point here.
|
||||
*
|
||||
* Relative on purpose: `Base.astro` resolves it against `Astro.site`, because an
|
||||
* `og:image` that is not absolute is not a weaker signal, it is an ignored one.
|
||||
* Stable filename, unlike the hashed per-mint cards, so it is served with
|
||||
* `Cache-Control: max-age=3600` rather than immutable (see README).
|
||||
*/
|
||||
export const OG_IMAGE = '/og.png';
|
||||
export const OG_IMAGE = '/og/default.png';
|
||||
export const OG_IMAGE_WIDTH = 1200;
|
||||
export const OG_IMAGE_HEIGHT = 630;
|
||||
|
||||
/**
|
||||
* Whether a rated mint's JSON-LD also carries the review-snippet-eligible `Product`
|
||||
* type (see `mintServiceNode` in schema.ts for the reasoning). On by default; set
|
||||
* `SEO_PRODUCT_JSONLD=0` to fall back to the plain `Service` node on the next build.
|
||||
* An env flag rather than a source edit, so the call can be reversed without a code
|
||||
* change if Google's treatment of self-collected review markup shifts.
|
||||
*/
|
||||
export const PRODUCT_JSONLD = (process.env['SEO_PRODUCT_JSONLD'] ?? '1') !== '0';
|
||||
|
||||
@@ -82,6 +82,7 @@ export const FIXTURE_REVIEWS: LoadedReview[] = [
|
||||
* actually renders: name, URL, status, rating, review count, software and last seen.
|
||||
*/
|
||||
export const FIXTURE_MINT: MintDetail = {
|
||||
type: 'cashu',
|
||||
url: 'https://mint.example.com',
|
||||
host: 'mint.example.com',
|
||||
name: 'Example Mint',
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
---
|
||||
/**
|
||||
* One federation. The mint page's layout, with the panels a federation can honestly
|
||||
* fill and none of the ones it cannot.
|
||||
*
|
||||
* What is the same: the identity header, the warning banner slot, the verdict strip,
|
||||
* the reviews panel (identical component, identical cards, identical write dialog), the
|
||||
* related ring and the disclaimer.
|
||||
*
|
||||
* What is different, and why:
|
||||
*
|
||||
* - The NUTs panel becomes a Modules panel. Same rows, same check, but there is no
|
||||
* disabled state, because a `modules` tag says what a federation runs and nothing
|
||||
* about whether any of it is switched on.
|
||||
* - The verdict strip drops the software and limits cells. A federation reports no
|
||||
* version and publishes no min/max, and a cell reading "unknown" twice is furniture.
|
||||
* Network and "announced / last confirmed" take their place, which are two things
|
||||
* this page does know.
|
||||
* - The invite codes block replaces the copy-URL header action, because a federation
|
||||
* has no URL to copy and the invite code is what a wallet actually needs.
|
||||
*/
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import Reviews from '../../../components/Reviews.astro';
|
||||
import MintLive from '../../../components/MintLive.astro';
|
||||
import Sparkline from '../../../components/Sparkline.astro';
|
||||
import CopyableId from '../../../components/CopyableId.astro';
|
||||
import InviteCodes from '../../../components/InviteCodes.astro';
|
||||
import MintCard from '../../../components/MintCard.astro';
|
||||
import {
|
||||
getMintWarnings, isTestNetwork, type FedimintDetail, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { fetchFedimint, fetchFedimints, iconUrl } from '../../../lib/api';
|
||||
import { ogImageFor } from '../../../lib/og';
|
||||
import { fedimintSubject } from '../../../lib/review-subject';
|
||||
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
|
||||
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { PRODUCT_JSONLD } from '../../../lib/site';
|
||||
import { bannerHtml } from '../../../lib/mint-state';
|
||||
import { localModuleName, localNetworkName, moduleRowsHtml, otherModules } from '../../../lib/fedimint-ui';
|
||||
import { iconGradient, initials, transitionName, truncateMiddle } from '../../../lib/format';
|
||||
import { localePath, useI18n } from '../../../i18n';
|
||||
import { formatters, starString } from '../../../i18n/format';
|
||||
import { statusLabel, warningOptions } from '../../../i18n/mint';
|
||||
import { localePathsFor } from '../../../i18n/paths';
|
||||
|
||||
/** Every federation, in every locale. One API call per federation, shared across the three. */
|
||||
export async function getStaticPaths() {
|
||||
const federations = await fetchFedimints();
|
||||
const details = await Promise.all(
|
||||
federations.map(async (federation, index) => {
|
||||
try {
|
||||
return { detail: await fetchFedimint(federation.host), rank: index + 1 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const collisions = nameCollisions(federations);
|
||||
|
||||
/*
|
||||
* The same related ring the mint pages use: position i links to i+1, i+2 and i+3,
|
||||
* wrapping. Deterministic, never itself, and drawn only from federations whose own
|
||||
* pages are indexable — a page nobody should index needs no crawl paths out of it.
|
||||
*/
|
||||
const ring = federations.filter(isIndexableMint);
|
||||
const ringIndex = new Map(ring.map((federation, index) => [federation.host, index]));
|
||||
|
||||
const related = (host: string): MintListItem[] => {
|
||||
const at = ringIndex.get(host);
|
||||
if (at === undefined || ring.length < 2) return [];
|
||||
return [1, 2, 3]
|
||||
.filter((step) => step < ring.length)
|
||||
.map((step) => ring[(at + step) % ring.length]!);
|
||||
};
|
||||
|
||||
return localePathsFor(
|
||||
details
|
||||
.filter((d): d is { detail: FedimintDetail; rank: number } => d !== null)
|
||||
.map(({ detail, rank }) => ({
|
||||
params: { id: detail.host },
|
||||
props: {
|
||||
federation: detail,
|
||||
rank,
|
||||
related: related(detail.host),
|
||||
seoName: seoName(detail.name, detail.host, collisions),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const { federation, rank, related, seoName: titleName, locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const federationId = federation.federation_id ?? '';
|
||||
const shortId = truncateMiddle(federationId, 10, 6);
|
||||
const name = federation.name ?? t('fedimint.unnamed');
|
||||
const icon = iconUrl(federation.icon);
|
||||
const status = statusLabel(federation.status, t);
|
||||
/** Nothing has ever confirmed this federation is running. */
|
||||
const announced = federation.status === 'announced';
|
||||
|
||||
const modules = federation.modules ?? [];
|
||||
const extraModules = otherModules(modules);
|
||||
const inviteCodes = federation.invite_codes ?? [];
|
||||
const network = federation.network;
|
||||
|
||||
/* verdict: rating. Identical to the mint page's, because a rating means the same thing. */
|
||||
const dist = federation.rating_distribution;
|
||||
const distMax = Math.max(1, ...Object.values(dist));
|
||||
const ratedCount = Object.values(dist).reduce((a, b) => a + b, 0);
|
||||
const unrated = federation.review_count - ratedCount;
|
||||
|
||||
/*
|
||||
* The banner.
|
||||
*
|
||||
* `getMintWarnings` takes the Fedimint branch on `type`, so this page can only ever
|
||||
* receive one of two: a real check reported the guardians down, or the announcement is
|
||||
* a month old and nothing has confirmed it. There is no code path from here to a
|
||||
* capability warning, which is what keeps this page from inventing one.
|
||||
*/
|
||||
const warning = getMintWarnings(federation, warningOptions(f))[0] ?? null;
|
||||
|
||||
/*
|
||||
* SEO. Same templates as a mint page, with the Cashu-only clause swapped out: a
|
||||
* federation's third fact is its modules and network, never limits or a version.
|
||||
*/
|
||||
const hasRating = federation.review_count > 0 && federation.rating_avg !== null;
|
||||
const offlineDays =
|
||||
federation.status === 'offline' && federation.last_online !== null
|
||||
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - federation.last_online / 86400))
|
||||
: null;
|
||||
|
||||
const title =
|
||||
offlineDays !== null
|
||||
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
|
||||
: hasRating
|
||||
? t('seo.mint.title', {
|
||||
name: titleName,
|
||||
rating: f.decimal(federation.rating_avg!),
|
||||
n: federation.review_count,
|
||||
})
|
||||
: t('seo.fedimint.titleNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* The opening sentence is the announcement's own description when it carried one,
|
||||
* cleaned for a meta tag but never translated. The fallback identifies the federation
|
||||
* by its short id, which is the one string that unambiguously names it.
|
||||
*/
|
||||
const lead =
|
||||
cleanMintDescription(federation.description) ??
|
||||
t('seo.desc.fallback', { name, domain: shortId });
|
||||
|
||||
const ratingPart = hasRating
|
||||
? t('seo.desc.rated', { rating: f.decimal(federation.rating_avg!), n: federation.review_count })
|
||||
: t('seo.desc.noReviews');
|
||||
|
||||
// A warning's own meta sentence ("Announced 28 May 2024, never confirmed") replaces
|
||||
// the plain status: the state is the one thing a search result must not soften.
|
||||
const moduleList = modules.slice(0, 5).join(', ');
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: moduleList
|
||||
? network
|
||||
? t('seo.desc.fedModulesNetwork', { status, modules: moduleList, network: localNetworkName(network, f) })
|
||||
: t('seo.desc.fedModules', { status, modules: moduleList })
|
||||
: t('seo.desc.status', { status });
|
||||
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
|
||||
|
||||
/* The card: one English render shared by all locales, hashed filename per state. */
|
||||
const ogImage = ogImageFor(federation.host);
|
||||
const imageAlt = hasRating
|
||||
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(federation.rating_avg!), n: federation.review_count })
|
||||
: t('seo.mint.ogAltNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* Indexability, on exactly the same rule as a mint page: never confirmed and never
|
||||
* reviewed is a page with nothing on it that is not also on the next one. It stays
|
||||
* listed, linked and reviewable; it just carries `noindex, follow` until a check
|
||||
* reaches it or somebody writes something.
|
||||
*/
|
||||
const indexable = isIndexableMint(federation);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: new URL(localePath('/', locale), Astro.site).href },
|
||||
{ name: t('fedimint.crumb'), url: new URL(localePath('/fedimints', locale), Astro.site).href },
|
||||
{ name: shortId },
|
||||
];
|
||||
|
||||
const canonical = new URL(localePath(`/fedimint/${federation.host}`, locale), Astro.site).href;
|
||||
|
||||
const schema = [
|
||||
breadcrumbNode(canonical, crumbs),
|
||||
mintServiceNode({
|
||||
canonical,
|
||||
name: titleName,
|
||||
description,
|
||||
product: PRODUCT_JSONLD,
|
||||
/*
|
||||
* The invite code stands in for the service's address, because it is the address:
|
||||
* it is the string a wallet is given to reach this federation. A federation with no
|
||||
* code would not be listed at all, so there is always one.
|
||||
*/
|
||||
mintUrl: inviteCodes[0] ?? federation.url,
|
||||
icon: icon ? new URL(icon, Astro.site).href : null,
|
||||
rating:
|
||||
ratedCount > 0 && federation.rating_avg !== null
|
||||
? {
|
||||
value: Number(federation.rating_avg.toFixed(1)),
|
||||
ratingCount: ratedCount,
|
||||
reviewCount: federation.review_count,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
title={title}
|
||||
description={description}
|
||||
current="fedimints"
|
||||
noindex={!indexable}
|
||||
schema={schema}
|
||||
image={ogImage ?? undefined}
|
||||
imageAlt={imageAlt}
|
||||
>
|
||||
<div class="page">
|
||||
<nav class="crumbs" aria-label={t('fedimint.breadcrumb')}>
|
||||
<a href={localePath('/', locale)}>{t('nav.home')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<a href={localePath('/fedimints', locale)}>{t('fedimint.crumb')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<span class="here mono">{shortId}</span>
|
||||
</nav>
|
||||
|
||||
<div class="mint-head">
|
||||
{
|
||||
icon ? (
|
||||
<img
|
||||
class="mint-icon"
|
||||
src={icon}
|
||||
alt=""
|
||||
width="72"
|
||||
height="72"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
style={`view-transition-name:${transitionName('icon', federation.host)}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="mint-icon"
|
||||
style={`background:${iconGradient(federationId)};view-transition-name:${transitionName('icon', federation.host)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-id">
|
||||
<div class="mint-name-row">
|
||||
<h1 style={`view-transition-name:${transitionName('name', federation.host)}`}>{name}</h1>
|
||||
<span class:list={['status-chip', federation.status]} data-live-status>
|
||||
<span class="status-dot"></span><span data-live-status-label>{status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The federation id, in full behind a copy button. It is the identifier a review
|
||||
points at and the one thing that unambiguously names this federation, so it
|
||||
takes the line the mint page gives to a URL.
|
||||
*/}
|
||||
<div class="mint-domain">
|
||||
<span class="fed-label">{t('fedimint.idLabel')}</span>
|
||||
<CopyableId
|
||||
value={federationId}
|
||||
label={shortId}
|
||||
kind={t('fedimint.kind.federationId')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mint-tags">
|
||||
<span class="tag">{t('mint.rankByScore', { rank: String(rank) })}</span>
|
||||
{
|
||||
/*
|
||||
The network badge. Anything that is not mainnet is called out rather than
|
||||
stated flatly: a signet federation's ecash is worth nothing, and someone
|
||||
scanning the page for the joinable one needs that to be loud.
|
||||
*/
|
||||
network && (
|
||||
<span class:list={['tag', { testnet: isTestNetwork(network) }]}>
|
||||
{localNetworkName(network, f)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{modules.length > 0 && <span class="tag">{t('fedimint.moduleCount', { n: modules.length })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="head-actions">
|
||||
{
|
||||
inviteCodes[0] && (
|
||||
<button class="btn" data-copy={inviteCodes[0]} type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
{t('fedimint.copyInvite')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<button class="btn primary" data-write-open type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
{t('mint.writeReview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Fragment set:html={bannerHtml(warning)} />
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<section class="verdict verdict-3" aria-labelledby="fed-summary">
|
||||
<h2 class="sr-only" id="fed-summary">{t('mint.summary')}</h2>
|
||||
|
||||
<div class="v-cell rating-cell" data-reveal data-reveal-delay="0">
|
||||
<div class="v-label">{t('mint.rating.label')}</div>
|
||||
<div class="rating-body">
|
||||
<div class="rating-figure">
|
||||
{
|
||||
federation.rating_avg === null ? (
|
||||
<>
|
||||
<div class="v-big">{t('time.none')}</div>
|
||||
<div class="v-sub">{t('mint.rating.none')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="v-big" data-count-up={federation.rating_avg.toFixed(1)} data-count-decimals="1">
|
||||
{f.decimal(federation.rating_avg)}
|
||||
</div>
|
||||
<div class="stars" aria-hidden="true">{starString(federation.rating_avg)}</div>
|
||||
<div class="v-sub">
|
||||
{t('mint.rating.reviews', { n: federation.review_count })}
|
||||
{unrated > 0 && <><br />{t('mint.rating.unrated', { n: unrated })}</>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="dist" aria-hidden="true">
|
||||
{
|
||||
([5, 4, 3, 2, 1] as const).map((n) => {
|
||||
const count = dist[String(n) as '1' | '2' | '3' | '4' | '5'];
|
||||
return (
|
||||
<div class="dist-row">
|
||||
<span class="n">{n}</span>
|
||||
<span class="dist-bar">
|
||||
<span
|
||||
class:list={['dist-fill', { low: n === 1 }]}
|
||||
style={`width:${Math.round((count / distMax) * 100)}%`}
|
||||
/>
|
||||
</span>
|
||||
<span class="c">{f.number(count)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="60">
|
||||
<div class="v-label">{t('mint.activity.label')}</div>
|
||||
<div class="v-big">{f.shortDuration(federation.last_review_at)}</div>
|
||||
<div class="v-sub">
|
||||
{federation.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')}
|
||||
{federation.reviews_90d > 0 && (
|
||||
<><br />{t('mint.activity.recent', { n: federation.reviews_90d })}</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Sparkline probes={federation.probes_recent} uptime={federation.uptime_30d} />
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="120">
|
||||
<div class="v-label">{t('fedimint.confirmed.label')}</div>
|
||||
<div class="v-mono" data-live-checked>
|
||||
{
|
||||
/*
|
||||
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 ? (
|
||||
<>
|
||||
<b>{t('fedimint.confirmed.never')}</b><br />
|
||||
<span>
|
||||
{federation.announced_at
|
||||
? t('fedimint.announcedOn', { when: f.relative(federation.announced_at) })
|
||||
: t('fedimint.announcedUnknown')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>{f.relative(federation.status === 'offline' ? federation.last_online : federation.last_probe)}</b><br />
|
||||
<span>
|
||||
{federation.status_source
|
||||
? t('fedimint.confirmed.by', { source: federation.status_source })
|
||||
: t('fedimint.confirmed.unattributed')}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<Reviews
|
||||
subject={fedimintSubject(federation)}
|
||||
reviewCount={federation.review_count}
|
||||
distribution={federation.rating_distribution}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
<aside class="side">
|
||||
{inviteCodes.length > 0 && <InviteCodes codes={inviteCodes} name={name} />}
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('fedimint.modules.title')}</h2>
|
||||
{
|
||||
modules.length === 0 ? (
|
||||
<p class="side-empty">{t('fedimint.modules.none')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div class="nut-hi" data-live-modules set:html={moduleRowsHtml(modules, f)} />
|
||||
{extraModules.length > 0 && (
|
||||
<div class="nut-rest">
|
||||
{extraModules.map((module) => (
|
||||
<span class="nut-chip" title={localModuleName(module, f)}>{module}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p class="nut-more">{t('fedimint.modules.note')}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('fedimint.details.title')}</h2>
|
||||
<div class="kv">
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.federationId')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={federationId}
|
||||
label={truncateMiddle(federationId, 10, 6)}
|
||||
kind={t('fedimint.kind.federationId')}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
network && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.network')}</span>
|
||||
<span class="kv-v">{localNetworkName(network, f)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
federation.announcer_pubkey && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.announcedBy')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={federation.announcer_pubkey}
|
||||
label={truncateMiddle(federation.announcer_pubkey, 10, 6)}
|
||||
kind={t('mint.kind.mintPubkey')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
federation.announced_at && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.announced')}</span>
|
||||
<span class="kv-v">{f.date(federation.announced_at)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.firstSeen')}</span>
|
||||
<span class="kv-v">{f.monthYear(federation.first_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="more-mints" aria-labelledby="more-federations">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="more-federations">{t('fedimint.more.title')}</h2>
|
||||
<a class="sec-link" href={localePath('/fedimints', locale)}>{t('fedimint.more.all')}</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{related.map((item) => <MintCard mint={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="mint-disclaimer">
|
||||
{t('fedimint.disclaimer')}
|
||||
<a href={localePath('/disclaimer', locale)}>{t('mint.disclaimerLink')}</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MintLive host={federation.host} />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.more-mints { margin-top: 52px; }
|
||||
|
||||
.mint-disclaimer {
|
||||
margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--line-soft);
|
||||
font-size: 13px; color: var(--faint); max-width: 720px;
|
||||
}
|
||||
.mint-disclaimer a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
|
||||
|
||||
.mint-head { display: flex; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
.mint-icon {
|
||||
width: 72px; height: 72px; border-radius: 18px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--display); font-weight: 700; font-size: 32px; color: #0E0D0B;
|
||||
border: 1px solid rgba(255, 255, 255, .14); object-fit: cover; overflow: hidden;
|
||||
}
|
||||
.mint-id { min-width: 240px; flex: 1; }
|
||||
.mint-name-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; overflow-wrap: anywhere; }
|
||||
.mint-domain {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 8px;
|
||||
font-size: 14px; color: var(--muted);
|
||||
}
|
||||
.fed-label { font-size: 12px; color: var(--faint); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.mint-domain :global(.cid) { font-family: var(--mono); font-size: 13.5px; color: var(--muted); }
|
||||
.mint-domain :global(.cid:hover) { color: var(--amber); }
|
||||
|
||||
.mint-tags { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.tag { font-size: 12px; font-weight: 500; color: var(--muted); border: 1px solid var(--line); border-radius: 7px; padding: 3px 9px; }
|
||||
/* Not mainnet: the ecash is play money and the badge has to read that way. */
|
||||
.tag.testnet { color: var(--amber); border-color: var(--amber-dim); background: rgba(240, 169, 59, .07); font-weight: 600; }
|
||||
|
||||
.head-actions { margin-left: auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.verdict {
|
||||
display: grid;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); overflow: hidden; margin-bottom: 28px;
|
||||
}
|
||||
/* Three cells rather than the mint page's four, with the rating keeping the width it
|
||||
needs for its distribution bars. */
|
||||
.verdict-3 { grid-template-columns: 1.35fr 1fr 1fr; }
|
||||
.v-cell { padding: 20px; border-right: 1px solid var(--line-soft); position: relative; }
|
||||
.v-cell:last-child { border-right: none; }
|
||||
.v-label {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--faint); margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
|
||||
height: 16px;
|
||||
}
|
||||
.v-big { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; line-height: 1; }
|
||||
.v-sub { font-size: 13px; color: var(--muted); margin-top: 6px; }
|
||||
.rating-body { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.rating-figure { flex: 0 0 auto; }
|
||||
.rating-body .dist { flex: 1 1 150px; }
|
||||
.rating-cell .stars { font-size: 15px; margin-top: 7px; }
|
||||
|
||||
.cols { display: grid; grid-template-columns: 1fr 372px; gap: 26px; align-items: start; }
|
||||
.side { display: flex; flex-direction: column; gap: 20px; }
|
||||
.side .panel { padding: 20px; }
|
||||
.side-empty { font-size: 13.5px; color: var(--muted); }
|
||||
|
||||
.nut-rest { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.nut-chip { font-family: var(--mono); font-size: 10.5px; line-height: 1.5; color: var(--muted); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.nut-more { font-size: 12px; color: var(--faint); margin-top: 10px; line-height: 1.55; }
|
||||
|
||||
.kv-row :global(.cid.kv-v) { flex: 1; font-size: 12.5px; color: var(--text); }
|
||||
.kv-row :global(.cid.kv-v:hover) { color: var(--amber); }
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.cols { grid-template-columns: 1fr; }
|
||||
.verdict-3 { grid-template-columns: 1fr 1fr; }
|
||||
.v-cell:nth-child(2) { border-right: none; }
|
||||
.v-cell:nth-child(1), .v-cell:nth-child(2) { border-bottom: 1px solid var(--line-soft); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.verdict-3 { grid-template-columns: 1fr; }
|
||||
.v-cell { border-right: none; border-bottom: 1px solid var(--line-soft); }
|
||||
.v-cell:last-child { border-bottom: none; }
|
||||
.head-actions { margin-left: 0; width: 100%; }
|
||||
.head-actions .btn { flex: 1 1 44%; justify-content: center; }
|
||||
.page { padding: 20px var(--page-pad) 60px; }
|
||||
h1 { font-size: 26px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds, wireCopyButtons } from '../../../lib/client';
|
||||
import { countUp, onReady, whenRevealed } from '../../../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
wireCopyButtons();
|
||||
wireCopyableIds();
|
||||
|
||||
const big = document.querySelector<HTMLElement>('[data-count-up]');
|
||||
const cell = big?.closest<HTMLElement>('[data-reveal]');
|
||||
if (big && cell) {
|
||||
const target = Number(big.dataset['countUp']);
|
||||
const decimals = Number(big.dataset['countDecimals'] ?? '0');
|
||||
whenRevealed(cell, () => countUp(big, target, { decimals }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={t('fedimints.title')} description={description} current="fedimints" schema={schema}>
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<h1>{t('fedimints.heading')}</h1>
|
||||
<p class="lede">{t('fedimints.lede')}</p>
|
||||
</div>
|
||||
|
||||
<div class="controls" data-mint-controls>
|
||||
<form class="search" role="search" onsubmit="return false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<label class="sr-only" for="mint-search">{t('fedimints.search.label')}</label>
|
||||
<input
|
||||
id="mint-search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('fedimints.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="sr-only" for="mint-sort">{t('fedimints.sort.label')}</label>
|
||||
<div class="select-wrap">
|
||||
<select id="mint-sort" data-sort>
|
||||
<option value="score">{t('mints.sort.score')}</option>
|
||||
<option value="rating">{t('mints.sort.rating')}</option>
|
||||
<option value="reviews">{t('mints.sort.reviews')}</option>
|
||||
<option value="recent">{t('mints.sort.recent')}</option>
|
||||
</select>
|
||||
<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
|
||||
<span class="toggle-dot" aria-hidden="true"></span>
|
||||
{t('fedimints.hideOffline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="result-count" data-result-count aria-live="polite">
|
||||
{t('fedimints.showing', { total: federations.length, online, announced })}
|
||||
</p>
|
||||
|
||||
{/*
|
||||
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 && (
|
||||
<p class="index-note">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
<span>{t('fedimints.announcedNote', { n: announced })}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
{t('fedimints.noResults')}
|
||||
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
||||
</p>
|
||||
|
||||
{
|
||||
federations.length === 0 && (
|
||||
<p class="no-results">{t('fedimints.emptyIndex')}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index-head { margin-bottom: 24px; }
|
||||
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 12px; flex: 1 1 320px; min-width: 240px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 10px 16px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
||||
.search svg { color: var(--faint); flex-shrink: 0; }
|
||||
.search input {
|
||||
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
||||
color: var(--text); font-family: var(--body); font-size: 15px;
|
||||
}
|
||||
.search input::placeholder { color: var(--faint); }
|
||||
.search kbd {
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.control-group { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
|
||||
|
||||
.select-wrap { position: relative; display: flex; align-items: center; }
|
||||
.select-wrap select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 9px;
|
||||
color: var(--text); font-family: var(--body); font-size: 14px;
|
||||
padding: 10px 34px 10px 14px; cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.select-wrap select:hover { border-color: #38342A; }
|
||||
.select-wrap .chevron { position: absolute; right: 12px; color: var(--faint); pointer-events: none; }
|
||||
.select-wrap option { background: var(--card); color: var(--text); }
|
||||
|
||||
.toggle-chip {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--body); font-size: 13.5px; font-weight: 500;
|
||||
color: var(--muted); cursor: pointer;
|
||||
border: 1px solid var(--line); background: none; border-radius: 999px;
|
||||
padding: 9px 15px;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.toggle-chip:hover { color: var(--text); border-color: #38342A; }
|
||||
.toggle-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex-shrink: 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.toggle-chip[aria-pressed='true'] {
|
||||
color: #141109; background: var(--amber); border-color: var(--amber); font-weight: 600;
|
||||
}
|
||||
.toggle-chip[aria-pressed='true'] .toggle-dot { background: #141109; }
|
||||
|
||||
.result-count { font-size: 13px; color: var(--faint); margin-bottom: 8px; font-family: var(--mono); }
|
||||
|
||||
.index-note {
|
||||
display: flex; gap: 9px; align-items: flex-start; max-width: 720px;
|
||||
font-size: 13px; color: var(--muted); line-height: 1.55; margin-bottom: 16px;
|
||||
}
|
||||
.index-note svg { flex-shrink: 0; margin-top: 2px; color: var(--faint); }
|
||||
|
||||
.mint-grid :global(.mint-card.leaving) {
|
||||
transform: scale(.98);
|
||||
transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out);
|
||||
}
|
||||
|
||||
.no-results { color: var(--muted); font-size: 14.5px; padding: 34px 0; }
|
||||
.link-btn {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: var(--body); font-size: inherit; color: var(--amber);
|
||||
border-bottom: 1px solid var(--amber-dim);
|
||||
}
|
||||
.link-btn:hover { border-bottom-color: var(--amber); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.control-group { margin-left: 0; width: 100%; }
|
||||
.select-wrap, .select-wrap select { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../../lib/client';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||
|
||||
/** How long a card takes to fade out of the results before it is hidden. */
|
||||
const LEAVE_MS = 120;
|
||||
|
||||
const setup = (): void => {
|
||||
wireCopyableIds();
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
||||
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
||||
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
||||
const countEl = document.querySelector<HTMLElement>('[data-result-count]');
|
||||
const noResults = document.querySelector<HTMLElement>('[data-no-results]');
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||
|
||||
/*
|
||||
* The same four sorts /mints offers, minus "recently seen online".
|
||||
*
|
||||
* That one reads `data-last-online`, which is 0 for every federation nothing
|
||||
* checks — a third of this list — so the sort would silently bury them all under
|
||||
* a heading that implies they went offline. There is no honest way to order rows
|
||||
* by a measurement that was never taken.
|
||||
*/
|
||||
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
||||
score: (a, b) => offlineRank(a) - offlineRank(b) || num(b, 'score') - num(a, 'score'),
|
||||
rating: (a, b) => num(b, 'rating') - num(a, 'rating') || num(b, 'reviews') - num(a, 'reviews'),
|
||||
reviews: (a, b) => num(b, 'reviews') - num(a, 'reviews'),
|
||||
recent: (a, b) => num(b, 'lastReview') - num(a, 'lastReview'),
|
||||
};
|
||||
|
||||
const leaving = new Map<HTMLElement, number>();
|
||||
|
||||
function show(card: HTMLElement, visible: boolean): void {
|
||||
const pending = leaving.get(card);
|
||||
if (pending !== undefined) {
|
||||
window.clearTimeout(pending);
|
||||
leaving.delete(card);
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
const wasHidden = card.hidden;
|
||||
card.classList.remove('leaving');
|
||||
card.hidden = false;
|
||||
if (wasHidden && !prefersReducedMotion()) {
|
||||
card.classList.add('appearing');
|
||||
card.addEventListener('animationend', () => card.classList.remove('appearing'), { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.hidden) return;
|
||||
if (prefersReducedMotion()) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.classList.add('leaving');
|
||||
leaving.set(
|
||||
card,
|
||||
window.setTimeout(() => {
|
||||
leaving.delete(card);
|
||||
card.hidden = true;
|
||||
card.classList.remove('leaving');
|
||||
}, LEAVE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
const query = searchInput!.value.trim().toLowerCase();
|
||||
// A pasted federation id should find its federation, whether or not the reader
|
||||
// trimmed it to the sixteen characters the card shows.
|
||||
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
|
||||
|
||||
let visible = 0;
|
||||
let onlineCount = 0;
|
||||
let announcedCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
const domain = card.dataset['domain'] ?? '';
|
||||
const matches =
|
||||
needle === '' ||
|
||||
(card.dataset['name'] ?? '').includes(needle) ||
|
||||
domain.includes(needle) ||
|
||||
// The card shows the first 16 characters of the id; a pasted full id starts
|
||||
// with them, so match that way round too.
|
||||
(domain.length > 0 && needle.startsWith(domain));
|
||||
const isOffline = card.dataset['status'] === 'offline';
|
||||
const keep = matches && !(hide && isOffline);
|
||||
|
||||
show(card, keep);
|
||||
if (keep) {
|
||||
visible++;
|
||||
if (card.dataset['status'] === 'online') onlineCount++;
|
||||
else if (card.dataset['status'] === 'announced') announcedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
|
||||
for (const card of sorted) grid!.append(card);
|
||||
|
||||
const ranked = sortSelect!.value === 'score' && needle === '' && !hide;
|
||||
let rank = 0;
|
||||
for (const card of sorted) {
|
||||
const chip = card.querySelector<HTMLElement>('.mc-rank');
|
||||
if (!chip) continue;
|
||||
if (!ranked) {
|
||||
chip.hidden = true;
|
||||
continue;
|
||||
}
|
||||
rank++;
|
||||
chip.hidden = false;
|
||||
chip.textContent = `#${rank}`;
|
||||
chip.classList.toggle('gold', rank === 1);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
swapText(
|
||||
countEl,
|
||||
visible === cards.length
|
||||
? t('fedimints.showing', {
|
||||
total: visible,
|
||||
online: onlineCount,
|
||||
announced: announcedCount,
|
||||
})
|
||||
: t('fedimints.showingFiltered', {
|
||||
shown: visible,
|
||||
total: cards.length,
|
||||
online: onlineCount,
|
||||
announced: announcedCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', apply);
|
||||
sortSelect.addEventListener('change', () => {
|
||||
apply();
|
||||
enterStagger(cards.filter((card) => !card.hidden));
|
||||
});
|
||||
hideOffline.addEventListener('click', () => {
|
||||
const on = hideOffline.getAttribute('aria-pressed') === 'true';
|
||||
hideOffline.setAttribute('aria-pressed', String(!on));
|
||||
apply();
|
||||
});
|
||||
clearSearch?.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
apply();
|
||||
});
|
||||
|
||||
// Deep links from the home page strip: /fedimints?q=…&sort=…
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get('q');
|
||||
const sort = params.get('sort');
|
||||
if (q) searchInput.value = q;
|
||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||
if (q || sort) apply();
|
||||
|
||||
onLeave(() => {
|
||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||
leaving.clear();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -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', {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
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 && (
|
||||
<div class="section eco-strip-wrap">
|
||||
<a class="eco-strip" href={localePath('/fedimints', locale)} data-reveal>
|
||||
<span class="eco-mark" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="6" r="2.4" /><circle cx="5.5" cy="17" r="2.4" /><circle cx="18.5" cy="17" r="2.4" />
|
||||
<path d="M10.6 8 6.9 14.9M13.4 8l3.7 6.9M8 17.4h8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="eco-body">
|
||||
<span class="eco-title">{t('home.fedimint.title')}</span>
|
||||
<span class="eco-sub">
|
||||
{t('home.fedimint.body', {
|
||||
total: stats.fedimint_total,
|
||||
online: stats.fedimint_online,
|
||||
announced: stats.fedimint_announced,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span class="eco-go">
|
||||
{t('home.fedimint.link')}
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<section class="section" aria-labelledby="latest-reviews-title" id="latest-reviews">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="latest-reviews-title">{t('home.latest.title')}</h2>
|
||||
@@ -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 class="rev-card" href={localePath(`/mint/${review.mint_host}`, locale)} data-reveal>
|
||||
<a
|
||||
class="rev-card"
|
||||
href={localePath(targetPath({ type: review.mint_type, host: review.mint_host }), locale)}
|
||||
data-reveal
|
||||
>
|
||||
<span class="rc-top">
|
||||
<span class="rc-avatar-wrap">
|
||||
<span class="rc-avatar" style={`background:${iconGradient(review.pubkey)}`} aria-hidden="true">
|
||||
@@ -360,6 +420,47 @@ const signedBody = t('home.why.signed.body', {
|
||||
}
|
||||
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
|
||||
|
||||
/* ---------- fedimint strip ---------- */
|
||||
/*
|
||||
* One card wide enough to be a section and short enough not to be one. It sits in a
|
||||
* `.section` for the page's gutter and max width, but carries no heading of its own:
|
||||
* a `<h2>` here would give a signpost the same weight as the mint grid above it.
|
||||
*/
|
||||
.eco-strip-wrap { padding-top: 0; }
|
||||
.eco-strip {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); padding: 18px 20px;
|
||||
transition: border-color var(--dur-quick), background var(--dur-quick), transform var(--dur-quick);
|
||||
}
|
||||
.eco-strip:hover { border-color: #38342A; background: var(--card2); }
|
||||
.eco-strip:hover .eco-go { color: var(--amber); }
|
||||
.eco-strip:hover .eco-go svg { transform: translateX(2px); }
|
||||
|
||||
.eco-mark {
|
||||
width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--amber); background: rgba(240, 169, 59, .09);
|
||||
border: 1px solid rgba(240, 169, 59, .22);
|
||||
}
|
||||
.eco-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.eco-title { font-family: var(--display); font-size: 16.5px; font-weight: 600; letter-spacing: -.01em; color: var(--text); }
|
||||
.eco-sub { font-size: 13.5px; color: var(--muted); }
|
||||
.eco-go {
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13.5px; font-weight: 500; color: var(--muted);
|
||||
transition: color var(--dur-quick);
|
||||
}
|
||||
.eco-go svg { transition: transform var(--dur-quick); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* The arrow label drops; the whole row is the link and the chevron still points. */
|
||||
.eco-strip { gap: 13px; }
|
||||
.eco-go { font-size: 0; gap: 0; }
|
||||
.eco-go svg { font-size: 15px; }
|
||||
}
|
||||
|
||||
/* ---------- latest reviews carousel ---------- */
|
||||
/*
|
||||
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
|
||||
|
||||
@@ -7,12 +7,15 @@ import CopyableId from '../../../components/CopyableId.astro';
|
||||
import MintQr from '../../../components/MintQr.astro';
|
||||
import MintCard from '../../../components/MintCard.astro';
|
||||
import {
|
||||
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, readCapabilities,
|
||||
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, parseLimits, readCapabilities,
|
||||
type MintDetail, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints, iconUrl } from '../../../lib/api';
|
||||
import { ogImageFor } from '../../../lib/og';
|
||||
import { mintSubject } from '../../../lib/review-subject';
|
||||
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
|
||||
import { clampWords, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { PRODUCT_JSONLD } from '../../../lib/site';
|
||||
import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state';
|
||||
import {
|
||||
displayDomain, iconGradient, initials, shortNpub, transitionName, truncateMiddle,
|
||||
@@ -149,45 +152,83 @@ const isBeta = /\b(beta|alpha|experimental|research project|test|do not|don't st
|
||||
/*
|
||||
SEO.
|
||||
|
||||
The description is three sentences, and only two of them are ours. The first is the
|
||||
mint's own description when it has published one, verbatim, in whatever language the
|
||||
operator wrote it in: it is their copy about their service and translating it would be
|
||||
putting words in their mouth. Only when there is none does the site write that
|
||||
sentence itself.
|
||||
Title first. Three shapes, most urgent wins: a mint that is offline leads with that
|
||||
(a search result should warn before anyone clicks), a rated mint leads with its
|
||||
rating, and a mint nobody has reviewed says plainly what the page is.
|
||||
*/
|
||||
const ratingPart =
|
||||
mint.review_count > 0 && mint.rating_avg !== null
|
||||
? t('mint.description.rated', {
|
||||
n: mint.review_count,
|
||||
rating: f.decimal(mint.rating_avg),
|
||||
})
|
||||
: t('mint.description.noReviews');
|
||||
const hasRating = mint.review_count > 0 && mint.rating_avg !== null;
|
||||
const offlineDays =
|
||||
offline && mint.last_online !== null
|
||||
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - mint.last_online / 86400))
|
||||
: null;
|
||||
|
||||
// The state goes in the description too, so a search result for a melt-only or a
|
||||
// long-dead mint carries the warning before anyone clicks through to read it, in the
|
||||
// language of the result they are looking at.
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: mint.status === 'online'
|
||||
? t('mint.description.online')
|
||||
: t('mint.description.unreachable');
|
||||
const title =
|
||||
offlineDays !== null
|
||||
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
|
||||
: hasRating
|
||||
? t('seo.mint.title', {
|
||||
name: titleName,
|
||||
rating: f.decimal(mint.rating_avg!),
|
||||
n: mint.review_count,
|
||||
})
|
||||
: t('seo.mint.titleNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* The opening sentence. The mint's own description when it published one; otherwise the
|
||||
* site writes it, and which sentence it writes depends on whether the mint's name says
|
||||
* anything. "Cashu mint is a Cashu mint at mint.endfiat.money" is what the name-shaped
|
||||
* sentence produces for a mint called "Cashu mint", so a generic or missing name gets a
|
||||
* sentence built around the domain instead.
|
||||
* The description: the mint's own words, then the rating, then the state.
|
||||
*
|
||||
* The opening sentence is the mint's own description when it published one — cleaned
|
||||
* for a meta tag (first sentence, shouting normalised, 70 chars on a word boundary)
|
||||
* but never translated: it is their copy about their service. Only when there is none
|
||||
* does the site write that sentence, and that one is localized.
|
||||
*
|
||||
* The state goes in too, so a search result for a melt-only or long-dead mint carries
|
||||
* the warning before anyone clicks through: a warning's own `meta` sentence replaces
|
||||
* the plain status, and the limits are only quoted for a mint that can actually take
|
||||
* a deposit.
|
||||
*/
|
||||
// A mint with no name of its own (titleName fell back to the domain) gets the
|
||||
// domain-only sentence, not "Community reviews for {domain} ({domain})".
|
||||
const lead =
|
||||
mint.description?.trim() ||
|
||||
cleanMintDescription(mint.description) ??
|
||||
(titleName === domain
|
||||
? t('mint.description.fallbackDomain', { domain })
|
||||
: t('mint.description.fallback', { name, domain }));
|
||||
? t('seo.desc.fallbackDomain', { domain })
|
||||
: t('seo.desc.fallback', { name: titleName, domain }));
|
||||
|
||||
// Clamped on a word boundary: a description cut mid-word reads as broken everywhere
|
||||
// that does not re-truncate it, which is every link preview and feed reader.
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 300);
|
||||
const ratingPart = hasRating
|
||||
? t('seo.desc.rated', { rating: f.decimal(mint.rating_avg!), n: mint.review_count })
|
||||
: t('seo.desc.noReviews');
|
||||
|
||||
const limits = parseLimits(info?.nuts, 4);
|
||||
const limitsText =
|
||||
!warning && limits && !caps.mintDisabled
|
||||
? limits.min !== null && limits.max !== null
|
||||
? t('mint.limits.range', { min: f.compact(limits.min), max: f.compact(limits.max), unit: limits.unit })
|
||||
: limits.max !== null
|
||||
? t('mint.limits.upTo', { max: f.compact(limits.max), unit: limits.unit })
|
||||
: limits.min !== null
|
||||
? t('mint.limits.from', { min: f.compact(limits.min), unit: limits.unit })
|
||||
: null
|
||||
: null;
|
||||
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: limitsText
|
||||
? t('seo.desc.statusLimits', { status, limits: limitsText })
|
||||
: t('seo.desc.status', { status });
|
||||
|
||||
// Clamped on a word boundary to the length search results actually show; anything
|
||||
// longer is cut mid-thought by every consumer that matters.
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
|
||||
|
||||
/*
|
||||
* The card. One English render shared by every locale (the manifest maps host to a
|
||||
* content-hashed filename), while title, description and alt text translate. A mint
|
||||
* whose card failed to render falls back to the sitewide default via Base.
|
||||
*/
|
||||
const ogImage = ogImageFor(mint.host);
|
||||
const imageAlt = hasRating
|
||||
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(mint.rating_avg!), n: mint.review_count })
|
||||
: t('seo.mint.ogAltNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* Indexability.
|
||||
@@ -215,6 +256,7 @@ const schema = [
|
||||
name: titleName,
|
||||
description,
|
||||
mintUrl: mint.url,
|
||||
product: PRODUCT_JSONLD,
|
||||
icon: icon ? new URL(icon, Astro.site).href : null,
|
||||
/*
|
||||
* Only when reviews actually carried ratings. `ratedCount` is what produced the
|
||||
@@ -234,12 +276,14 @@ const schema = [
|
||||
---
|
||||
|
||||
<Base
|
||||
title={t('mint.title', { name: titleName })}
|
||||
title={title}
|
||||
description={description}
|
||||
current="mints"
|
||||
mintCount={undefined}
|
||||
noindex={!indexable}
|
||||
schema={schema}
|
||||
image={ogImage ?? undefined}
|
||||
imageAlt={imageAlt}
|
||||
>
|
||||
<div class="page">
|
||||
{/*
|
||||
@@ -438,7 +482,12 @@ const schema = [
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<Reviews mint={mint} />
|
||||
<Reviews
|
||||
subject={mintSubject(mint)}
|
||||
reviewCount={mint.review_count}
|
||||
distribution={mint.rating_distribution}
|
||||
name={mint.name ?? mint.host}
|
||||
/>
|
||||
|
||||
<aside class="side">
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
/**
|
||||
* The global review feed: every mint's reviews in one list, newest first.
|
||||
* The global review feed: every review of every mint and federation, newest first.
|
||||
*
|
||||
* Prerendered from relay data at build time so the page has real reviews for a
|
||||
* crawler and for a reader with JavaScript off, then the island re-queries the relays
|
||||
@@ -9,11 +9,12 @@
|
||||
* review looks and behaves identically wherever you meet it.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchFedimints, fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchReviewFeed } from '../../lib/nostr-build';
|
||||
import { feedTargets, targetPath } from '../../lib/feed-resolve';
|
||||
import { reviewHtml } from '../../lib/review-cards';
|
||||
import type { LoadedReview } from '../../lib/reviews-client';
|
||||
import { displayDomain } from '../../lib/format';
|
||||
import { ECOSYSTEMS } from '@cashumints/shared';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { formatters } from '../../i18n/format';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
@@ -32,19 +33,21 @@ const f = formatters(t);
|
||||
/** How many reviews the build renders. Enough to fill the page above and below the fold. */
|
||||
const PRERENDER_COUNT = 30;
|
||||
|
||||
const [mints, stats] = await Promise.all([fetchMints(), fetchStats()]);
|
||||
|
||||
const hostByUrl = new Map(mints.map((mint) => [mint.url, mint.host]));
|
||||
const feed = await fetchReviewFeed(hostByUrl, PRERENDER_COUNT);
|
||||
const [mints, federations, stats] = await Promise.all([
|
||||
fetchMints(),
|
||||
fetchFedimints(),
|
||||
fetchStats(),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Handed to the island as JSON so the browser can resolve a review's `u` tag back to
|
||||
* a mint without asking the API again. Name and domain are what the card links show.
|
||||
* Everything the feed can resolve a review to, both ecosystems in one list.
|
||||
*
|
||||
* Handed to the island as JSON so the browser can resolve a review back to a listing
|
||||
* without asking the API again. Name and domain are what the card's link shows; `ref`
|
||||
* is the federation id a 38173 review points at, read straight out of the row key.
|
||||
*/
|
||||
const feedMints = mints.map((mint) => {
|
||||
const domain = displayDomain(mint.url);
|
||||
return { url: mint.url, host: mint.host, name: mint.name ?? domain.split('/')[0] ?? domain, domain };
|
||||
});
|
||||
const feedMints = feedTargets(mints, federations);
|
||||
const feed = await fetchReviewFeed(feedMints, PRERENDER_COUNT);
|
||||
|
||||
const mintByHost = new Map(feedMints.map((mint) => [mint.host, mint]));
|
||||
|
||||
@@ -54,13 +57,26 @@ const mintsPayload = JSON.stringify(feedMints).replace(/</g, '\\u003c');
|
||||
const cards = feed.map((review) => {
|
||||
const mint = mintByHost.get(review.mint_host);
|
||||
return reviewHtml(review as unknown as LoadedReview, f, {
|
||||
mint: mint ? { href: localePath(`/mint/${mint.host}`, locale), label: mint.domain } : null,
|
||||
mint: mint ? { href: localePath(targetPath(mint), locale), label: mint.domain } : null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The ecosystem filter's options: "All", then one per ecosystem, in `ECOSYSTEMS` order.
|
||||
*
|
||||
* Built from that list rather than written out, so a third ecosystem is a line in
|
||||
* `shared/src/nostr.ts` and a catalog key — not an edit to this page, its island, or
|
||||
* the counting below.
|
||||
*/
|
||||
const ecosystemFilters = [
|
||||
{ key: 'all', label: t('feed.ecosystem.all') },
|
||||
...ECOSYSTEMS.map((type) => ({ key: type, label: t(`feed.ecosystem.${type}`) })),
|
||||
];
|
||||
|
||||
const description = t('feed.description', {
|
||||
reviews: stats.reviews_total,
|
||||
mints: stats.mints_total,
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -111,12 +127,37 @@ const footNote = t('feed.foot', {
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div class="filters eco-filters" role="group" aria-label={t('feed.ecosystem.group')}>
|
||||
{
|
||||
ecosystemFilters.map((option, index) => (
|
||||
<button
|
||||
class="filter"
|
||||
type="button"
|
||||
data-eco={option.key}
|
||||
aria-pressed={index === 0 ? 'true' : 'false'}
|
||||
>
|
||||
{/*
|
||||
No count on "All": the rating group beside it already shows the same
|
||||
total, and two adjacent pills both reading "All · 180" read as one
|
||||
control that has been rendered twice.
|
||||
*/}
|
||||
{option.label}{option.key !== 'all' && <span data-eco-count={option.key}></span>}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div class="mint-filter">
|
||||
<label class="sr-only" for="feed-mint">{t('feed.filterByMint')}</label>
|
||||
<label class="sr-only" for="feed-mint">{t('feed.filterBySubject')}</label>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
@@ -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
|
||||
/>
|
||||
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearMint')}>
|
||||
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearSubject')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -176,6 +217,16 @@ const footNote = t('feed.foot', {
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.controls .filters { margin-left: 0; }
|
||||
/*
|
||||
* A hairline before the ecosystem group, so two runs of identical pills read as two
|
||||
* questions rather than as one long row of seven. It disappears when the row wraps,
|
||||
* where the line break already does that job.
|
||||
*/
|
||||
.eco-filters { position: relative; padding-left: 12px; }
|
||||
.eco-filters::before {
|
||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 1px; height: 20px; background: var(--line);
|
||||
}
|
||||
|
||||
.mint-filter {
|
||||
display: flex; align-items: center; gap: 10px; margin-left: auto;
|
||||
@@ -210,6 +261,11 @@ const footNote = t('feed.foot', {
|
||||
/* The pager is rebuilt through innerHTML, so its gap marker cannot be scoped. */
|
||||
:global(.pg-gap) { color: var(--faint); padding: 0 2px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Wrapped onto its own line: the divider would be pointing at nothing. */
|
||||
.eco-filters { padding-left: 0; }
|
||||
.eco-filters::before { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.mint-filter { margin-left: 0; width: 100%; }
|
||||
}
|
||||
@@ -222,6 +278,7 @@ const footNote = t('feed.foot', {
|
||||
import { localeFromPath, localePath } from '../../i18n/routing';
|
||||
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
|
||||
import { loadFeed, type FeedMint, type FeedReview } from '../../lib/feed-client';
|
||||
import { targetPath } from '../../lib/feed-resolve';
|
||||
import { applyProfiles, isWritten, reviewHtml, summaryHtml, type MintRef } from '../../lib/review-cards';
|
||||
import { loadProfiles } from '../../lib/reviews-client';
|
||||
|
||||
@@ -243,6 +300,7 @@ const footNote = t('feed.foot', {
|
||||
|
||||
const list = document.querySelector<HTMLElement>('[data-feed-items]');
|
||||
const controls = document.querySelector<HTMLElement>('[data-feed-controls]');
|
||||
const ecoGroup = document.querySelector<HTMLElement>('.eco-filters');
|
||||
const countEl = document.querySelector<HTMLElement>('[data-feed-count]');
|
||||
const pagerWrap = document.querySelector<HTMLElement>('[data-feed-pager]');
|
||||
const mintInput = document.querySelector<HTMLInputElement>('[data-mint-filter]');
|
||||
@@ -257,6 +315,14 @@ const footNote = t('feed.foot', {
|
||||
|
||||
let all: FeedReview[] = [];
|
||||
let rating: 'all' | '5' | 'critical' = 'all';
|
||||
/**
|
||||
* Which ecosystem is showing: `all`, or a `mint_type` value.
|
||||
*
|
||||
* A string rather than a union, because the options are generated from
|
||||
* `ECOSYSTEMS` in the frontmatter and this has to accept whatever is in that list.
|
||||
* A third ecosystem needs no change here at all.
|
||||
*/
|
||||
let ecosystem = 'all';
|
||||
let mintQuery = '';
|
||||
let page = 1;
|
||||
/** Day summaries the reader has opened, so a filter or page change does not shut them. */
|
||||
@@ -269,6 +335,7 @@ const footNote = t('feed.foot', {
|
||||
function filtered(): FeedReview[] {
|
||||
const needle = mintQuery.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
return all.filter((review) => {
|
||||
if (ecosystem !== 'all' && review.mint_type !== ecosystem) return false;
|
||||
if (rating === '5' && review.rating !== 5) return false;
|
||||
if (rating === 'critical' && (review.rating === null || review.rating > 2)) return false;
|
||||
if (needle === '') return true;
|
||||
@@ -291,7 +358,9 @@ const footNote = t('feed.foot', {
|
||||
const pathLocale = localeFromPath(window.location.pathname);
|
||||
|
||||
const mintRef = (review: FeedReview): MintRef => ({
|
||||
href: localePath(`/mint/${review.mint_host}`, pathLocale),
|
||||
// One route per ecosystem, decided by the same helper the build uses, so a card
|
||||
// cannot link to /mint/fed-… after the island redraws it.
|
||||
href: localePath(targetPath({ type: review.mint_type, host: review.mint_host }), pathLocale),
|
||||
label: review.mint_domain,
|
||||
});
|
||||
|
||||
@@ -422,13 +491,30 @@ const footNote = t('feed.foot', {
|
||||
}
|
||||
|
||||
function updateCounts(): void {
|
||||
/*
|
||||
* The rating counts are counts within the chosen ecosystem, not across the whole
|
||||
* feed. Pressing "Fedimint" and still reading "5 stars · 380" would be counting
|
||||
* reviews the list is not going to show.
|
||||
*/
|
||||
const inEcosystem = all.filter((r) => ecosystem === 'all' || r.mint_type === ecosystem);
|
||||
|
||||
const set = (key: string, value: number) => {
|
||||
const el = controls!.querySelector(`[data-count="${key}"]`);
|
||||
if (el) el.textContent = ` · ${f.number(value)}`;
|
||||
};
|
||||
set('all', all.length);
|
||||
set('5', all.filter((r) => r.rating === 5).length);
|
||||
set('critical', all.filter((r) => r.rating !== null && r.rating <= 2).length);
|
||||
set('all', inEcosystem.length);
|
||||
set('5', inEcosystem.filter((r) => r.rating === 5).length);
|
||||
set('critical', inEcosystem.filter((r) => r.rating !== null && r.rating <= 2).length);
|
||||
|
||||
// The ecosystem counts are the other way round: each one counts its whole
|
||||
// ecosystem, so the group says how much there is of each rather than reacting to
|
||||
// the rating filter beside it.
|
||||
for (const button of ecoGroup?.querySelectorAll<HTMLElement>('[data-eco]') ?? []) {
|
||||
const key = button.dataset['eco'] ?? 'all';
|
||||
// "All" has no count slot, by design: see the comment on the markup.
|
||||
const slot = button.querySelector('[data-eco-count]');
|
||||
if (slot) slot.textContent = ` · ${f.number(all.filter((r) => r.mint_type === key).length)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- loading ---------- */
|
||||
@@ -470,7 +556,22 @@ const footNote = t('feed.foot', {
|
||||
/* ---------- events ---------- */
|
||||
|
||||
controls.addEventListener('click', (event) => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-filter]');
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const ecoButton = target.closest<HTMLButtonElement>('[data-eco]');
|
||||
if (ecoButton) {
|
||||
ecosystem = ecoButton.dataset['eco'] ?? 'all';
|
||||
page = 1;
|
||||
for (const other of ecoGroup?.querySelectorAll('[data-eco]') ?? []) {
|
||||
other.setAttribute('aria-pressed', String(other === ecoButton));
|
||||
}
|
||||
render(true);
|
||||
// The rating counts are scoped to the ecosystem, so they move with it.
|
||||
updateCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest<HTMLButtonElement>('[data-filter]');
|
||||
if (!button) return;
|
||||
rating = button.dataset['filter'] as typeof rating;
|
||||
page = 1;
|
||||
@@ -531,14 +632,30 @@ const footNote = t('feed.foot', {
|
||||
void load();
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// A mint can be preselected from a link: /reviews?mint=mint.example.com
|
||||
const preset = new URLSearchParams(window.location.search).get('mint');
|
||||
const preset = params.get('mint');
|
||||
if (preset) {
|
||||
mintInput.value = preset;
|
||||
mintQuery = preset;
|
||||
if (mintClear) mintClear.hidden = false;
|
||||
}
|
||||
|
||||
// And an ecosystem: /reviews?type=fedimint, which is what the federation pages and
|
||||
// the home page strip link to. An unknown value is ignored rather than showing an
|
||||
// empty feed for a filter with no button pressed to explain it.
|
||||
const presetType = params.get('type');
|
||||
const ecoButton = presetType
|
||||
? ecoGroup?.querySelector<HTMLButtonElement>(`[data-eco="${CSS.escape(presetType)}"]`)
|
||||
: null;
|
||||
if (ecoButton) {
|
||||
ecosystem = presetType!;
|
||||
for (const other of ecoGroup?.querySelectorAll('[data-eco]') ?? []) {
|
||||
other.setAttribute('aria-pressed', String(other === ecoButton));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* /404 is deliberately absent, in every locale.
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { fetchMints } from '../lib/api';
|
||||
import { fetchFedimints, fetchMints } from '../lib/api';
|
||||
import { alternates } from '../i18n/routing';
|
||||
import { isIndexableMint } from '../lib/seo';
|
||||
import { DEFAULT_LOCALE, LOCALE_CODES } from '../i18n/config';
|
||||
@@ -36,6 +36,12 @@ interface Entry {
|
||||
const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
|
||||
{ path: '/', changefreq: 'daily', priority: 1.0 },
|
||||
{ path: '/mints', changefreq: 'daily', priority: 0.9 },
|
||||
/*
|
||||
* Below /mints, above /reviews. There are an order of magnitude fewer federations
|
||||
* than mints and this site's centre of gravity is still Cashu, so the index that
|
||||
* changes most and matters most keeps the higher number.
|
||||
*/
|
||||
{ path: '/fedimints', changefreq: 'daily', priority: 0.8 },
|
||||
{ path: '/reviews', changefreq: 'daily', priority: 0.7 },
|
||||
{ path: '/wallets', changefreq: 'monthly', priority: 0.5 },
|
||||
{ path: '/about', changefreq: 'monthly', priority: 0.5 },
|
||||
@@ -53,6 +59,9 @@ const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
|
||||
*/
|
||||
const MINT_PRIORITY = 0.8;
|
||||
|
||||
/** One notch below a mint page, for the same reason /fedimints sits below /mints. */
|
||||
const FEDIMINT_PRIORITY = 0.7;
|
||||
|
||||
/**
|
||||
* Non-default locales sit slightly below their English counterpart, which is the same
|
||||
* thing `x-default` says in the alternate set: when a crawler has to pick one, pick
|
||||
@@ -74,7 +83,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
const origin = site ?? new URL('https://cashumints.space');
|
||||
const builtAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const mints = await fetchMints();
|
||||
const [mints, federations] = await Promise.all([fetchMints(), fetchFedimints()]);
|
||||
|
||||
const entries: Entry[] = [
|
||||
...STATIC_PAGES.map((page) => ({ ...page, lastmod: builtAt })),
|
||||
@@ -95,6 +104,23 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
changefreq: 'weekly' as const,
|
||||
priority: MINT_PRIORITY,
|
||||
})),
|
||||
/*
|
||||
* Federations, on exactly the same rule: only the ones whose own pages are
|
||||
* indexable, which `isIndexableMint` decides for both and `check-hreflang.mjs`
|
||||
* verifies against the built HTML.
|
||||
*
|
||||
* `lastmod` differs in one way worth noting. A federation that no check has ever
|
||||
* confirmed has no `last_online`, so its date comes from its newest review or, with
|
||||
* neither, from the build — and such a page is unindexable anyway, so that fallback
|
||||
* is unreachable in practice rather than merely unlikely.
|
||||
*/
|
||||
...federations.filter(isIndexableMint).map((federation) => ({
|
||||
path: `/fedimint/${federation.host}`,
|
||||
lastmod:
|
||||
Math.max(federation.last_review_at ?? 0, federation.last_online ?? 0) || builtAt,
|
||||
changefreq: 'weekly' as const,
|
||||
priority: FEDIMINT_PRIORITY,
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -365,6 +365,19 @@ main { flex: 1; }
|
||||
.status-chip.degraded .status-dot { background: var(--amber); box-shadow: 0 0 8px rgba(240, 169, 59, .7); }
|
||||
.status-chip.unknown { color: var(--muted); background: rgba(154, 146, 126, .07); border-color: rgba(154, 146, 126, .22); }
|
||||
.status-chip.unknown .status-dot { background: var(--muted); box-shadow: none; }
|
||||
/*
|
||||
* "Announced": a federation on Nostr that no check has ever confirmed.
|
||||
*
|
||||
* Grey and, uniquely, hollow. Every other state here is a filled dot with a glow,
|
||||
* because every other state is something this site found out; a ring with nothing in it
|
||||
* is the one that says the opposite. It must never be mistaken for the green one at a
|
||||
* glance, which is the whole reason it is not simply a paler mint.
|
||||
*/
|
||||
.status-chip.announced { color: var(--muted); background: rgba(154, 146, 126, .07); border-color: rgba(154, 146, 126, .22); }
|
||||
.status-chip.announced .status-dot {
|
||||
background: none; box-shadow: none;
|
||||
border: 2px solid var(--faint); box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------- panels ---------- */
|
||||
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); }
|
||||
@@ -461,6 +474,8 @@ main { flex: 1; }
|
||||
.mc-dot.offline { background: var(--red); }
|
||||
.mc-dot.degraded { background: var(--amber); }
|
||||
.mc-dot.unknown { background: var(--muted); }
|
||||
/* Hollow, for the same reason the chip's dot is. See `.status-chip.announced`. */
|
||||
.mc-dot.announced { background: none; border: 2px solid var(--faint); box-sizing: border-box; width: 7px; height: 7px; }
|
||||
/* Two words next to the status, in the same colours the banner uses. Cards never
|
||||
explain, they only flag: the mint page has the sentence. */
|
||||
.mc-chip {
|
||||
@@ -536,13 +551,24 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
/*
|
||||
* Below this the header stops being one row.
|
||||
*
|
||||
* Four nav items, the brand, the language control and the account control do not fit
|
||||
* across a phone, and a header that does not fit is a page that scrolls sideways: the
|
||||
* body inherits the overflow and every section under it drifts. So the nav takes a row
|
||||
* of its own and the two controls stay up top with the brand, which is also the order
|
||||
* they are wanted in: what page am I on, then who am I and what language is this.
|
||||
* Five nav items, the brand, the language control and the account control do not fit
|
||||
* across anything narrower than a laptop, and a header that does not fit is a page that
|
||||
* scrolls sideways: the body inherits the overflow and every section under it drifts. So
|
||||
* the nav takes a row of its own and the two controls stay up top with the brand, which
|
||||
* is also the order they are wanted in: what page am I on, then who am I and what
|
||||
* language is this.
|
||||
*
|
||||
* 1040px, not the 620px this used to be. The old figure was already two pixels short of
|
||||
* fitting four English items at 768, and five need 400px against the 303px the first row
|
||||
* has left there — no amount of tightening the padding closes a gap that size.
|
||||
*
|
||||
* The number is set by Spanish, which is the longest of the three ("Mints Cashu /
|
||||
* Fedimints / Reseñas / Monederos / Acerca de") and needs 1008px to fit on one row
|
||||
* against English's 906. Picking the English figure is how this shipped broken once
|
||||
* already: every locale renders the same stylesheet, so the breakpoint has to clear the
|
||||
* widest of them, with room for the next language to be longer still.
|
||||
*/
|
||||
@media (max-width: 620px) {
|
||||
@media (max-width: 1040px) {
|
||||
.topbar-inner { flex-wrap: wrap; row-gap: 10px; }
|
||||
/* Pushes the pair to the right of the first row, where they were before it wrapped. */
|
||||
.topbar-inner > .lang-menu { margin-left: auto; }
|
||||
@@ -553,6 +579,15 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
/* The row's own edges, so the first and last item line up with the brand and the
|
||||
account control above them rather than sitting inset by their padding. */
|
||||
margin-inline: -9px;
|
||||
/*
|
||||
* Wraps onto a second line rather than overflowing.
|
||||
*
|
||||
* Four items fitted one narrow row; "Cashu mints" and "Fedimints" together do not,
|
||||
* and a nav that overflows here is the sideways scroll this whole block exists to
|
||||
* prevent. Two tidy rows beat one row with a cut-off "About".
|
||||
*/
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
|
||||
Reference in New Issue
Block a user