22 Commits
Author SHA1 Message Date
michilisandClaude Fable 5.1 ae7664fbe0 Stop a listing page from rewriting the next one's grid after navigation.
Switching between /mints, /fedimints and /lnurl-mints showed the right list
for a moment and then flashed back to the previous ecosystem's mints. The
client router keeps every page's script alive, and onReady re-runs each
setup on every arrival, so a visited page's setup also ran on the next page.
All three grids were marked with the same bare data-mint-grid, so the stale
setup found the new grid, fetched its own type and overwrote it.

Mark and query each grid by ecosystem (data-mint-grid="cashu" etc.), the way
the home page already scopes data-home-grid, so a stale setup finds nothing
and bails. Skip the render in hydrateMintGrid when the grid has already been
detached by the router. Document the re-run-everywhere contract on onReady,
and add a static wiring test so a bare marker cannot come back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:36:26 +02:00
michilisandCursor 70b35f4ccc Show a brief publishing note that fades out after a few seconds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:42:00 +02:00
michilisandClaude Opus 5 1eade490c8 Order the latest-reviews strip by created_at alone.
The home page carousel floated reviews whose author had a kind 0 ahead of
everything else, capped at 90 days old, so a named review from two months ago
sat between two reviews from this week. Under a heading that says "Latest
reviews", with each card's foot printing the very timestamp being overruled,
that reads as broken rather than as curation.

Strict recency now, newest first, as the last thing that happens to the list,
with the event id breaking ties so two builds of the same events agree. Names
and avatars are still resolved and still shown; they no longer decide the
order.

The candidate scan stops at the limit rather than gathering twelve times it:
the scan already runs newest first, so nothing further down can outrank what
it has, and the build resolves ten profiles per locale instead of a hundred
and twenty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:23:22 +02:00
michilisandClaude Opus 5 860a4de009 Check in the dynamic-mint-data analysis it was already sitting on.
It was untracked in the working tree. web/src/lib/mint-cards.ts cites it for the
reasoning behind hydrating rather than moving the list to SSR, and a comment
pointing at a file nobody else has is worse than no comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:34:54 +02:00
michilisandClaude Opus 5 24fe2003b6 Drop the nightly rebuild timer.
The timer was load-bearing while the mint list was a build-time snapshot: a
rebuild was the only way a new mint, a new review count or a changed status ever
reached /mints. The list hydrates now, so all three arrive within a second of
load, in every language, and rebuilding 2,000 pages at 03:30 to refresh numbers
that refresh themselves is twenty minutes of CPU for nothing.

cashumints-web.service stays exactly as it is — it is the deploy-time publish
step, and now the only thing that starts it is a deploy. A build still produces
what only a build can: the prerendered HTML a crawler reads, a social card per
mint, the sitemap and hreflang set, and a /mint/{host} page for every mint known
at build time.

The one thing that gets staler is that last item. A mint indexed since the last
deploy has no prerendered page: /mint/newhost is a 404, whose resolver looks the
address up against the live API and renders it — readable, reviewable, noindex
until a deploy gives it a real page. That was already true between nightly
builds; this only lengthens the window.

README documents the one-time host commands to remove the installed timer, and
gains a "Live lists" section describing what replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:29:09 +02:00
michilisandClaude Opus 5 14548179a0 Hydrate the mint lists from the live API after paint.
/mints was a snapshot of whatever the API held when `astro build` ran, and stayed
that until the next build: a mint indexed at noon was reviewable at once — the 404
resolver saw to that — and simply had no card until 03:30. Every card's rating,
review count and status were as stale as the page.

The three index pages and the home page's three top-six strips now refetch
`GET /api/mints?type=…` once, after paint, and rebuild their grids. The
prerendered cards stay: they are the first paint, what a crawler indexes, and the
whole page without JavaScript. Hydration only ever replaces them with something
newer, and never with nothing — neither a failed fetch nor a well-formed empty
array touches a grid that has cards in it.

To make that affordable, the list payload grew the facts a chip is drawn from:
`nuts`, `capabilities`, and the two probed LNURL fields. /mints and /lnurl-mints
were fetching `GET /api/mints/:host` once per mint at build time to read two
booleans off each; that N+1 is gone from both, which takes the build from
fifty-six requests to one and is what makes the same read possible in a browser.
Additive: `MintDetail` already had all four.

web/src/lib/mint-cards.ts is MintCard.astro's parallel renderer, the same
relationship review-cards.ts has with the reviews panel. Same classes, same
data-* attributes — the sort, the search, the rank chips and the shared-element
view transitions all read the DOM — and the same i18n, through the page's own
inlined catalog rather than a build-time one.

Base.astro gained `clientNamespaces`, so the home page can inline the `home.`
catalog its strips need to rewrite "All 60 mints →" without putting 2KB of
marketing copy on 1,300 mint pages. check-i18n reads the prop off the page, so
the two cannot disagree.

Verified in Chromium against the built site: 60 prerendered cards become 61
including a mint inserted after the build; sort, search and hide-offline operate
on the new cards; /es/mints renders "En línea", "54 reseñas", "4,9" and "Solo
fundir"; JavaScript disabled still shows all 60; an aborted or empty API leaves
the grid alone; and a navigation away and back re-hydrates. 2016 pages build,
link and hreflang checks pass, 30 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:27:33 +02:00
michilisandClaude Opus 5 060c7f1a59 Refuse to publish a site built from a hollow index.
Health answering 200 and the index being complete are different claims. A year of
~31-event backfills left a perfectly healthy API serving a real, correct, complete
list of eight mints. A build against that succeeds — it prerenders eight cards —
and rsync --delete-after then replaces fifty-five with eight.

cashumints-web.service gains a second ExecStartPre after the health wait: count
/api/mints, and exit non-zero below MIN_MINTS_FOR_BUILD (default 20, overridable
with `systemctl edit`). A refusal aborts the unit before `pnpm build`, and
publishing is ExecStartPost, so the previously published site is untouched; the
OnFailure alert added in the last commit says why.

Counted by the "host": key rather than by counting braces, because the list
payload is about to carry a nested object per mint. A curl that fails at all
counts as zero, which is below every floor — so an API that fell over between the
health check and this line refuses the build instead of sailing through it.

Verified against three live APIs: 73 mints passes, a doctored 8-mint database
fails with the reason, and a dead port fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:13:16 +02:00
michilisandClaude Opus 5 0ebc8ada54 Make a crash loop reach somebody instead of scrolling past.
`Restart=on-failure` with no start limit is an infinite loop by definition: the
unit never reaches `failed`, `systemctl status` stays active (auto-restart), and
the only evidence is a journal moving at four lines a second. That is how 464
restarts over fifteen hours went unnoticed.

All three units now stop after five failures in 120s and run
OnFailure=cashumints-alert@%n.service. The window is 120s and not 60s because
RestartSec=5s plus a process that takes a few seconds to die can spread five
failures past a sixty second window, reset the counter, and loop forever anyway.

cashumints-alert@.service is a oneshot that takes the failed unit's name as its
instance. Configuration is /etc/cashumints/alert.env: NTFY_URL gets a plain-text
body, WEBHOOK_URL gets JSON carrying `content` so one payload fits Discord and
Slack-compatible endpoints. With neither set — or the file absent — it still
writes to the journal at ERROR via a `<3>` syslog prefix, so `journalctl -p err -t
cashumints-alert` is a complete history on a host nobody configured.

It cannot become a second thing to debug: each curl is bounded at 10s, each
failure falls back to a journal line, and the shell ends in `true`, so the alerter
always exits 0. Verified with systemd-analyze verify and by running the ExecStart
body against a local sink — the JSON parses, and every branch exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:10:01 +02:00
michilisandClaude Opus 5 9ffa53094d Make a starved discovery cycle say so, in the log and on /api/health.
For about a year the production RELAYS list did not include the relay carrying
the kind 38000/38172 archive. Every backfill read about thirty events, wrote them
faithfully, reported ok=true, and the nightly build republished an index of eight
mints. Nothing measured the difference between "the cycle completed" and "the
cycle read anything", so nothing went red.

Three signals now do:

  - Per-relay attribution. queryRelays() replaces pool.querySync(), which merges
    every relay into one deduplicated array and throws away who sent what. It
    keeps one subscription per relay over the pool's existing sockets and shares
    a single alreadyHaveEvent across them, so an event five relays carry is still
    verified once; receivedEvent fires before that check, which is what makes the
    per-relay count mean "what this relay contributed". The deadline moved out of
    each Subscription's own EOSE timer so `eose` means a frame arrived rather than
    something timed out.

  - A WARN naming any relay that will not connect, on every cycle, and any relay
    that connected and sent nothing, on backfills only. An incremental cycle is
    supposed to come back empty.

  - BACKFILL_MIN_EVENTS, default 200. Under it, ERROR discovery starvation
    suspected and a flag health reports as discovery_starved, forcing 503. Sticky
    across incremental cycles so an hourly cycle finding four events cannot clear
    what a backfill diagnosed; stored in the database so a restart cannot either.

A fresh database is starved until its first backfill lands. That is intended: it
holds the build's health gate rather than publishing a site made from nothing.

Verified against the live relay set — 1528 events, five relays connected, EOSE on
all five, health 200 — and against an unreachable list, which produces the two
WARN lines, the ERROR, and 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:07:01 +02:00
michilisandClaude Opus 5 65307ba278 Compile the API instead of running its TypeScript in production.
The unit's ExecStart named src/index.ts, so every start depended on the host
having Node 22.18 or newer for native type stripping. A deploy onto a host with
Node 20 met ERR_UNKNOWN_FILE_EXTENSION, exited in under a second, and was
restarted 464 times over fifteen hours with nothing anywhere going red.

api/tsconfig.json now emits to api/dist. The source keeps its explicit .ts import
specifiers, which is what makes `node --watch src/index.ts` work in development;
rewriteRelativeImportExtensions turns them into .js on the way out, so what runs
in production is ordinary ESM that any Node from 20.18 up will start.

`pnpm build` builds shared, then api, then web. `pnpm dev` is unchanged.

deploy/ is tracked rather than ignored: the unit files are the thing an operator
copies to /etc/systemd/system, and the alert unit added next has to live
somewhere a deploy can find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:58:53 +02:00
michilisandCursor 06ba3d35e7 Clarify the empty-WEB_ROOT hint to point at cashumints-web.
A bare pnpm build only fills dist; production needs the publish unit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:46:23 +02:00
michilisandCursor 79a115be38 Serve the prerendered site from Node instead of nginx root.
Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:27:27 +02:00
michilisandCursor 301679d340 Wire locale catalogs, repair mint terminology, and confirm login with a toast.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 22:35:43 +02:00
michilisandCursor b95aab2bcd Improve write-review flow and require Node 22.18 for native TS stripping.
Gate login, reveal the form after a rating, and wire inline Rate this mint; drop --experimental-strip-types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:52:11 +02:00
michilisandCursor c74c7fc187 Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:43 +02:00
michilisandCursor 2a9444942b Index, probe, and announce LNURL mints in the API.
Wire discovery and probing for LNURL mints, add rate-limited POST /api/index
for user submissions, and optionally announce confirmed state to relays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:35 +02:00
michilisandCursor c97b44018d Add shared LNURL types, indexing helpers, and warnings.
Introduce lnurl as a first-class mint type with probe/announcement fields
and shared helpers the API and web can both rely on.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:27 +02:00
michilisandCursor 36c01861f5 Document LNURL mint kind and live probe notes.
Capture the NIP kind contract and wire-level NOTES so indexing and probing
can follow observed LNURL mint behavior rather than outdated assumptions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:20 +02:00
michilisandCursor be322cb0d8 Add configurable Plausible analytics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 06:11:04 +02:00
michilisandCursor 47e6537dde Expand i18n locales and improve reviews UI
Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 05:06:58 +02:00
michilisandCursor 1c5df18e81 Improve SEO and social card wiring
Add SearchAction and optional Product JSON-LD, per-page OG images/alts, and document the og build and cache headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 02:17:35 +02:00
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00
171 changed files with 41380 additions and 2145 deletions
+74 -8
View File
@@ -38,16 +38,50 @@ 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.
# Plausible-compatible analytics script and the domain reported with each page view.
# Leave either value empty to disable analytics.
PLAUSIBLE_URL=https://analytics.azzamo.net/js/script.js
PLAUSIBLE_DOMAIN=cashumints.space
# SQLite file.
# 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
# ─── 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 ────────────────────────────────────────────────────────────
@@ -62,6 +96,19 @@ SITE_URL=https://cashumints.space
# so reviews the old site published to snort/primal were invisible to it.
RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net
# How many events a backfill has to read before it counts as having read anything.
#
# A backfill asks every relay above for the whole history of four kinds; on a working
# relay list that is thousands of events. Under this floor, discovery logs
# `ERROR discovery starvation suspected` and /api/health answers 503 with
# `discovery_starved: true` until the next backfill clears it.
#
# This exists because a RELAYS list missing the relay that carries the announcement
# archive returned about thirty events per backfill for a year, reported ok=true every
# time, and left the index at eight mints with every health signal green. Lower it only
# for a private or test relay that genuinely holds less; 1 disables the check.
#BACKFILL_MIN_EVENTS=200
# Profile relays for the BUILD (kind 0, prerendered reviewer names on the home
# page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind
# 0 at all and snort/primal hold almost none, so the two aggregators below are what
@@ -99,9 +146,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
@@ -109,6 +169,12 @@ PROBE_TIMEOUT_MS=5000
# which is the literal BACKEND.md behaviour and ranks noticeably worse.
SCORE_PRIOR_MEAN=3
# How many mints one address may submit to `POST /api/index` in an hour. Ten is several
# times what any honest use of the review dialog needs; raise it while exercising the
# whole flow end to end, which trips ten in about a minute. Behind a proxy, the limiter
# can only tell visitors apart if `X-Forwarded-For` is forwarded (see README, "API").
# INDEX_RATE_LIMIT=10
# ─── Tooling ─────────────────────────────────────────────────────────────────
# Dev server Boneyard captures against (`pnpm bones`). Defaults to WEB_PORT.
+4
View File
@@ -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/
+350
View File
@@ -0,0 +1,350 @@
# NOTES-LNURL.md
What the LNURL mint endpoints **actually** return, recorded from the live reference
instance `https://lnurl.21mint.me` on 2026-08-21 and from a locally run
[dni/lnurl-mint](https://github.com/dni/lnurl-mint) (commit `a70cae1`) used to
reproduce the states the live instance does not currently exhibit.
Same rule as NOTES.md: where the software's README and the wire disagree, the wire
wins, and the disagreement is written down rather than quietly resolved.
---
## 1. The probe endpoint: `GET /.well-known/lnurlw/{username}`
**This is the LNURL equivalent of Cashu's `/v1/info`, and it is not the endpoint the
brief expected.** Findings, in the order they mattered:
### `/p` does not exist
The brief (and older readings of the project) describe `GET /p` as the LUD-06
payRequest carrying the `withdrawLink` mint advertisement. It is a **404** on the live
instance:
```
$ curl -i https://lnurl.21mint.me/p
HTTP/2 404
content-type: application/json
{"detail":"Not Found"}
```
The repository's own README says so explicitly — the LUD-16 well-known alias is
"this mint's **only** payRequest entry point (no separate bare `/p`)" — and
`router.py` registers no such route. `/p/cb` (the callback) exists; bare `/p` never
did. Note the body shape: `{"detail":"Not Found"}` with a real 404 status is FastAPI's
*unmatched route* handler. Every **registered** route answers errors as LNURL does
instead (see §5), so this shape is a reliable "this software does not have that
endpoint" signal.
### Both well-known aliases answer, and only one carries the limits
| Endpoint | Carries |
| --- | --- |
| `GET /.well-known/lnurlp/{username}` | LUD-06 payRequest: `minSendable`/`maxSendable`, `metadata`, `withdrawLink` |
| `GET /.well-known/lnurlw/{username}` | LUD-03-shaped withdrawRequest: `minWithdrawable`/`maxWithdrawable`, `defaultDescription`, `mintPubkey`, `payLink`, node identity |
`{username}` is the configured `USERNAME` (default `mint`) **or** the LUD-16 reserved
bare-domain `_`. Both resolve to the identical mint identity; `_` is used as the probe
path because it needs no prior knowledge of the operator's chosen username.
**The chosen primary probe endpoint is `/.well-known/lnurlw/_`**, because it is the one
carrying the withdraw limits, the description, and the mint's node identity — every
field the site renders. `/.well-known/lnurlp/_` is fetched as a **fallback only**, when
the withdraw side does not answer.
### Live response, `/.well-known/lnurlw/_`
```json
{
"tag": "withdrawRequest",
"callback": "https://lnurl.21mint.me/w",
"minWithdrawable": 5000,
"maxWithdrawable": 999899000,
"defaultDescription": "lnurlcash bearer note on lnurl.21mint.me",
"mintPubkey": "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555",
"payLink": "https://lnurl.21mint.me/.well-known/lnurlp/mint",
"nodeAlias": "Azzamo",
"nodeUri": "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736",
"nodeColor": "#68f442",
"nodeCapacity": 30027500000,
"nodeNumChannels": 8,
"nodeNumPeers": 18
}
```
Headers: `server: nginx/1.22.1`, `content-type: application/json`. **No version header
of any kind**, on this or any other endpoint.
Notes on the fields:
- **All amounts are millisatoshi.** `minWithdrawable: 5000` is 5 sat;
`maxWithdrawable: 999899000` is 999,899 sat. `nodeCapacity` is msat too
(30,027,500 sat here). Divide by 1000 for display.
- `maxWithdrawable` is **fee-adjusted**: it is `MAX_SENDABLE_MSAT` minus the mint fee
at that amount (`router.max_mintable_msat`), not the raw setting. Likewise
`minWithdrawable` is `MIN_MINT_MSAT`. So these two numbers are the bounds a freshly
minted note's *value* can fall into — which is exactly what a reader wants — and not
the bounds of what they must *pay*. The payRequest's `minSendable`/`maxSendable` are
the pay-side numbers, and they differ (11,000 vs 10,000 msat locally, because of the
1000 msat base fee).
- `defaultDescription` is always `"lnurlcash bearer note on {host}"` — generated, never
operator-written. Useful as a description fallback and nothing more; it says what the
software is, not what this mint is.
- **`k1` is absent.** The model declares it `str | None = None`; its absence from the
wire is the first proof that `response_model_exclude_none = True` is in force
(`error_handler.py:26`). That matters for everything in §3.
### Live response, `/.well-known/lnurlp/_`
```json
{
"tag": "payRequest",
"callback": "https://lnurl.21mint.me/p/cb",
"minSendable": 6000,
"maxSendable": 1000000000,
"metadata": "[[\"text/plain\", \"Mint an lnurlcash bearer note on lnurl.21mint.me\"], [\"text/identifier\", \"_@lnurl.21mint.me\"], [\"text/plain\", \"Mint fees: 1000,100\"]]",
"withdrawLink": "https://lnurl.21mint.me/w"
}
```
- `metadata` is a **JSON-encoded string containing a JSON array** of `[mime, value]`
pairs — parse twice.
- `text/identifier` **echoes the username actually queried**. Querying `_` returns
`_@lnurl.21mint.me`; querying `mint` returns `mint@lnurl.21mint.me`. So the probe
cannot read the operator's real username off the `_` response. It reads it off
`payLink` on the withdraw side instead (`…/lnurlp/mint` → username `mint`), which is
built from `settings.username` unconditionally.
- The `Mint fees: <base_msat>,<ppm>` entry is present only when a fee is configured;
its absence means fee-free, per spec.
---
## 2. Every other endpoint, and what it is worth to a probe
| Endpoint | Live result | Verdict |
| --- | --- | --- |
| `GET /` | 200, 13,804 bytes of HTML, `<title>21 lnurl-mint</title>` | Fetched **only** for the onion address (§4) |
| `GET /openapi.json` | 200, `info.version` = `"0.1.0"`, `info.title` = `"lnurl-mint"` | **The only version source there is** (§6) |
| `GET /docs` | 200, Swagger UI | Ignored |
| `GET /w` (no `k1`) | 200 `{"status":"ERROR","reason":"Request validation error: Field required \`k1\`."}` | Ignored — never a mint advertisement |
| `GET /w?k1=deadbeef` | 200 `{"status":"ERROR","reason":"Unknown note."}` | Ignored — per-note, not per-mint |
| `GET /verify/{hash}` | 200 `{"status":"ERROR","reason":"Not found"}` | **Useless as a capability probe** (§5) |
| `GET /p` | 404 `{"detail":"Not Found"}` | Does not exist |
The site probes **`/.well-known/lnurlw/_`, then `/.well-known/lnurlp/_` on failure, and
`GET /openapi.json` + `GET /` opportunistically**. Nothing else is fetched, and nothing
that mutates state is ever called — `/p/cb` would make the mint issue a real invoice on
every probe cycle, which is not a thing a directory gets to do to a stranger's node.
---
## 3. The degraded state: no funding source (**empirically reproduced**)
The README: *"Without one, minting and melting are unavailable (rotate/split/merge of
existing notes still work)."* The question was whether that is visible over HTTP. It is.
`_mint_address_response` (`router.py:480`) only populates the node fields
`if funding_source.backend:`, and wraps the lookup in `try/except` that logs and leaves
them `None` on failure. With `response_model_exclude_none = True`, `None` fields are
**omitted from the wire entirely**.
Run locally with no `FUNDINGSOURCE_*` set at all:
```
$ curl http://127.0.0.1:8137/.well-known/lnurlw/_
{"tag":"withdrawRequest","callback":"https://lnurl.test/w","minWithdrawable":10000,
"maxWithdrawable":999999000,"defaultDescription":"lnurlcash bearer note on lnurl.test",
"payLink":"https://lnurl.test/.well-known/lnurlp/mint"}
```
`mintPubkey`, `nodeAlias`, `nodeUri`, `nodeColor`, `nodeCapacity`, `nodeNumChannels`
and `nodeNumPeers` are **all gone**. The response is otherwise a completely valid
withdrawRequest with real limits — the mint is up, serving, and answering. Its startup
log says the rest out loud:
```
WARNING:root:No funding source configured (FUNDINGSOURCE_BACKEND unset) -
minting, melting, and offline verification are all unavailable.
```
`/.well-known/lnurlp/_` **still answers normally** in this state (200, full payRequest),
so the pay side is *not* a degraded-state signal — only the withdraw side is.
### The detection rule, and the honest thing to say about it
> **`mintPubkey` absent from a valid mint-address response ⇒ no funding source is
> reachable ⇒ minting and melting are unavailable right now.**
One bit, and it necessarily conflates two causes:
1. no funding source was ever configured, and
2. one was configured but the node is unreachable at this moment (`except Exception`
above catches it and returns the same reduced response).
They are **not distinguishable over HTTP**, and the site does not try. It does not need
to: the user-facing consequence is identical in both cases — nothing moves in or out
over Lightning until it comes back — and that is exactly what the warning says. Calling
it "no funding source" specifically would be a guess; calling it "minting and melting
unavailable" is the observation.
The same bit also carries two other meanings, which is worth stating plainly because
three site features read it:
- it is the `signed-notes` capability (`signing.mint_pubkey` needs the same funding
source, and the README ties them: *"without a funding source, both fields are simply
omitted"*), and
- it is the canonical `d` identifier for the Nostr announcement (see
`docs/KIND-LNURL-MINT.md`).
That last one is why the `d` rule has to be sticky: a mint indexed while its node was
up must not silently change identity because its node had a bad minute. See the doc.
---
## 4. Tor / onion: HTML only, and only over clearnet
`ONION_URL` is **never** in any JSON response. It appears in exactly one place: the
one-pager's "Also via Tor" block (`frontend._tor_section`), and only when the request
did *not* arrive over the onion host itself (otherwise `public_base_url` already made
the onion the primary and the block would be a duplicate). A clearnet probe is
therefore the case where it does show.
Reproduced locally with `ONION_URL` set:
```html
<h2>Also via Tor</h2>
<div class="qr">…</div>
<button class="copy" data-copy="LNURL1…" title="Copy LNURL">LNURL1…</button>
<button class="copy" data-copy="mint@abcdefgh….onion" title="Copy lightning address">⚡ mint@…onion</button>
```
Cheaply parseable: scan the `GET /` body for a `*.onion` host. The live instance
advertises none, so `onion` is absent there.
**Caveat worth recording:** the fixture address in lnurl-mint's own tests
(`abcdefghijklmnop1234567890abcdefghijklmnop1234567890abcdefgh.onion`) contains
`0`, `1`, `8` and `9`, which are **not in base32's alphabet** — it is not a valid v3
address. A strict `[a-z2-7]{56}` matcher rejects it. The site's matcher is deliberately
looser (`[a-z0-9]{16,60}\.onion`) so that a real-but-unusual address is still shown;
nothing is fetched over Tor, so a wrong match costs a displayed string and nothing more.
---
## 5. LUD-21 verify is **not** probe-detectable (and why the site does not pretend)
`VERIFY_ENABLED=false` makes `/verify/{payment_hash}` raise `HTTPException(404, "Not
found")`. An *enabled* endpoint asked about a payment hash it has never seen raises
`HTTPException(404, "Not found")` too. Both then pass through
`LnurlErrorResponseHandler`, which converts any `HTTPException` into
`200 {"status": "ERROR", "reason": <detail>}`.
Measured, same binary, both settings:
```
VERIFY_ENABLED=true → 200 {"status":"ERROR","reason":"Not found"}
VERIFY_ENABLED=false → 200 {"status":"ERROR","reason":"Not found"}
```
**Byte-identical.** There is no status code, no header, and no reason string that
separates them.
The only place a `verify` URL is genuinely advertised is the response of `/p/cb` — the
pay callback — and calling that **creates a real Lightning invoice on the operator's
node**. Doing that every ten minutes, forever, to every mint in the index, is not
acceptable behaviour for a directory.
**Resolution:** `lud21` is an **announcement-only** capability. The probe never sets it
and never clears it. If an operator declares it in their `features` tag, the site shows
it; otherwise the row is simply absent. This is written into
`docs/KIND-LNURL-MINT.md` as a normative property of the vocabulary, not left as an
implementation quirk.
---
## 6. Version: `/openapi.json`, and only there
No `Server`, `X-Powered-By` or any other header identifies the software — nginx fronts
it and reports only itself. The one-pager's `<title>` is operator-configurable
(`settings.title`; the live instance sets `"21 lnurl-mint"`), so it identifies nothing
reliably.
FastAPI's generated `GET /openapi.json` carries both:
```json
{"info": {"title": "lnurl-mint",
"description": "Minimal lnurlcash (LUD-25, Lightning bearer assets) mint - LUD-03/LUD-06 only.",
"version": "0.1.0"}}
```
- `info.title` = `"lnurl-mint"` — a genuine software fingerprint, hardcoded in
`server.py`, not operator-settable.
- `info.version` = `__version__`, resolved from installed package metadata
(`lnurl_mint/__init__.py`), falling back to `LNURL_MINT_VERSION` or
`"0.0.0+unknown"`.
Two observed values: the live instance reports `0.1.0`; a source checkout with no
installed metadata reports `0.0.0+unknown`. The site stores `lnurl-mint/<version>`,
and treats a `0.0.0+unknown` as **no version** rather than displaying it — it is the
library's "I don't know" sentinel, not a release.
The live instance still serves `/docs` from a jsdelivr CDN, whereas HEAD (`a70cae1`,
*"report real version in openapi, vendor swagger ui for /docs"*) vendors it. So the
deployed build predates that commit, and its `0.1.0` is the pre-commit hardcoded value.
Recorded because it means **a version string from this endpoint is a weak signal**: it
was not always wired to the real package version.
---
## 7. Error convention, and what "online" must therefore mean
Every **registered** route returns HTTP **200** for its errors, with an LNURL body:
```json
{"status": "ERROR", "reason": "…"}
```
Only an **unregistered path** gives a real 404 (`{"detail":"Not Found"}`).
Consequence, and it is the single most important parsing rule here: **HTTP 200 does not
mean the mint is up.** A probe that stopped at the status code would call every
misconfigured host, every `Unknown user.`, and every wrong-path hit "online".
So the site's online test is on the parsed body, never the status:
- `tag == "withdrawRequest"` **and** numeric `minWithdrawable`/`maxWithdrawable` → online
(or degraded, per §3);
- `tag == "payRequest"` with numeric `minSendable`/`maxSendable` (fallback endpoint) → online;
- anything else that parses as JSON — including `{"status":"ERROR"}` — → **invalid**, a
distinct third outcome that is neither online nor offline. It renders the
"Endpoint responding but invalid" banner, because a host that answers with something
that is not a mint advertisement is a different problem from a host that does not
answer at all.
---
## 8. Summary of what the endpoints expose beyond the withdraw params
Everything below is real, observed, and stored in `ecosystem_json`:
| Field | Source | Used for |
| --- | --- | --- |
| `mintPubkey` | lnurlw | `d` identifier, `signed-notes` feature, degraded detection, sidebar row |
| `payLink` | lnurlw | Deriving the LUD-16 lightning address (`{username}@{host}`) |
| `nodeAlias` | lnurlw | Identity header fallback name |
| `nodeUri` | lnurlw | `pubkey@host:port` — the sidebar's node connect string |
| `nodeColor` | lnurlw | Not rendered (a node's own colour is not this site's palette) |
| `nodeCapacity` | lnurlw | msat, publicly announced channel capacity — sidebar |
| `nodeNumChannels` / `nodeNumPeers` | lnurlw | Sidebar |
| `defaultDescription` | lnurlw | Description fallback |
| `minSendable` / `maxSendable` | lnurlp | Pay-side limits, distinct from withdraw limits |
| `metadata` → `text/plain` | lnurlp | Description fallback, ranked above `defaultDescription` |
| `metadata` → `text/identifier` | lnurlp | Confirms the lightning address |
| `metadata` → `Mint fees:` | lnurlp | Mint fee disclosure (base msat, ppm) |
| `info.version` / `info.title` | openapi | Software cell |
| `*.onion` | `GET /` HTML | `onion` feature, sidebar row |
`nodeCapacity` is explicitly the **publicly announced** capacity only — lnurl-mint
sources it from the public graph (`lnd GetNodeInfo` / `cln listchannels`), never from a
private `listfunds`/`ListChannels` view. Displaying it discloses nothing the node's own
gossip does not already.
+32
View File
@@ -130,6 +130,38 @@ are matched by bare domain (too loose, a review of `mint.minibits.cash/Bitcoin`
`mint.minibits.cash/Anything`). The new backend fixes both directions: one normalizer, path is part
of identity, applied to mints and reviews alike (see `api/src/normalize.ts`).
### The slug is deterministic, not reversible
Found while building on-demand indexing (`POST /api/index`), which is the first thing that
has to turn a *page address* back into a *mint address* rather than the other way round.
`mint.example.com/Bitcoin` slugs to `mint.example.com-bitcoin`, and so would a mint whose
hostname really is `mint.example.com-bitcoin`. Looking a row up is unaffected — both
spellings are stored, and the slug column is what the lookup uses — but *indexing* from a
slug means opening a socket to whatever it decodes to, and guessing wrong there means
probing, and possibly listing, somebody else's server.
So `addressFromSlug` (shared/src/indexing.ts) only derives an address from the one
unambiguous shape: a plain hostname, optionally with the `-3338` port suffix. Anything
else returns null and the 404 page says it cannot work the address out from the link
alone, offering the dialog where a reader can paste the full URL including its path.
The `lnurl-` collision prefix is deliberately *not* stripped there either: a slug only
takes that prefix at insert time, so a link carrying one describes a row that already
exists and never reaches that code, while a hostname that merely begins `lnurl-` is a
real possibility.
Two URL shapes that fought the normalizer while testing this, both now covered by
`api/src/check-index.ts`:
- **The port suffix round trip.** `https://smilemoji.cash:3338` slugs to
`smilemoji.cash-3338`, which reads as a hostname with a trailing number until you know
the rule. The derivation has to put the colon back, and only for a 2 to 5 digit tail.
- **Case, scheme and trailing slash together.** `MINT.600.WTF`, `http://mint.600.wtf`,
`mint.600.wtf/` and `https://mint.600.wtf:443/` are one mint, and the endpoint has to
answer "already indexed" for all four rather than creating a second row. They collapse
in `normalizeMintUrl`, before the row is looked up and before the in-flight map is
keyed, which is what makes the dedup and the lookup agree.
## Old scoring
`src/hooks/usePopularMints.ts`:
+1066 -148
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -4,24 +4,30 @@
"private": true,
"type": "module",
"scripts": {
"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",
"build": "tsc -p tsconfig.json",
"dev": "node --env-file-if-exists=../.env --watch src/index.ts",
"start": "node --env-file-if-exists=../.env dist/index.js",
"seed": "node --env-file-if-exists=../.env src/seed.ts",
"migrate": "node --env-file-if-exists=../.env 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",
"test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts"
"test": "node --env-file-if-exists=../.env src/check.ts",
"test:warnings": "node --env-file-if-exists=../.env src/check-warnings.ts",
"test:offline": "node --env-file-if-exists=../.env src/check-offline.ts",
"test:lnurl": "node --env-file-if-exists=../.env src/check-lnurl.ts",
"test:index": "node src/check-index.ts"
},
"dependencies": {
"@cashumints/shared": "workspace:*",
"@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"
}
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Publishing `kind:38174` announcements for LNURL mints this indexer has confirmed.
*
* **This writes to public relays.** Nostr has no delete that anyone is obliged to
* honour, so an event published here is on the network permanently, signed by this
* site's key and attributed to it. That is the whole reason for the gating below, and
* the reason it is two switches rather than one.
*
* Why publish at all: 38174 is a proposed kind (see `docs/KIND-LNURL-MINT.md`) and a
* proposed kind with no events is a document rather than a protocol. An indexer that
* has already probed a mint and confirmed what it serves is the one party in a position
* to put honest announcements on the network before any operator has heard of the kind.
*
* ## What is announced, and what deliberately is not
*
* Only what the probe actually **observed**. The vocabulary has eleven values; this
* publishes at most seven of them, and the four it never publishes are the point:
*
* - `rotate`, `split`, `merge` — these are only provable by calling `/w/cb`, which
* mutates or destroys a note. Not probeable, not published, even though the one
* implementation in existence supports all three unconditionally. Inferring them
* from a version string would be publishing a guess under this site's signature.
* - `lud21` — genuinely undetectable: verify-disabled and unknown-payment-hash return
* byte-identical responses. See NOTES-LNURL.md §5.
*
* An operator's own announcement can and should claim more; theirs is a statement about
* what they built, this is a statement about what was seen. When both exist the newer
* `created_at` wins, which is normal addressable-event behaviour and means an operator
* takes over their own listing simply by publishing one.
*/
import { finalizeEvent, getPublicKey, type EventTemplate } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import * as nip19 from 'nostr-tools/nip19';
import {
KIND_LNURL_ANNOUNCEMENT, lnurlIdentifier, observedFeatures, type LnurlFields,
} from '@cashumints/shared';
import { announceConfig } from './config.ts';
import { getDb, getState, setState } from './db.ts';
import { log } from './log.ts';
import { parseEcosystem, type MintRow } from './mints.ts';
/**
* Republish interval.
*
* An addressable event is replaced, not duplicated, so republishing is cheap — but it
* is still traffic to somebody else's relay and a new `created_at` on every cycle would
* make this site's announcement permanently outrank an operator's own. Content changes
* are published immediately; an unchanged announcement is refreshed once a week, which
* keeps it from ageing out of relays that prune.
*/
const REPUBLISH_AFTER_S = 7 * 24 * 60 * 60;
/** State-table key holding what was last published for one mint, and when. */
const stateKey = (url: string): string => `announce:${url}`;
/**
* The secret key to sign with, as 32 bytes.
*
* Accepts an `nsec1…` or bare hex. Returns null — with a loud log line rather than a
* throw — for anything else: a misconfigured key must stop announcements and must not
* stop the indexer, which has a directory to serve either way.
*/
export function parseAnnounceKey(raw: string): Uint8Array | null {
const value = raw.trim();
if (!value) return null;
if (value.startsWith('nsec1')) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'nsec') return decoded.data;
} catch {
return null;
}
return null;
}
if (!/^[0-9a-f]{64}$/i.test(value)) return null;
return Uint8Array.from(Buffer.from(value, 'hex'));
}
/**
* The `features` this site is willing to sign for, from one mint's stored probe.
*
* Thin, and deliberately: the rule about what a probe may claim lives in
* `observedFeatures` in shared/, next to the vocabulary it draws from, so the list the
* page renders and the list this signs cannot drift apart. See that function for why
* `rotate`, `split`, `merge` and `lud21` are never among them.
*/
export function announcedFeatures(fields: LnurlFields): string[] {
return observedFeatures({
fundingAvailable: fields.funding_available,
maxWithdrawableMsat: fields.max_withdrawable_msat,
maxSendableMsat: fields.max_sendable_msat,
lightningAddress: fields.lightning_address,
mintPubkey: fields.mint_pubkey,
onionUrl: fields.onion_url,
});
}
/** The event this site would publish for one confirmed LNURL mint. */
export function announcementTemplate(
row: MintRow,
fields: LnurlFields,
createdAt: number,
): EventTemplate {
const tags: string[][] = [
['d', lnurlIdentifier(fields.base_url, fields.mint_pubkey)],
['u', fields.base_url],
];
const features = announcedFeatures(fields);
if (features.length > 0) tags.push(['features', features.join(',')]);
// Only when the mint said so. Absent reads as mainnet per the kind document, and
// asserting mainnet on a mint that never claimed a network would be this site
// inventing the one fact that decides whether the money is real.
if (fields.network) tags.push(['n', fields.network]);
/*
* `content` is left empty unless the mint published a name of its own.
*
* Empty content means "use the publisher's kind 0", per NIP-87 and the kind document
* — and the publisher here is this site, whose kind 0 is this site. That is the
* honest default for an announcement this site wrote: it is not the mint speaking.
* A name the mint itself served (its node alias) is worth passing on, and nothing else
* from the row is, because everything else came from a previous announcement.
*/
const content = row.name ? JSON.stringify({ name: row.name }) : '';
return { kind: KIND_LNURL_ANNOUNCEMENT, created_at: createdAt, tags, content };
}
/** Stable fingerprint of an announcement's meaning, for the change detector. */
function fingerprint(template: EventTemplate): string {
return JSON.stringify([template.tags, template.content]);
}
/**
* Which rows are eligible.
*
* Confirmed by probing, and only that: `status = 'online'` with a base URL and a
* withdraw ceiling means an advertisement genuinely parsed at some point. A mint that
* has never answered, is offline, or is only "responding but invalid" is never
* announced — this site does not put its signature on the existence of something it has
* not seen.
*
* A `degraded-funding` mint **is** announced: it is up and serving, and the reduced
* `features` list already tells the whole story.
*/
function eligible(row: MintRow, fields: LnurlFields | null): fields is LnurlFields {
if (row.type !== 'lnurl' || row.status !== 'online') return false;
if (!fields?.base_url) return false;
if (fields.invalid_reason) return false;
return fields.max_withdrawable_msat !== null;
}
export interface AnnounceResult {
eligible: number;
published: number;
skipped: number;
failed: number;
}
/**
* Publish an announcement for every confirmed LNURL mint that needs one.
*
* Returns counts rather than throwing: a relay refusing an event is not a reason for a
* probe cycle to fail. Does nothing at all, and says why once, when announcing is off —
* which is the default and is how every deployment that has not opted in behaves.
*/
export async function announceLnurlMints(
now = Math.floor(Date.now() / 1000),
): Promise<AnnounceResult> {
const empty: AnnounceResult = { eligible: 0, published: 0, skipped: 0, failed: 0 };
const settings = announceConfig();
if (!settings) return empty;
const secret = parseAnnounceKey(settings.secretKey);
if (!secret) {
log.error('announce disabled: ANNOUNCE_KEY is not an nsec or 64 hex characters');
return empty;
}
const db = await getDb();
const rows = await db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
const pool = new SimplePool();
const result: AnnounceResult = { ...empty };
try {
for (const row of rows) {
const fields = parseEcosystem<LnurlFields>(row);
if (!eligible(row, fields)) continue;
result.eligible++;
const template = announcementTemplate(row, fields, now);
const mark = fingerprint(template);
const previous = await getState(stateKey(row.url));
if (previous) {
try {
const { mark: lastMark, at } = JSON.parse(previous) as { mark: string; at: number };
if (lastMark === mark && now - at < REPUBLISH_AFTER_S) {
result.skipped++;
continue;
}
} catch {
// Unreadable marker: republish, which is the safe direction.
}
}
const event = finalizeEvent(template, secret);
/*
* `Promise.allSettled`, not `Promise.all`: one relay refusing the event (rate
* limits, a paid-relay policy, a write it does not accept) must not stop the
* others from taking it. One acceptance is a successful publish.
*/
const outcomes = await Promise.allSettled(pool.publish(settings.relays, event));
const accepted = outcomes.filter((o) => o.status === 'fulfilled').length;
if (accepted === 0) {
result.failed++;
log.warn('announcement rejected by every relay', {
url: row.url,
d: template.tags[0]?.[1],
relays: settings.relays.length,
});
continue;
}
await setState(stateKey(row.url), JSON.stringify({ mark, at: now }));
result.published++;
log.info('announced lnurl mint', {
url: row.url,
d: template.tags[0]?.[1],
features: template.tags.find((t) => t[0] === 'features')?.[1] ?? '',
relays: `${accepted}/${settings.relays.length}`,
event: event.id,
});
}
} finally {
try {
pool.close(settings.relays);
} catch {
// A relay that is already gone throws on close. Nothing to do about it.
}
}
if (result.eligible > 0) {
log.info('announce cycle', { ...result, pubkey: getPublicKey(secret) });
}
return result;
}
+474
View File
@@ -0,0 +1,474 @@
/**
* pnpm --filter ./api test:index
*
* The checks for on-demand indexing (`POST /api/index`). Same shape as `check.ts`: no
* framework, throws on the first failure, prints a count.
*
* Three things are being defended here, and they are in descending order of how bad it
* would be to get them wrong:
*
* 1. **SSRF.** This is the one endpoint that fetches an address a stranger chose, so
* every refusal it makes is asserted against a resolver and a fetch that this file
* controls. Nothing here touches the network: a rule that can only be exercised by
* pointing the test at a real host is a rule that stops being exercised the first
* time CI runs offline.
* 2. **Slug collapse.** Two spellings of one mint must never become two rows with half
* its reviews on each. That is the bug the normalizer was written for, and this
* endpoint is a new way to reintroduce it — a reader can now type the spelling
* discovery never saw.
* 3. **Invite decoding**, which is what makes a Fedimint submission possible at all.
*/
import assert from 'node:assert/strict';
import {
checkIndexInput,
federationIdFromInviteCode,
isNut06Info,
isPrivateIpAddress,
lnurlKey,
normalizeMintUrl,
typeForPath,
} from '@cashumints/shared';
import { checkDestination, safeFetchText, type FetchDeps } from './safe-fetch.ts';
import { RATE_LIMIT, resetRateLimits, takeToken } from './rate-limit.ts';
/** A real code off the relay pool, the same one `check.ts` parses an announcement from. */
const REAL_INVITE =
'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' +
'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg';
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
async function checkAsync(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
/* ---------- address rules ---------- */
check('every private, loopback and link-local range is refused', () => {
for (const address of [
'127.0.0.1', '127.1.2.3', '10.0.0.1', '10.255.255.255',
'172.16.0.1', '172.20.10.5', '172.31.255.255',
'192.168.0.1', '192.168.1.5',
'169.254.169.254', // the cloud metadata endpoint, the reason this exists
'0.0.0.0', '100.64.0.1', // this-network and carrier-grade NAT
'224.0.0.1', '255.255.255.255',
'::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1',
]) {
assert.equal(isPrivateIpAddress(address), true, `${address} must be refused`);
}
});
check('ordinary public addresses are not', () => {
for (const address of ['1.1.1.1', '8.8.8.8', '157.245.26.63', '172.15.0.1', '172.32.0.1', '2606:4700::1111']) {
assert.equal(isPrivateIpAddress(address), false, `${address} must be allowed`);
}
});
await checkAsync('a URL whose hostname is a private literal never reaches DNS', async () => {
// If any of these consulted the resolver, this one would throw rather than answer.
const explode: FetchDeps = {
resolve: () => {
throw new Error('a literal address must not be resolved');
},
};
for (const url of [
'https://127.0.0.1/v1/info',
'https://10.0.0.1/v1/info',
'https://172.16.4.4/v1/info',
'https://192.168.1.5/v1/info',
'https://169.254.169.254/latest/meta-data/',
'https://[::1]/v1/info',
'https://localhost/v1/info',
'https://mint.local/v1/info',
'https://abcdefghij234567.onion/v1/info',
]) {
const verdict = await checkDestination(new URL(url), explode);
assert.equal(verdict?.kind, 'blocked', `${url} must be refused`);
}
});
await checkAsync('a public-looking name that resolves privately is refused', async () => {
const deps: FetchDeps = { resolve: async () => ['10.0.0.5'] };
const verdict = await checkDestination(new URL('https://internal.example.com'), deps);
assert.equal(verdict?.kind, 'blocked');
// One private answer among several is enough: the socket would pick one of them.
const mixed: FetchDeps = { resolve: async () => ['93.184.216.34', '127.0.0.1'] };
assert.equal((await checkDestination(new URL('https://mixed.example.com'), mixed))?.kind, 'blocked');
const public_: FetchDeps = { resolve: async () => ['93.184.216.34'] };
assert.equal(await checkDestination(new URL('https://mint.example.com'), public_), null);
});
await checkAsync('a name that does not resolve is unresolved, not blocked', async () => {
// The distinction the rugged-mint case turns on: a mint whose operator let the domain
// lapse must reach the Nostr lookup, not be rejected as an inadmissible address.
const gone: FetchDeps = { resolve: async () => null };
const verdict = await checkDestination(new URL('https://gone.example.com'), gone);
assert.equal(verdict?.kind, 'unresolved');
const outcome = await safeFetchText(
'https://gone.example.com/v1/info',
{ accept: 'application/json' },
{ ...gone, fetchImpl: (() => { throw new Error('must not connect'); }) as unknown as typeof fetch },
);
assert.equal(outcome.state, 'unreachable');
});
await checkAsync('http is refused outright: this endpoint is https only', async () => {
assert.equal((await checkDestination(new URL('http://mint.example.com')))?.kind, 'blocked');
assert.equal((await checkDestination(new URL('ftp://mint.example.com')))?.kind, 'blocked');
});
/* ---------- redirects ---------- */
/** A fetch that answers from a table, and records every URL it was asked for. */
function scriptedFetch(routes: Record<string, Response>): { fetch: typeof fetch; seen: string[] } {
const seen: string[] = [];
const impl = (async (input: unknown): Promise<Response> => {
const url = String(input);
seen.push(url);
const res = routes[url];
if (!res) throw new Error(`unexpected fetch of ${url}`);
return res;
}) as typeof fetch;
return { fetch: impl, seen };
}
await checkAsync('a redirect to a private address is refused before it is fetched', async () => {
const { fetch: impl, seen } = scriptedFetch({
'https://mint.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://169.254.169.254/latest/meta-data/' },
}),
});
const outcome = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{ fetchImpl: impl, resolve: async () => ['93.184.216.34'] },
);
assert.equal(outcome.state, 'blocked');
assert.match(outcome.state === 'blocked' ? outcome.reason : '', /redirected/);
// The crux: the metadata endpoint was never connected to, only reasoned about.
assert.deepEqual(seen, ['https://mint.example.com/v1/info']);
});
await checkAsync('a redirect to a name that resolves privately is refused too', async () => {
const { fetch: impl, seen } = scriptedFetch({
'https://mint.example.com/v1/info': new Response(null, {
status: 301,
headers: { location: 'https://internal.example.com/v1/info' },
}),
});
const outcome = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{
fetchImpl: impl,
resolve: async (host) => (host === 'mint.example.com' ? ['93.184.216.34'] : ['10.1.2.3']),
},
);
assert.equal(outcome.state, 'blocked');
assert.equal(seen.length, 1, 'the private hop must never be fetched');
});
await checkAsync('two redirects are followed, a third is not', async () => {
const ok = { 'content-type': 'application/json' };
const routes: Record<string, Response> = {
'https://a.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://b.example.com/v1/info' },
}),
'https://b.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://c.example.com/v1/info' },
}),
'https://c.example.com/v1/info': new Response('{"name":"ok"}', { headers: ok }),
};
const deps = { resolve: async () => ['93.184.216.34'] };
const two = await safeFetchText(
'https://a.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: scriptedFetch(routes).fetch },
);
assert.equal(two.state, 'ok', 'two hops are within the cap');
const deeper = {
...routes,
'https://c.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://d.example.com/v1/info' },
}),
};
const three = scriptedFetch(deeper);
const over = await safeFetchText(
'https://a.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: three.fetch },
);
assert.equal(over.state, 'unreachable');
assert.equal(three.seen.length, 3, 'the fourth address is never fetched');
});
await checkAsync('a body over the cap and a body of the wrong type are both refused', async () => {
const deps = { resolve: async () => ['93.184.216.34'] };
const big = scriptedFetch({
'https://mint.example.com/v1/info': new Response('x'.repeat(2048), {
headers: { 'content-type': 'application/json' },
}),
});
const capped = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json', maxBytes: 512 },
{ ...deps, fetchImpl: big.fetch },
);
assert.equal(capped.state, 'unreachable');
const image = scriptedFetch({
'https://mint.example.com/v1/info': new Response('\x89PNG', {
headers: { 'content-type': 'image/png' },
}),
});
const wrongType = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: image.fetch },
);
assert.equal(wrongType.state, 'unreachable');
assert.match(wrongType.state === 'unreachable' ? wrongType.reason : '', /content type/);
});
/* ---------- slug collapse ---------- */
check('every spelling of one mint collapses to one row key', () => {
const spellings = [
'https://mint.600.wtf',
'mint.600.wtf',
'mint.600.wtf/',
'https://mint.600.wtf/',
'https://MINT.600.WTF',
'http://mint.600.wtf',
'https://mint.600.wtf:443/',
' https://mint.600.wtf/ ',
].map((raw) => normalizeMintUrl(raw));
const urls = new Set(spellings.map((s) => s?.url));
const hosts = new Set(spellings.map((s) => s?.host));
assert.equal(urls.size, 1, `one canonical URL, got ${[...urls].join(', ')}`);
assert.equal(hosts.size, 1, `one routing slug, got ${[...hosts].join(', ')}`);
assert.equal([...urls][0], 'https://mint.600.wtf');
assert.equal([...hosts][0], 'mint.600.wtf');
// And the LNURL row key derived from it is one value too, since that is what the
// endpoint actually looks the row up by.
assert.equal(new Set(spellings.map((s) => lnurlKey(s!.url))).size, 1);
});
check('a path is still part of a mint identity, and still collapses per path', () => {
const withPath = ['https://mint.example.com/Bitcoin', 'mint.example.com/Bitcoin/'].map((raw) =>
normalizeMintUrl(raw),
);
assert.equal(new Set(withPath.map((s) => s?.url)).size, 1);
assert.notEqual(withPath[0]?.url, normalizeMintUrl('https://mint.example.com')?.url);
});
/* ---------- what the browser checks before it asks ---------- */
check('the client-side pre-check accepts what the normalizer accepts', () => {
assert.deepEqual(checkIndexInput('lnurl', 'mint.600.wtf'), {
ok: true,
value: 'https://mint.600.wtf',
});
assert.deepEqual(checkIndexInput('cashu', ' https://21mint.me/ '), {
ok: true,
value: 'https://21mint.me',
});
assert.deepEqual(checkIndexInput('cashu', ''), { ok: false, reason: 'empty' });
assert.deepEqual(checkIndexInput('cashu', 'not a url at all'), { ok: false, reason: 'bad_url' });
// A private address fails the pre-check for the same reason the server refuses it.
assert.deepEqual(checkIndexInput('cashu', 'http://127.0.0.1:3338'), {
ok: false,
reason: 'bad_url',
});
});
check('an invite code pasted into a URL field is named as an invite code', () => {
const code = REAL_INVITE;
assert.deepEqual(checkIndexInput('cashu', code), { ok: false, reason: 'bad_invite' });
assert.deepEqual(checkIndexInput('lnurl', code), { ok: false, reason: 'bad_invite' });
assert.deepEqual(checkIndexInput('fedimint', code), { ok: true, value: code });
assert.deepEqual(checkIndexInput('fedimint', 'https://mint.example.com'), {
ok: false,
reason: 'bad_invite',
});
});
/* ---------- invite codes ---------- */
check('a real invite code yields the federation id its announcement carries', () => {
// The `d` tag of the announcement this code came in: decoding must agree with it, or
// a federation submitted by code would get a second row beside the announced one.
assert.equal(
federationIdFromInviteCode(REAL_INVITE),
'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21',
);
assert.equal(federationIdFromInviteCode(REAL_INVITE.toUpperCase()), federationIdFromInviteCode(REAL_INVITE));
});
check('anything that is not an invite code decodes to nothing', () => {
for (const junk of [
'',
'fed1',
'fed11',
'https://mint.example.com',
`${REAL_INVITE}x`, // checksum fails
REAL_INVITE.slice(0, -1), // truncated
REAL_INVITE.replace('fed11q', 'fed11p'), // one flipped character
'lnbc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', // valid-ish bech32, wrong hrp
]) {
assert.equal(federationIdFromInviteCode(junk), null, `${junk.slice(0, 24)} must not decode`);
}
});
/* ---------- what counts as a mint ---------- */
check('a NUT-06 document is told apart from an arbitrary JSON endpoint', () => {
assert.ok(isNut06Info({ nuts: { '4': { methods: [] } } }));
assert.ok(isNut06Info({ pubkey: '03c21ef6'.padEnd(66, 'a'), name: 'x' }));
assert.ok(isNut06Info({ name: 'Some mint', version: 'cdk-mintd/0.17.5' }));
// The shapes a host that is not a mint actually answers with.
assert.ok(!isNut06Info({ status: 'ok' }));
assert.ok(!isNut06Info({ detail: 'Not Found' }));
assert.ok(!isNut06Info({ tag: 'withdrawRequest', minWithdrawable: 1 }));
assert.ok(!isNut06Info([]));
assert.ok(!isNut06Info('hello'));
assert.ok(!isNut06Info(null));
assert.ok(!isNut06Info({ nuts: {} }), 'an empty nuts object proves nothing');
});
/* ---------- rate limiting ---------- */
check('the eleventh submission in an hour is refused, and refusals do not extend it', () => {
resetRateLimits();
const now = 1_800_000_000_000;
for (let i = 0; i < RATE_LIMIT; i++) {
assert.equal(takeToken('1.2.3.4', now + i).ok, true, `submission ${i + 1} must be allowed`);
}
const refused = takeToken('1.2.3.4', now + RATE_LIMIT);
assert.equal(refused.ok, false);
assert.ok(refused.retryAfter > 0 && refused.retryAfter <= 3600);
// Pressing the button again must not push the window out.
const again = takeToken('1.2.3.4', now + RATE_LIMIT + 1000);
assert.ok(again.retryAfter <= refused.retryAfter, 'a refusal must not extend the wait');
// A different address has its own budget.
assert.equal(takeToken('5.6.7.8', now + RATE_LIMIT).ok, true);
// An hour later the window has slid past the first submission.
assert.equal(takeToken('1.2.3.4', now + 3_600_001).ok, true);
resetRateLimits();
});
/* ---------- the deep-link routes ---------- */
check('every deep link shape maps to the ecosystem that owns it', () => {
assert.deepEqual(typeForPath('/mint/mint.600.wtf'), { type: 'cashu', host: 'mint.600.wtf' });
assert.deepEqual(typeForPath('/lnurl-mint/mint.600.wtf'), { type: 'lnurl', host: 'mint.600.wtf' });
assert.deepEqual(typeForPath('/fedimint/fed-aeca6cc80ffc530b'), {
type: 'fedimint',
host: 'fed-aeca6cc80ffc530b',
});
// A trailing slash is the same page; anything else is not a mint page at all.
assert.deepEqual(typeForPath('/mint/x.example.com/'), { type: 'cashu', host: 'x.example.com' });
assert.equal(typeForPath('/mints'), null);
assert.equal(typeForPath('/'), null);
assert.equal(typeForPath('/mint/'), null);
});
/* ---------- one submission, one row ---------- */
/*
* The dedup and the write path, against a throwaway in-memory database and with no
* network involved at all: a Fedimint submission is decoded rather than fetched, so it
* exercises `indexSubmission` end to end — the in-flight map, the insert, and the
* payload read back — without touching a relay or a mint.
*
* The import is deferred until after `DATABASE_URL` is set, because the config resolves
* its target on first use and this must not open the real database.
*/
process.env['DATABASE_URL'] = ':memory:';
const { indexSubmission, inFlightCount } = await import('./index-mint.ts');
const { closeDb } = await import('./db.ts');
await checkAsync('two simultaneous submissions of one address share one probe', async () => {
assert.equal(inFlightCount(), 0);
const first = indexSubmission('fedimint', REAL_INVITE);
assert.equal(inFlightCount(), 1, 'the first submission claims the key immediately');
// Deliberately a different spelling of the same code: the key is the decoded
// federation id, so the two collapse before anything is written.
const second = indexSubmission('fedimint', REAL_INVITE.toUpperCase());
assert.equal(inFlightCount(), 1, 'the second submission joins the first, it does not start');
const [a, b] = await Promise.all([first, second]);
assert.equal(a, b, 'both callers get the same answer object');
assert.equal(a.status, 201);
assert.equal(inFlightCount(), 0, 'the entry is released when the work finishes');
const created = a.body as { host?: string; status?: string; invite_codes?: string[] };
assert.equal(created.host, 'fed-aeca6cc80ffc530b');
assert.equal(created.status, 'announced', 'nothing checks a federation, so nothing claims it is up');
assert.deepEqual(created.invite_codes, [REAL_INVITE.toLowerCase()]);
// And once it is a row, submitting it again is a lookup rather than a write.
const again = await indexSubmission('fedimint', REAL_INVITE);
assert.equal(again.status, 200);
assert.equal((again.body as { existing?: boolean }).existing, true);
});
await checkAsync('a submission of the wrong shape never reaches the work at all', async () => {
const junk = await indexSubmission('fedimint', 'fed11not-a-real-code');
assert.equal(junk.status, 422);
assert.equal((junk.body as { error?: string }).error, 'invalid_invite');
assert.equal(inFlightCount(), 0);
const wrongType = await indexSubmission('cashu-ish', 'https://mint.example.com');
assert.equal((wrongType.body as { error?: string }).error, 'bad_type');
// A private address is refused by the normalizer, before DNS and before the map.
const private_ = await indexSubmission('cashu', 'https://192.168.1.5');
assert.equal((private_.body as { error?: string }).error, 'blocked_host');
assert.equal(inFlightCount(), 0);
});
await closeDb();
console.log(`ok, ${checks} index checks passed`);
+935
View File
@@ -0,0 +1,935 @@
/**
* pnpm --filter ./api test:lnurl
*
* The LNURL ecosystem, checked against the two things it has to agree with: the
* responses recorded in `NOTES-LNURL.md`, and the rules written down in
* `docs/KIND-LNURL-MINT.md`. Those two documents are the specification; this file is
* what stops the code and the documents drifting apart in silence.
*
* Four groups:
*
* 1. **Parsers**, against verbatim captures of the live reference instance and of a
* locally run mint with no funding source. Not hand-written approximations — the
* bytes that were actually on the wire.
* 2. **The `d` rules**, including the sticky-identity case that the kind document
* spends a section on and that a naive implementation gets wrong.
* 3. **The features vocabulary**, including the two negative cases that matter:
* nothing invents `lud21`, and nothing invents `rotate`/`split`/`merge`.
* 4. **A full round trip** — announcement and review published to a real relay by this
* site's own publisher, read back by the indexer's own parsers, resolved to a row.
* The relay is `test-relay.ts`, in-process and on an ephemeral port, so this runs
* in CI with nothing installed and never touches a public relay.
*/
import assert from 'node:assert/strict';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import * as nip19 from 'nostr-tools/nip19';
import {
ANNOUNCEMENT_KINDS,
FEATURE_VOCABULARY,
KIND_LNURL_ANNOUNCEMENT,
KIND_REVIEW,
addressFromPayLink,
baseUrlFromKey,
ecosystemForKind,
featureStates,
getMintWarnings,
hasFeature,
hostIdentifier,
isMintPubkey,
lnurlIdentifier,
lnurlIdentifiers,
lnurlKey,
mintChip,
msatToSat,
normalizeLnurlNetwork,
normalizeNetwork,
onionFromHtml,
otherFeatures,
parseAdvertisement,
parseFeatures,
parseLnurlAnnouncement,
parsePayInfo,
parseRating,
parseSoftware,
reviewEcosystem,
type LnurlFields,
type NostrEventLike,
} from '@cashumints/shared';
import { announcedFeatures, announcementTemplate, parseAnnounceKey } from './announce.ts';
import { announceConfig } from './config.ts';
import type { MintRow } from './mints.ts';
import { startTestRelay } from './test-relay.ts';
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
async function checkAsync(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
/* ------------------------------------------------------------------ *
* Captures. Verbatim, from NOTES-LNURL.md.
* ------------------------------------------------------------------ */
/** `GET https://lnurl.21mint.me/.well-known/lnurlw/_`, 2026-08-21. */
const LIVE_WITHDRAW = {
tag: 'withdrawRequest',
callback: 'https://lnurl.21mint.me/w',
minWithdrawable: 5000,
maxWithdrawable: 999899000,
defaultDescription: 'lnurlcash bearer note on lnurl.21mint.me',
mintPubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
payLink: 'https://lnurl.21mint.me/.well-known/lnurlp/mint',
nodeAlias: 'Azzamo',
nodeUri: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736',
nodeColor: '#68f442',
nodeCapacity: 30027500000,
nodeNumChannels: 8,
nodeNumPeers: 18,
};
/** `GET https://lnurl.21mint.me/.well-known/lnurlp/_`, same run. */
const LIVE_PAY = {
tag: 'payRequest',
callback: 'https://lnurl.21mint.me/p/cb',
minSendable: 6000,
maxSendable: 1000000000,
metadata:
'[["text/plain", "Mint an lnurlcash bearer note on lnurl.21mint.me"], ' +
'["text/identifier", "_@lnurl.21mint.me"], ["text/plain", "Mint fees: 1000,100"]]',
withdrawLink: 'https://lnurl.21mint.me/w',
};
/**
* The same endpoint on a locally run mint with no `FUNDINGSOURCE_*` configured at all.
*
* Every node field is *absent* rather than null, because the software runs with
* `response_model_exclude_none`. This object is the degraded state, and it is the reason
* the probe can report one.
*/
const NO_FUNDING_WITHDRAW = {
tag: 'withdrawRequest',
callback: 'https://lnurl.test/w',
minWithdrawable: 10000,
maxWithdrawable: 999999000,
defaultDescription: 'lnurlcash bearer note on lnurl.test',
payLink: 'https://lnurl.test/.well-known/lnurlp/mint',
};
/* ------------------------------------------------------------------ *
* 1. Parsers
* ------------------------------------------------------------------ */
check('the live mint advertisement parses into every field the site renders', () => {
const ad = parseAdvertisement(LIVE_WITHDRAW);
assert.ok(ad, 'the live advertisement must parse');
assert.equal(ad.minWithdrawableMsat, 5000);
assert.equal(ad.maxWithdrawableMsat, 999899000);
assert.equal(ad.defaultDescription, 'lnurlcash bearer note on lnurl.21mint.me');
assert.equal(ad.mintPubkey, '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555');
assert.equal(ad.nodeAlias, 'Azzamo');
assert.equal(ad.nodeCapacityMsat, 30027500000);
assert.equal(ad.nodeChannels, 8);
assert.equal(ad.nodePeers, 18);
assert.equal(ad.fundingAvailable, true);
});
check('millisatoshi limits become the sat figures the verdict strip shows', () => {
assert.equal(msatToSat(5000), 5);
assert.equal(msatToSat(999899000), 999899);
// Floored, not rounded: both numbers are bounds, and rounding a ceiling up would
// advertise a note larger than the mint will ever issue.
assert.equal(msatToSat(1999), 1);
assert.equal(msatToSat(999), 0);
assert.equal(msatToSat(null), null);
assert.equal(msatToSat(-1), null);
});
check('a missing mintPubkey is the degraded state, not a parse failure', () => {
const ad = parseAdvertisement(NO_FUNDING_WITHDRAW);
assert.ok(ad, 'a mint with no funding source still serves a valid advertisement');
assert.equal(ad.fundingAvailable, false);
assert.equal(ad.mintPubkey, null);
assert.equal(ad.nodeAlias, null);
assert.equal(ad.nodeUri, null);
// The limits are real and must still render. This is the whole point of the state.
assert.equal(msatToSat(ad.minWithdrawableMsat), 10);
assert.equal(msatToSat(ad.maxWithdrawableMsat), 999999);
});
check('an LNURL error body is not a mint advertisement, despite arriving as HTTP 200', () => {
// These endpoints answer their errors with 200. A probe keying on the status code
// would call every one of these "online".
assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown user.' }), null);
assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown note.' }), null);
assert.equal(parseAdvertisement({ detail: 'Not Found' }), null);
assert.equal(parseAdvertisement(LIVE_PAY), null, 'a payRequest is not a withdrawRequest');
assert.equal(parseAdvertisement(null), null);
assert.equal(parseAdvertisement('withdrawRequest'), null);
assert.equal(parseAdvertisement([]), null);
});
check('an advertisement with inverted or missing bounds is rejected', () => {
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 900, maxWithdrawable: 100 }), null);
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: undefined }), null);
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: 'lots' }), null);
});
check('a zero withdraw ceiling parses, because the warning needs to see it', () => {
// Rejecting this would turn "withdrawals disabled" into "offline", which is a
// different and much less useful thing to tell a reader.
const ad = parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 0, maxWithdrawable: 0 });
assert.ok(ad);
assert.equal(ad.maxWithdrawableMsat, 0);
});
check('the payRequest metadata is parsed twice and yields fee, description and address', () => {
const pay = parsePayInfo(LIVE_PAY);
assert.ok(pay);
assert.equal(pay.minSendableMsat, 6000);
assert.equal(pay.maxSendableMsat, 1000000000);
assert.equal(pay.description, 'Mint an lnurlcash bearer note on lnurl.21mint.me');
assert.equal(pay.feeBaseMsat, 1000);
assert.equal(pay.feePpm, 100);
assert.equal(pay.withdrawLink, 'https://lnurl.21mint.me/w');
});
check('the "Mint fees:" entry never becomes the description', () => {
// It shares `text/plain` with the description, so a naive first-match reader shows
// "Mint fees: 1000,100" as what the mint is.
const pay = parsePayInfo({
...LIVE_PAY,
metadata: '[["text/plain", "Mint fees: 2000,50"], ["text/plain", "A real description"]]',
});
assert.equal(pay?.description, 'A real description');
assert.equal(pay?.feeBaseMsat, 2000);
assert.equal(pay?.feePpm, 50);
});
check('a fee-free mint reports no fee rather than zero', () => {
const pay = parsePayInfo({ ...LIVE_PAY, metadata: '[["text/plain", "Just a mint"]]' });
assert.equal(pay?.feeBaseMsat, null);
assert.equal(pay?.feePpm, null);
});
check('unparseable metadata costs the description, not the whole response', () => {
const pay = parsePayInfo({ ...LIVE_PAY, metadata: 'not json at all' });
assert.ok(pay, 'the limits above the metadata are still good');
assert.equal(pay.minSendableMsat, 6000);
assert.equal(pay.description, null);
});
check('the lightning address comes from payLink, never from the echoed identifier', () => {
// `text/identifier` echoes whichever username was queried, so probing `_` gets back
// `_@host` — LUD-16's bare-domain form, and not a name to show a reader.
assert.equal(parsePayInfo(LIVE_PAY)?.identifier, '_@lnurl.21mint.me');
assert.equal(addressFromPayLink(LIVE_WITHDRAW.payLink), 'mint@lnurl.21mint.me');
assert.equal(addressFromPayLink('https://x.example/.well-known/lnurlp/_'), null);
assert.equal(addressFromPayLink('https://x.example/somewhere/else'), null);
assert.equal(addressFromPayLink(null), null);
assert.equal(addressFromPayLink('not a url'), null);
});
check('an onion address is found in the one-pager and nowhere else', () => {
const html =
'<h2>Also via Tor</h2><button class="copy" ' +
'data-copy="mint@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion" ' +
'title="Copy lightning address">&#9889;</button>';
assert.equal(
onionFromHtml(html),
'abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion',
);
assert.equal(onionFromHtml('<h1>a mint with no tor section</h1>'), null);
});
check('the version comes from openapi, and the unknown sentinel is not a version', () => {
assert.equal(
parseSoftware({ info: { title: 'lnurl-mint', version: '0.1.0' } }),
'lnurl-mint/0.1.0',
);
// What a source checkout with no installed package metadata reports. It is the
// library saying "I do not know", not a release, and must not reach a reader.
assert.equal(parseSoftware({ info: { title: 'lnurl-mint', version: '0.0.0+unknown' } }), null);
assert.equal(parseSoftware({ info: { version: '1.2.3' } }), null);
assert.equal(parseSoftware({}), null);
assert.equal(parseSoftware(null), null);
});
/* ------------------------------------------------------------------ *
* 2. Identity: the `d` rules
* ------------------------------------------------------------------ */
check('a mint pubkey is 66 hex characters beginning 02 or 03', () => {
assert.equal(isMintPubkey(LIVE_WITHDRAW.mintPubkey), true);
assert.equal(isMintPubkey('03' + 'a'.repeat(64)), true);
// A Cashu mint pubkey or a federation id is 64 characters, and must never be
// mistaken for one of these.
assert.equal(isMintPubkey('a'.repeat(64)), false);
assert.equal(isMintPubkey('04' + 'a'.repeat(64)), false);
assert.equal(isMintPubkey('021ab8'), false);
assert.equal(isMintPubkey(null), false);
});
check('the two `d` forms can never be confused for one another', () => {
const host = hostIdentifier('https://lnurl.21mint.me');
assert.equal(host, 'lnurl.21mint.me');
assert.equal(isMintPubkey(host), false, 'a host is never pubkey-shaped');
assert.ok(host.includes('.'), 'a host always contains a dot; a pubkey never does');
});
check('the `d` fallback drops the scheme and the trailing slash but keeps the path', () => {
assert.equal(hostIdentifier('https://mint.example.com/lnurl/'), 'mint.example.com/lnurl');
assert.equal(hostIdentifier('https://Mint.Example.com'), 'mint.example.com');
});
check('the identifier prefers the pubkey and falls back to the host', () => {
assert.equal(
lnurlIdentifier('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey),
LIVE_WITHDRAW.mintPubkey,
);
assert.equal(lnurlIdentifier('https://lnurl.21mint.me', null), 'lnurl.21mint.me');
// Junk in the pubkey position falls back rather than being published as a `d`.
assert.equal(lnurlIdentifier('https://lnurl.21mint.me', 'nonsense'), 'lnurl.21mint.me');
});
check('a mint that gained a pubkey is still reviewable under its old host identifier', () => {
// The case the kind document spends a section on. Reviews written before the mint had
// a funding source carry the host `d`; asking only for the current identifier would
// silently strand every one of them.
const both = lnurlIdentifiers('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey);
assert.deepEqual(both, [LIVE_WITHDRAW.mintPubkey, 'lnurl.21mint.me']);
const hostOnly = lnurlIdentifiers('https://lnurl.21mint.me', null);
assert.deepEqual(hostOnly, ['lnurl.21mint.me']);
});
check('the row key round-trips the base URL', () => {
const key = lnurlKey('https://lnurl.21mint.me');
assert.equal(key, 'lnurl:https://lnurl.21mint.me');
assert.equal(baseUrlFromKey(key), 'https://lnurl.21mint.me');
// Not an LNURL key, and must not be mistaken for one.
assert.equal(baseUrlFromKey('https://mint.example.com'), null);
assert.equal(baseUrlFromKey('fedimint:' + 'a'.repeat(64)), null);
});
/* ------------------------------------------------------------------ *
* 3. The features vocabulary
* ------------------------------------------------------------------ */
check('the features tag splits like a modules tag, and tolerates what publishers write', () => {
assert.deepEqual(parseFeatures('mint,melt,rotate'), ['mint', 'melt', 'rotate']);
assert.deepEqual(parseFeatures('mint, melt, rotate'), ['mint', 'melt', 'rotate']);
assert.deepEqual(parseFeatures('MINT,Melt'), ['mint', 'melt']);
assert.deepEqual(parseFeatures('mint,mint,melt'), ['mint', 'melt']);
assert.deepEqual(parseFeatures(''), []);
assert.deepEqual(parseFeatures(null), []);
// Junk tokens are dropped, not carried into a chip.
assert.deepEqual(parseFeatures('mint,<script>,melt'), ['mint', 'melt']);
});
check('an unrecognised feature is kept and shown, never dropped', () => {
// The kind document requires consumers to ignore tokens they do not know rather than
// reject the event, which is what lets the vocabulary grow without a new kind.
const features = parseFeatures('mint,melt,quantum-notes');
assert.deepEqual(features, ['mint', 'melt', 'quantum-notes']);
assert.deepEqual(otherFeatures(features), ['quantum-notes']);
});
check('the vocabulary is exactly what the kind document lists', () => {
assert.deepEqual([...FEATURE_VOCABULARY], [
'mint', 'melt', 'rotate', 'split', 'merge',
'lud06', 'lud03', 'lud16', 'lud21',
'signed-notes', 'onion',
]);
});
check('the notes row is satisfied by any one of rotate, split or merge', () => {
assert.equal(hasFeature(['rotate'], 'notes'), true);
assert.equal(hasFeature(['split'], 'notes'), true);
assert.equal(hasFeature(['merge'], 'notes'), true);
assert.equal(hasFeature(['mint', 'melt'], 'notes'), false);
});
check('a funding outage marks mint, melt and signed-notes unavailable, and nothing else', () => {
const features = ['mint', 'melt', 'rotate', 'split', 'merge', 'lud16', 'lud21', 'signed-notes'];
const down = featureStates(features, false);
assert.equal(down.mint, 'unavailable');
assert.equal(down.melt, 'unavailable');
assert.equal(down['signed-notes'], 'unavailable');
// Exactly what still works with no Lightning node, and the reason this state is
// rendered rather than collapsed into "offline".
assert.equal(down.notes, 'ok');
assert.equal(down.lud16, 'ok');
assert.equal(down.lud21, 'ok');
assert.equal(down.onion, 'none');
const up = featureStates(features, true);
assert.equal(up.mint, 'ok');
assert.equal(up['signed-notes'], 'ok');
// Nothing probed yet is not a reason to doubt an operator's claim.
const unknown = featureStates(features, null);
assert.equal(unknown.mint, 'ok');
});
check('a feature that was never claimed stays absent even when funding is down', () => {
const states = featureStates(['rotate'], false);
assert.equal(states.mint, 'none', 'absent, not "unavailable" — it was never claimed');
assert.equal(states.notes, 'ok');
});
check('the network tag agrees with the Fedimint side, bitcoin included', () => {
for (const value of ['bitcoin', 'mainnet', 'MAIN', 'signet', 'regtest', 'testnet4', '', null]) {
assert.equal(
normalizeLnurlNetwork(value),
normalizeNetwork(value),
`the two normalizers disagree about ${JSON.stringify(value)}`,
);
}
});
/* ------------------------------------------------------------------ *
* 4. The announcement
* ------------------------------------------------------------------ */
const ANNOUNCER = 'f'.repeat(64);
function announcement(tags: string[][], content = ''): NostrEventLike {
return {
id: 'e'.repeat(64),
pubkey: ANNOUNCER,
kind: KIND_LNURL_ANNOUNCEMENT,
created_at: 1_780_000_000,
content,
tags,
};
}
check('kind 38174 is wired into the one table every ecosystem is read from', () => {
assert.equal(ANNOUNCEMENT_KINDS.lnurl, 38174);
assert.equal(ecosystemForKind(38174), 'lnurl');
assert.equal(ecosystemForKind('38174'), 'lnurl');
// The neighbours are untouched.
assert.equal(ecosystemForKind(38172), 'cashu');
assert.equal(ecosystemForKind(38173), 'fedimint');
});
check('a full announcement parses into every field a row needs', () => {
const parsed = parseLnurlAnnouncement(
announcement(
[
['d', LIVE_WITHDRAW.mintPubkey],
['u', 'https://lnurl.21mint.me'],
['features', 'mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes'],
['n', 'mainnet'],
],
JSON.stringify({ name: '21 Mint', about: 'Bearer notes.' }),
),
);
assert.ok(parsed);
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
assert.equal(parsed.slug, 'lnurl.21mint.me');
assert.equal(parsed.network, 'mainnet');
assert.equal(parsed.name, '21 Mint');
assert.equal(parsed.about, 'Bearer notes.');
assert.equal(parsed.announcerPubkey, ANNOUNCER);
assert.equal(parsed.features.length, 10);
});
check('an announcement by host parses, and carries no pubkey', () => {
const parsed = parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']]),
);
assert.ok(parsed);
assert.equal(parsed.identifier, 'lnurl.example.com');
assert.equal(parsed.mintPubkey, null, 'a host `d` is not a pubkey and must not read as one');
});
check('an announcement with no usable `u` is rejected', () => {
// `u` is REQUIRED for this kind, unlike 38172: a host-form `d` has no scheme and is
// not fetchable, so without `u` there is no address at all.
assert.equal(parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com']])), null);
assert.equal(
parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com'], ['u', 'not a url']])),
null,
);
// An onion cannot be the canonical `u`; the normalizer refuses it for every ecosystem.
assert.equal(
parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'http://abcdefghijklmnop.onion']]),
),
null,
);
});
check('an announcement with an unusable `d` is rejected', () => {
assert.equal(parseLnurlAnnouncement(announcement([['u', 'https://lnurl.example.com']])), null);
assert.equal(
parseLnurlAnnouncement(
announcement([['d', 'not an identifier'], ['u', 'https://lnurl.example.com']]),
),
null,
);
});
check('a `d` naming one mint and a `u` naming another is accepted, deliberately', () => {
// Not a validation failure: a mint's `d` is its funding node's pubkey, which has no
// relationship to its hostname at all. Cross-checking them would reject exactly the
// events the identity rule exists to allow.
const parsed = parseLnurlAnnouncement(
announcement([['d', LIVE_WITHDRAW.mintPubkey], ['u', 'https://somewhere.else.example']]),
);
assert.ok(parsed);
assert.equal(parsed.baseUrl, 'https://somewhere.else.example');
});
check('hostile announcement content degrades to no metadata', () => {
for (const content of ['not json', '[]', 'null', '{"name": 12345}', '""']) {
const parsed = parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']], content),
);
assert.ok(parsed, `content ${content} must not reject the announcement`);
assert.equal(parsed.name, null);
}
// A `javascript:` picture never reaches an img src.
const parsed = parseLnurlAnnouncement(
announcement(
[['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']],
JSON.stringify({ picture: 'javascript:alert(1)' }),
),
);
assert.equal(parsed?.picture, null);
});
/* ------------------------------------------------------------------ *
* 5. Reviews
* ------------------------------------------------------------------ */
function review(tags: string[][], content = '[5/5] Good mint.'): NostrEventLike {
return {
id: 'd'.repeat(64),
pubkey: 'c'.repeat(64),
kind: KIND_REVIEW,
created_at: 1_780_000_100,
content,
tags,
};
}
check('a k=38174 review is filed under lnurl and nothing else', () => {
assert.equal(reviewEcosystem(review([['k', '38174']])), 'lnurl');
assert.equal(reviewEcosystem(review([['k', '38172']])), 'cashu');
assert.equal(reviewEcosystem(review([['k', '38173']])), 'fedimint');
// A review with no `k` is Cashu, because every one of those predates everything else.
assert.equal(reviewEcosystem(review([])), 'cashu');
// A kind this build has no ecosystem for belongs to nobody.
assert.equal(reviewEcosystem(review([['k', '39999']])), null);
});
check('the rating convention is identical across all three ecosystems', () => {
// A reader comparing a Cashu mint to an LNURL one must be comparing the same scale,
// so this uses the site's existing parser with no LNURL-specific path at all.
assert.equal(parseRating(review([['k', '38174']], '[4/5] Fast melts')), 4);
assert.equal(parseRating(review([['k', '38174'], ['rating', '2']])), 2);
assert.equal(parseRating(review([['k', '38174'], ['rating', '0.8']])), 4);
assert.equal(parseRating(review([['k', '38174']], 'no rating here')), null);
});
/* ------------------------------------------------------------------ *
* 6. Warnings
* ------------------------------------------------------------------ */
const onlineMint = {
type: 'lnurl',
status: 'online' as const,
last_online: 1_786_999_910,
first_seen: 1_769_720_000,
};
const NOW = 1_787_000_000;
check('a healthy LNURL mint gets no banner', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: true },
{ now: NOW },
);
assert.deepEqual(warnings, []);
});
check('a zero withdraw ceiling is critical and says withdrawals are disabled', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 0, funding_available: true },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
assert.equal(warnings[0]?.severity, 'critical');
assert.match(warnings[0]!.lead, /Withdrawals disabled/);
assert.equal(mintChip(warnings)?.label, 'No withdrawals');
});
check('never having probed is not "withdrawals disabled"', () => {
// `=== 0`, not falsy. null means nothing has looked yet.
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: null, funding_available: null },
{ now: NOW },
);
assert.deepEqual(warnings, []);
});
check('an unreachable funding source is a warning that says what still works', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: false },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-no-funding');
assert.equal(warnings[0]?.severity, 'warning');
assert.match(warnings[0]!.body, /rotated, split, or merged/);
assert.match(warnings[0]!.body, /nothing moves in or out/);
assert.equal(mintChip(warnings)?.label, 'No mint / melt');
});
check('a host responding with junk says so, rather than saying offline', () => {
const warnings = getMintWarnings(
{
...onlineMint,
status: 'degraded',
max_withdrawable_msat: 999899000,
invalid_reason: 'mint replied: Unknown user.',
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-invalid');
assert.match(warnings[0]!.lead, /Endpoint responding but invalid/);
assert.match(warnings[0]!.body, /withdrawals may not work/);
});
check('the offline tiers are exactly the Cashu ones', () => {
const day = 86400;
const tiers: Array<[number, string]> = [
[2 * day, 'offline'],
[10 * day, 'offline-long'],
[40 * day, 'gone'],
];
for (const [age, kind] of tiers) {
const warnings = getMintWarnings(
{ type: 'lnurl', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, kind, `${age / day} days offline should be ${kind}`);
// The same input with `type: cashu` reaches the same tier, which is what makes
// "identical to Cashu" a checked claim rather than a comment.
const cashu = getMintWarnings(
{ type: 'cashu', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
{ now: NOW },
);
assert.equal(cashu[0]?.kind, kind);
assert.equal(cashu[0]?.lead, warnings[0]?.lead, 'and says the same words');
}
});
check('a mint that never answered once lands in the top tier', () => {
const warnings = getMintWarnings(
{ type: 'lnurl', status: 'offline', last_online: null, first_seen: NOW - 40 * 86400 },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'gone');
});
check('withdrawals disabled outranks an offline banner, and still says it is offline', () => {
const warnings = getMintWarnings(
{
type: 'lnurl',
status: 'offline',
last_online: NOW - 3 * 86400,
first_seen: NOW - 200 * 86400,
max_withdrawable_msat: 0,
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
assert.match(warnings[0]!.body, /also been offline/);
});
check('an offline banner rescues the funding fact into its last sentence', () => {
const warnings = getMintWarnings(
{
type: 'lnurl',
status: 'offline',
last_online: NOW - 40 * 86400,
first_seen: NOW - 200 * 86400,
funding_available: false,
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'gone');
assert.match(warnings[0]!.body, /Lightning node was also unreachable/);
});
check('no LNURL warning is ever derived from the features tag', () => {
// `features` is the operator's claim about what they built. A banner derived from a
// claim rather than an observation is the invention the Fedimint branch refuses to
// make, and this branch refuses it too.
const claimed = getMintWarnings(
{ ...onlineMint, features: [], max_withdrawable_msat: 5000, funding_available: true } as never,
{ now: NOW },
);
assert.deepEqual(claimed, []);
});
/* ------------------------------------------------------------------ *
* 7. The publisher
* ------------------------------------------------------------------ */
const probedRow: MintRow = {
url: 'lnurl:https://lnurl.21mint.me',
host: 'lnurl.21mint.me',
type: 'lnurl',
name: '21 Mint',
description: null,
icon_url: null,
icon_file: null,
pubkey: null,
info_json: null,
ecosystem_json: null,
nuts_json: null,
version: 'lnurl-mint/0.1.0',
status: 'online',
consecutive_fails: 0,
last_online: 1_786_999_910,
last_probe: 1_786_999_910,
first_seen: 1_769_720_000,
updated_at: 1_786_999_910,
};
const probedFields: LnurlFields = {
lnurl_id: LIVE_WITHDRAW.mintPubkey,
base_url: 'https://lnurl.21mint.me',
features: [],
network: 'mainnet',
announced_at: null,
announcer_pubkey: null,
mint_pubkey: LIVE_WITHDRAW.mintPubkey,
funding_available: true,
probe_endpoint: '/.well-known/lnurlw/_',
invalid_reason: null,
min_withdrawable_msat: 5000,
max_withdrawable_msat: 999899000,
min_sendable_msat: 6000,
max_sendable_msat: 1000000000,
fee_base_msat: 1000,
fee_ppm: 100,
lightning_address: 'mint@lnurl.21mint.me',
onion_url: null,
node_alias: 'Azzamo',
node_uri: LIVE_WITHDRAW.nodeUri,
node_capacity_msat: 30027500000,
node_channels: 8,
node_peers: 18,
observed_features: ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes'],
};
check('announcing is off unless all three switches are set', () => {
assert.equal(announceConfig({}), null);
assert.equal(announceConfig({ ANNOUNCE_LNURL: 'true' }), null, 'no key, no publish');
assert.equal(
announceConfig({ ANNOUNCE_LNURL: 'true', ANNOUNCE_KEY: 'nsec1x' }),
null,
'no relays, no publish — there is deliberately no default',
);
assert.equal(
announceConfig({ ANNOUNCE_LNURL: 'false', ANNOUNCE_KEY: 'k', ANNOUNCE_RELAYS: 'wss://r' }),
null,
);
const on = announceConfig({
ANNOUNCE_LNURL: 'true',
ANNOUNCE_KEY: 'nsec1x',
ANNOUNCE_RELAYS: 'wss://one, wss://two, http://not-a-relay',
});
assert.deepEqual(on, { secretKey: 'nsec1x', relays: ['wss://one', 'wss://two'] });
});
check('the signing key accepts an nsec or hex, and refuses anything else', () => {
const secret = generateSecretKey();
const hex = Buffer.from(secret).toString('hex');
assert.deepEqual(parseAnnounceKey(hex), secret);
assert.deepEqual(parseAnnounceKey(nip19.nsecEncode(secret)), secret);
assert.equal(parseAnnounceKey('npub1abc'), null);
assert.equal(parseAnnounceKey('nsec1notvalid'), null);
assert.equal(parseAnnounceKey('deadbeef'), null);
assert.equal(parseAnnounceKey(''), null);
});
check('this site announces only what it observed', () => {
const features = announcedFeatures(probedFields);
assert.deepEqual(features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
// The four it must never publish, and the reasons, from the kind document:
assert.ok(!features.includes('lud21'), 'verify is undetectable over HTTP');
for (const operation of ['rotate', 'split', 'merge']) {
assert.ok(
!features.includes(operation),
`${operation} is only provable by calling /w/cb, which mutates a stranger's note`,
);
}
});
check('a mint with no reachable node is announced without the funded capabilities', () => {
const features = announcedFeatures({ ...probedFields, funding_available: false });
assert.deepEqual(features, ['lud06', 'lud03', 'lud16']);
assert.ok(!features.includes('signed-notes'), 'note signing needs the same node');
});
check('a zero withdraw ceiling is never announced as melt', () => {
const features = announcedFeatures({ ...probedFields, max_withdrawable_msat: 0 });
assert.ok(!features.includes('melt'));
assert.ok(features.includes('mint'), 'the pay side is unaffected');
});
check('the announcement template is shaped exactly as the kind document says', () => {
const template = announcementTemplate(probedRow, probedFields, 1_787_000_000);
assert.equal(template.kind, 38174);
assert.deepEqual(template.tags[0], ['d', LIVE_WITHDRAW.mintPubkey]);
assert.deepEqual(template.tags[1], ['u', 'https://lnurl.21mint.me']);
assert.deepEqual(template.tags[2], ['features', 'mint,melt,lud06,lud03,lud16,signed-notes']);
assert.deepEqual(template.tags[3], ['n', 'mainnet']);
assert.equal(template.content, JSON.stringify({ name: '21 Mint' }));
});
check('a mint that never named a network is not assigned one', () => {
// Absent reads as mainnet, so asserting it would be this site inventing the one fact
// that decides whether the money is real.
const template = announcementTemplate(probedRow, { ...probedFields, network: null }, 1);
assert.equal(template.tags.some((t) => t[0] === 'n'), false);
});
check('an announcement this site wrote defers to its own kind 0 when there is no name', () => {
const template = announcementTemplate({ ...probedRow, name: null }, probedFields, 1);
assert.equal(template.content, '');
});
/* ------------------------------------------------------------------ *
* 8. The round trip, against a real relay
* ------------------------------------------------------------------ */
await checkAsync('a 38174 announcement and a k=38174 review round-trip a relay', async () => {
const relay = await startTestRelay();
const pool = new SimplePool();
try {
const siteKey = generateSecretKey();
const sitePubkey = getPublicKey(siteKey);
const reviewerKey = generateSecretKey();
/* --- publish the announcement, using the publisher's own template builder --- */
const created = 1_787_000_000;
const announcementEvent = finalizeEvent(
announcementTemplate(probedRow, probedFields, created),
siteKey,
);
await Promise.allSettled(pool.publish([relay.url], announcementEvent));
/* --- publish a review of it, shaped as the kind document specifies --- */
const reviewEvent = finalizeEvent(
{
kind: KIND_REVIEW,
created_at: created + 60,
content: '[5/5] Rotations are instant and melts have never failed me.',
tags: [
['k', String(KIND_LNURL_ANNOUNCEMENT)],
['u', probedFields.base_url, 'lnurl'],
['d', probedFields.lnurl_id],
['a', `${KIND_LNURL_ANNOUNCEMENT}:${sitePubkey}:${probedFields.lnurl_id}`, relay.url],
],
},
reviewerKey,
);
await Promise.allSettled(pool.publish([relay.url], reviewEvent));
/* --- read both back the way the indexer does --- */
const announcements = await pool.querySync(
[relay.url],
{ kinds: [KIND_LNURL_ANNOUNCEMENT] },
{ maxWait: 4000 },
);
assert.equal(announcements.length, 1, 'the relay should hold exactly one announcement');
const parsed = parseLnurlAnnouncement(announcements[0]!);
assert.ok(parsed, 'the indexer must be able to parse what the site published');
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.network, 'mainnet');
assert.equal(parsed.announcerPubkey, sitePubkey);
assert.deepEqual(parsed.features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
assert.equal(parsed.name, '21 Mint');
/* --- the review, asked for exactly as the targeted pass asks --- */
const reviews = await pool.querySync(
[relay.url],
{
kinds: [KIND_REVIEW],
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
'#d': lnurlIdentifiers(parsed.baseUrl, parsed.mintPubkey),
},
{ maxWait: 4000 },
);
assert.equal(reviews.length, 1, 'the #d/#k filter the indexer uses must find it');
assert.equal(reviewEcosystem(reviews[0]!), 'lnurl');
assert.equal(parseRating(reviews[0]!), 5);
/* --- and by `u`, which is how a client that does not know this kind writes one --- */
const byUrl = await pool.querySync(
[relay.url],
{ kinds: [KIND_REVIEW], '#u': [probedFields.base_url] },
{ maxWait: 4000 },
);
assert.equal(byUrl.length, 1, 'the #u filter must find it too');
/* --- replacement: a second announcement for the same `d` replaces the first --- */
const updated = finalizeEvent(
announcementTemplate(
probedRow,
{ ...probedFields, funding_available: false },
created + 3600,
),
siteKey,
);
await Promise.allSettled(pool.publish([relay.url], updated));
const after = relay.byKind(KIND_LNURL_ANNOUNCEMENT);
assert.equal(after.length, 1, '38174 is addressable: the relay holds one, not two');
assert.equal(
after[0]?.tags.find((t) => t[0] === 'features')?.[1],
'lud06,lud03,lud16',
'and it is the newer one, with the funded capabilities dropped',
);
} finally {
pool.close([relay.url]);
await relay.close();
}
});
console.log(`ok, ${checks} LNURL checks passed`);
+44 -24
View File
@@ -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
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`,
)
.get() as { url: string; host: string; name: string } | undefined;
);
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
View File
@@ -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`);
+144 -1
View File
@@ -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,12 +14,118 @@ 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[],
/**
* The floor a backfill cycle has to clear before it counts as a real read.
*
* For about a year this deployment's RELAYS list did not include the relay carrying
* the kind 38000/38172 archive. Every backfill returned about thirty events, wrote
* them, reported ok=true, and the index sat at eight mints while every health signal
* stayed green. A backfill asks five relays for the whole history of four kinds; on a
* working relay set it comes back with thousands. Anything under this is not a quiet
* network, it is a misconfigured one, and it says so in the log and on /api/health.
*
* Raise it on a deployment that genuinely has more history, lower it for a local
* test relay. It is deliberately not zero-able: set it to 1 if you mean "off".
*/
backfillMinEvents: int('BACKFILL_MIN_EVENTS', 200),
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
probeConcurrency: int('PROBE_CONCURRENCY', 8),
@@ -25,4 +133,39 @@ export const config = {
userAgent: 'cashumints.space-indexer/1.0',
} as const;
/**
* Publishing `kind:38174` announcements, which is **off unless two switches are set**.
*
* One switch would be enough to make it work and is not enough to make it safe. This
* writes signed events to public relays, permanently and under this site's name, so
* turning it on has to be a thing somebody did on purpose rather than a thing that
* happened because a key was left in an env file from a test run:
*
* ANNOUNCE_LNURL=true the intent
* ANNOUNCE_KEY=nsec1… the identity it will be signed with
* ANNOUNCE_RELAYS=wss://… where, explicitly — there is deliberately no default
*
* The missing default on ANNOUNCE_RELAYS is the important one. Falling back to the
* site's read pool would mean the difference between a CI run against a local test relay
* and a permanent write to five public ones was a single unset variable.
*
* Returns null — never throws — when any of the three is missing, because a
* misconfigured publisher must not stop an indexer that still has a directory to serve.
* See the README section "Publishing LNURL mint announcements".
*/
export function announceConfig(
env: NodeJS.ProcessEnv = process.env,
): { secretKey: string; relays: string[] } | null {
if (env['ANNOUNCE_LNURL']?.trim().toLowerCase() !== 'true') return null;
const secretKey = env['ANNOUNCE_KEY']?.trim() ?? '';
const relays = (env['ANNOUNCE_RELAYS'] ?? '')
.split(',')
.map((relay) => relay.trim())
.filter((relay) => relay.startsWith('ws://') || relay.startsWith('wss://'));
if (!secretKey || relays.length === 0) return null;
return { secretKey, relays };
}
export const startedAt = Math.floor(Date.now() / 1000);
+44
View File
@@ -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>;
}
+147
View File
@@ -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();
}
}
+120
View File
@@ -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)`,
];
+124
View File
@@ -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();
}
}
+99 -75
View File
@@ -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
);
`;
return db;
}
export type DB = Database.Database;
/**
* 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;
}
}
}
let db: DB | null = null;
let opening: Promise<Db> | null = null;
export function getDb(): DB {
if (db) return db;
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
/**
* 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 });
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;
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 function closeDb(): void {
db?.close();
db = null;
export async function closeDb(): Promise<void> {
const pending = opening;
opening = null;
if (pending) await pending.then((db) => db.close()).catch(() => undefined);
}
export function getState(key: string): string | null {
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
| { value: string }
| 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;
}
+793 -68
View File
@@ -1,19 +1,31 @@
import { SimplePool } from 'nostr-tools/pool';
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
ANNOUNCEMENT_KINDS,
KIND_FEDIMINT_ANNOUNCEMENT,
KIND_LNURL_ANNOUNCEMENT,
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
isCashuMintReview,
mintPubkeyRef,
mintUrlSpellings,
mintUrlsFromEvent,
normalizeMintUrl,
parseFedimintAnnouncement,
parseLnurlAnnouncement,
parseRating,
reviewEcosystem,
reviewTargetId,
reviewTargetKind,
type FedimintAnnouncement,
type LnurlAnnouncement,
type LnurlFields,
type RelayHealth,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, getStateNumber, setState } from './db.ts';
import { getDb, setState, getState, 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, upsertLnurl } from './mints.ts';
const QUERY_LIMIT = 500;
const MAX_WAIT_MS = 12_000;
@@ -28,11 +40,130 @@ export interface DiscoveryResult {
newMints: string[];
newReviews: number;
ok: boolean;
/** What each configured relay actually did, in `config.relays` order. */
relays: RelayHealth[];
/** A backfill that came in under `config.backfillMinEvents`. */
starved: boolean;
}
/**
* What the last cycle did, kept so /api/health can answer for it.
*
* Written to the `state` table rather than held in memory, because the question it
* answers — "is discovery actually reading anything?" — has to survive the restart that
* would otherwise reset it to "no cycle yet, nothing to report". A process that crash
* loops would clear an in-memory flag on every attempt.
*/
export interface DiscoveryReport {
at: number;
mode: 'backfill' | 'incremental';
events: number;
ok: boolean;
relays: RelayHealth[];
starved: boolean;
}
/** `state` key holding the JSON of the above. */
const REPORT_KEY = 'last_discovery_report';
/**
* The last cycle's report, or null before any cycle has run.
*
* A row that will not parse reads as null — the same as no cycle — because the caller
* is a health endpoint and "I cannot tell you" must not be dressed up as "fine".
*/
export async function lastDiscoveryReport(): Promise<DiscoveryReport | null> {
const raw = await getState(REPORT_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as DiscoveryReport;
return Array.isArray(parsed.relays) ? parsed : null;
} catch {
return null;
}
}
/**
* Per-relay bookkeeping for one cycle.
*
* Every relay in `config.relays` gets a row up front, including the ones that are never
* reached, because a relay that produced no row at all is exactly the one worth naming:
* the year-long starvation was a relay list that connected cleanly and simply did not
* hold the archive, and the only field that would have shown it is a zero here.
*
* `events` counts what a relay sent *before* cross-relay deduplication, so five relays
* carrying the same 400 events report 400 each rather than 400 once and 0 four times.
* Attribution is the whole point; the deduplicated total is reported separately.
*/
class RelayTally {
private readonly rows = new Map<string, { events: number; subs: number; eoses: number; connected: boolean }>();
constructor(urls: readonly string[]) {
for (const url of urls) {
this.rows.set(url, { events: 0, subs: 0, eoses: 0, connected: false });
}
}
private row(url: string) {
let found = this.rows.get(url);
if (!found) {
found = { events: 0, subs: 0, eoses: 0, connected: false };
this.rows.set(url, found);
}
return found;
}
connected(url: string): void {
this.row(url).connected = true;
}
subscribed(url: string): void {
this.row(url).subs++;
}
event(url: string): void {
this.row(url).events++;
}
eose(url: string): void {
this.row(url).eoses++;
}
/** One row per configured relay, in configuration order. */
list(): RelayHealth[] {
return [...this.rows.entries()].map(([url, row]) => ({
url,
connected: row.connected,
events: row.events,
// A cycle asks a relay many questions. It only counts as having reached the end
// of the stream if it reached the end of every one of them.
eose: row.subs > 0 && row.eoses === row.subs,
}));
}
}
/**
* Long enough that the relay's own EOSE timer never wins.
*
* `Subscription` fires `oneose` both when an EOSE frame arrives and when its internal
* timer expires, so the two are indistinguishable from the callback. Pushing that timer
* out of reach and running the deadline here instead is what makes `eose` in the report
* mean "the relay said it was done" rather than "something gave up".
*/
const NEVER_EOSE_MS = 24 * 60 * 60 * 1000;
let pool: SimplePool | null = null;
function getPool(): SimplePool {
/**
* The process's one relay pool.
*
* Exported because `relay-lookup.ts` asks the same relays a different question — "has
* anyone ever mentioned this address?", for a mint a reader just submitted that does
* not answer — and opening a second pool for it would mean a second set of sockets to
* the same five relays, with its own reconnect behaviour and its own lifetime to get
* wrong at shutdown.
*/
export function getPool(): SimplePool {
pool ??= new SimplePool();
return pool;
}
@@ -46,12 +177,100 @@ export function closePool(): void {
pool = null;
}
/**
* Ask every configured relay one filter, and record what each of them did.
*
* This replaces `pool.querySync(config.relays, …)`, which answers the same question and
* throws the attribution away: it merges five relays into one deduplicated array, so a
* relay list where four relays are empty and one carries everything is indistinguishable
* from five healthy ones. That indistinguishability is the bug this whole file is being
* changed for — a year of ~31-event backfills, `ok=true` every time.
*
* What it keeps from `querySync`, deliberately:
*
* - One subscription per relay over the pool's existing sockets, so this is the same
* number of connections as before.
* - A single `alreadyHaveEvent` shared across all five. `AbstractRelay._onmessage`
* consults it *before* `JSON.parse` and signature verification, so an event five
* relays all carry is still verified once. Per-relay `querySync` calls would have
* verified it five times, which at 500 events a page is real CPU.
* - `receivedEvent`, which fires on the way past that check, so the per-relay count is
* what the relay sent rather than what was new because of it.
*
* What it changes: the deadline is run here rather than by each `Subscription`'s own
* EOSE timer, so `oneose` firing means an EOSE frame actually arrived. See NEVER_EOSE_MS.
*
* Never throws. A relay that will not connect is a fact to record, not a reason to
* abandon the four that did.
*/
async function queryRelays(filter: Filter, tally: RelayTally | null): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
const known = new Set<string>();
const alreadyHaveEvent = (id: string): boolean => {
if (known.has(id)) return true;
known.add(id);
return false;
};
await Promise.all(
config.relays.map(async (url) => {
let relay;
try {
// The same connection budget subscribeMap would have used for this maxWait.
relay = await getPool().ensureRelay(url, {
connectionTimeout: Math.max(MAX_WAIT_MS * 0.8, MAX_WAIT_MS - 1000),
});
} catch {
// Left as connected=false in the tally, which is the whole report this needs.
return;
}
tally?.connected(url);
await new Promise<void>((resolve) => {
let settled = false;
let deadline: ReturnType<typeof setTimeout> | undefined;
const finish = (): void => {
if (settled) return;
settled = true;
if (deadline !== undefined) clearTimeout(deadline);
resolve();
};
try {
const sub = relay.subscribe([filter], {
onevent: (event) => events.push(event),
alreadyHaveEvent,
receivedEvent: () => tally?.event(url),
oneose: () => {
tally?.eose(url);
sub.close('closed automatically on eose');
},
onclose: finish,
eoseTimeout: NEVER_EOSE_MS,
});
tally?.subscribed(url);
deadline = setTimeout(() => sub.close('closed on maxWait'), MAX_WAIT_MS);
} catch {
// The socket went away between ensureRelay and the REQ.
finish();
}
});
}),
);
return events;
}
/**
* Query one kind, paging backwards with `until` until a page yields nothing new.
* Relays cap `limit` independently, so paging is the only way a fresh database
* converges to the complete history.
*/
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
async function fetchKind(
kind: number,
since: number | null,
tally: RelayTally | null,
): Promise<NostrEvent[]> {
const seen = new Map<string, NostrEvent>();
let until: number | undefined;
@@ -62,7 +281,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
let batch: NostrEvent[];
try {
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
batch = await queryRelays(filter, tally);
} catch (err) {
log.warn('relay query failed', {
kind,
@@ -101,38 +320,270 @@ 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,
tally: RelayTally | 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.type === 'lnurl') {
/*
* Asked for by every identifier it has ever had, not just its current one.
*
* An LNURL mint's `d` is its funding node's pubkey when it has one and its host
* otherwise, so a mint that gained a funding source has reviews filed under both
* spellings — the older ones under the host, the newer under the pubkey. One `#d`
* filter carrying both is one round trip and finds all of them; asking for only the
* current identifier would silently strand the earlier half. See the kind document.
*
* `#u` is asked as well, and unlike a federation's invite codes an LNURL mint's URLs
* are short enough that no relay rejects the filter.
*/
if (target.identifiers.length > 0) {
filters.push({
...base,
'#d': target.identifiers,
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
});
}
filters.push({ ...base, '#u': mintUrlSpellings(target.baseUrl ?? target.url) });
} 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) =>
getPool()
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
.catch(() => [] as NostrEvent[]),
),
filters.map((filter) => queryRelays(filter, tally).catch(() => [] as NostrEvent[])),
);
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, `fedimint:<id>`, or `lnurl:<base url>`. */
url: string;
pubkey: string | null;
federationId: string | null;
/** LNURL: every `d` this mint could be reviewed under. Empty for the others. */
identifiers: string[];
/** LNURL: the fetchable https URL inside the row key. */
baseUrl: 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 LNURL mint the batch announces, inserted or refreshed.
*
* Deduped **by normalized base URL**, not by the `d` tag, which is the one place this
* genuinely differs from `ingestFedimints`. A federation has exactly one identity for
* life; an LNURL mint's identifier is its funding node's pubkey when it has one and its
* host otherwise, so the same mint legitimately announces under two different `d` values
* across its life. Keying on `d` would split it into two pages with half its reviews on
* each. The `u` tag is present in every form of the event, so it is the key.
*
* Newest announcement per URL wins inside the batch, for the same reason the Fedimint
* side does it: the same mint is announced by more than one npub and more than once, and
* writing each of those would be one UPDATE per event to reach the same final state.
*/
async function ingestLnurl(
events: Iterable<NostrEvent>,
now: number,
newRows: Set<string>,
): Promise<void> {
const newest = new Map<string, LnurlAnnouncement>();
for (const event of events) {
const announcement = parseLnurlAnnouncement(event);
if (!announcement) continue;
const existing = newest.get(announcement.baseUrl);
if (existing && existing.announcedAt >= announcement.announcedAt) continue;
newest.set(announcement.baseUrl, announcement);
}
for (const announcement of newest.values()) {
const created = await upsertLnurl(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>;
/**
* Every LNURL identifier to its row key: the mint pubkey *and* the normalized host,
* both pointing at the same row.
*
* Kept apart from `byPubkey`, which is the Cashu index over `mints.pubkey`. An LNURL
* mint's key is its funding node's identity, not a Cashu mint pubkey, and mixing the
* two namespaces would let a review of one resolve to the other. Nothing writes
* `mints.pubkey` for an LNURL row, for exactly that reason.
*/
byLnurlId: Map<string, string>;
/** Normalized base URL to row key, for a review that carries `u` but no usable `d`. */
byLnurlUrl: 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>();
const byLnurlId = new Map<string, string>();
const byLnurlUrl = 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 === 'lnurl' && row.ecosystem_json) {
try {
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
// Both identifiers, always: a mint announced by host and later by pubkey has
// reviews under each, and both belong to this one row.
for (const id of [fields.lnurl_id, fields.mint_pubkey]) {
if (id) byLnurlId.set(id.toLowerCase(), row.url);
}
if (fields.base_url) byLnurlUrl.set(fields.base_url, row.url);
} catch {
// Unparseable column: this row simply cannot be matched by identifier. It still
// has a page and still resolves by its key, which is honest.
}
continue;
}
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, byLnurlId, byLnurlUrl };
}
/**
* Resolve which mint a review is about.
*
@@ -141,83 +592,292 @@ 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);
if (ecosystem === 'lnurl') return resolveLnurlReview(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;
}
/**
* Which LNURL mint a review is about.
*
* `d` first, matched against both identifiers a mint can have — its funding node's
* pubkey and its normalized host — because a mint that gained a funding source has
* reviews written under each and both name the same page.
*
* The `u` fallback is not the afterthought it is on the Fedimint side. An LNURL mint's
* `u` tag is a real, normalizable https URL, so a review that carries only an address
* resolves exactly as a Cashu review does, through the same normalizer. That matters
* for reviews written by clients that do not know this kind: `u` is the tag they are
* most likely to get right.
*
* A review of a mint this site has never seen resolves to nothing and is dropped, as it
* is for the other two ecosystems. There is no page to put it on.
*/
function resolveLnurlReview(event: NostrEvent, index: MintIndex): string | null {
const d = reviewTargetId(event);
if (d) {
const found = index.byLnurlId.get(d.trim().toLowerCase());
if (found) return found;
}
for (const raw of mintUrlsFromEvent(event)) {
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
const found = index.byLnurlUrl.get(normalized.url);
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;
/*
* A review whose `k` names an ecosystem this build knows has already proved itself
* by resolving: `resolveReviewTarget` runs one resolver per ecosystem and returns a
* row only for an event whose `k` matches that resolver, so nothing here has to
* re-test it. Written against `ANNOUNCEMENT_KINDS` rather than against a list of
* kind numbers, so a fourth ecosystem needs no edit here either.
*
* The `u` clause is what keeps the legacy events: a great many kind 38000 on the
* network carry no `k` at all, every one of them is about a Cashu mint, and they are
* accepted on having a resolvable mint URL exactly as they always were.
*/
const kindKnown = reviewTargetKind(e) !== null && reviewEcosystem(e) !== null;
if (!kindKnown && 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);
const newMints = new Set<string>();
const tally = new RelayTally(config.relays);
let newReviews = 0;
let events = 0;
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, tally));
}),
);
const announcements = announcementsByType.get('cashu') ?? [];
const federations = announcementsByType.get('fedimint') ?? [];
const lnurlMints = announcementsByType.get('lnurl') ?? [];
events += announcements.length + federations.length + lnurlMints.length;
await ingestMintUrls(announcements, now, newMints);
await ingestFedimints(federations, now, newMints);
await ingestLnurl(lnurlMints, now, newMints);
// Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too.
const reviews = await fetchKind(KIND_REVIEW, since);
const reviews = await fetchKind(KIND_REVIEW, since, tally);
// The recent window catches anything a relay dropped from the unbounded query.
const recent =
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S, tally) : [];
const byId = new Map<string, NostrEvent>();
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;
let identifiers: string[] = [];
let baseUrl: string | null = null;
if (row.ecosystem_json) {
try {
if (row.type === 'fedimint') {
federationId =
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
} else if (row.type === 'lnurl') {
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
baseUrl = fields.base_url ?? null;
// Deduped, because a mint with no funding source has `lnurl_id` and
// `mint_pubkey` describing the same thing and a relay filter listing one
// value twice is one wasted slot.
identifiers = [...new Set(
[fields.lnurl_id, fields.mint_pubkey]
.filter((id): id is string => Boolean(id))
.map((id) => id.toLowerCase()),
)];
}
} catch {
// Nothing to ask by. The global sweep above already had its chance.
}
}
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId, identifiers, baseUrl };
});
const CONCURRENCY = 6;
let cursor = 0;
@@ -226,32 +886,97 @@ 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, tally);
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) });
}
const mode = backfill ? 'backfill' : 'incremental';
const relays = tally.list();
/*
* Name the relay, every time, one line each.
*
* A relay that would not connect is worth saying on any cycle: the address is wrong,
* or it is down, and neither gets better by itself. A relay that connected and sent
* nothing is only news on a backfill — an incremental cycle asking for the last hour
* of four kinds legitimately comes back empty, and warning about that hourly would
* train everyone to skip the line that eventually matters.
*/
for (const relay of relays) {
if (!relay.connected) {
log.warn('discovery relay unreachable', { relay: relay.url, mode });
continue;
}
if (backfill && relay.events === 0) {
log.warn('discovery relay returned no events', { relay: relay.url, mode });
} else if (!relay.eose) {
log.warn('discovery relay never reached EOSE', {
relay: relay.url,
mode,
events: relay.events,
});
}
}
/*
* The floor, and the flag the health endpoint reads.
*
* Only a backfill is measured against it. A backfill asks for the entire history of
* every announcement kind and every review, so on a working relay set it is thousands
* of events; an incremental cycle asks for one interval and is supposed to be small.
*
* The flag is sticky across incremental cycles: an hourly cycle that finds four
* events must not clear a starvation a backfill diagnosed, so a non-backfill carries
* forward whatever the last backfill concluded.
*/
let starved: boolean;
if (backfill) {
starved = events < config.backfillMinEvents;
if (starved) {
log.error('discovery starvation suspected', {
events,
floor: config.backfillMinEvents,
relays: relays.length,
silent: relays.filter((r) => r.events === 0).length,
unreachable: relays.filter((r) => !r.connected).length,
hint: 'check RELAYS: a relay list missing the announcement archive looks exactly like this',
});
}
} else {
// No backfill has ever run in this deployment: nothing has confirmed the relay set
// reads anything, and saying "fine" would be the whole original bug.
starved = (await lastDiscoveryReport())?.starved ?? true;
}
const report: DiscoveryReport = { at: now, mode, events, ok, relays, starved };
// A report that cannot be written is not worth failing a cycle over; the cycle's own
// work is already committed, and health degrades on the stale timestamp instead.
await setState(REPORT_KEY, JSON.stringify(report)).catch(() => undefined);
log.info('discovery cycle', {
mode: backfill ? 'backfill' : 'incremental',
mode,
events,
new_mints: newMints.size,
new_reviews: newReviews,
ok,
starved,
relays: relays.map((r) => `${r.url}=${r.connected ? r.events : 'down'}`).join(' '),
ms: Date.now() - started,
});
return { events, newMints: [...newMints], newReviews, ok };
return { events, newMints: [...newMints], newReviews, ok, relays, starved };
}
+108
View File
@@ -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);
}
}
+39
View File
@@ -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
View File
@@ -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);
+536
View File
@@ -0,0 +1,536 @@
/**
* On-demand indexing: `POST /api/index`.
*
* The fifth endpoint, and the first one that writes. Everything else this API serves is
* a read of rows the probe and discovery loops put there on their own schedule; this is
* a reader saying "I have an address you do not know, look at it now" and getting a page
* back in the same request.
*
* It exists because of a specific failure the site had: opening `/lnurl-mint/mint.600.wtf`
* — a live, healthy mint — returned the 404 page, because the only way into the index
* was a Nostr announcement that nobody had published. A directory whose answer to "here
* is a mint you have not got" is a 404 is a directory that can only ever list what other
* people already listed.
*
* The order of the checks below is the whole design, and each step is only reached
* because the one above it did not settle the question:
*
* 1. **Already indexed?** Then there is nothing to do and nothing to fetch. Answering
* from the row is not an optimisation, it is the correct answer: a mint's status is
* the probe loop's business, and a submission is not a reason to re-probe it.
* 2. **Does it answer, as what it claims to be?** One probe, the standard timeout,
* through the guarded fetcher in `safe-fetch.ts`.
* 3. **Does it answer as something else?** A Cashu mint pasted into the LNURL box is
* the single most likely mistake, and "that address answered, but as a Cashu mint"
* with a button to go there is worth far more than "invalid".
* 4. **Did it ever exist?** Silence over HTTPS is not proof of absence. One bounded
* relay lookup separates a mint that rugged from an address nobody ever used —
* see `relay-lookup.ts`, which is where that argument is made at length.
*
* A row written here is an ordinary row the moment it exists. The probe loop owns it
* from the next cycle, discovery will fill in its reviews, and nothing downstream can
* tell how it arrived.
*/
import {
fedimintKey,
federationIdFromInviteCode,
isIndexType,
isNut06Info,
lnurlKey,
normalizeMintUrl,
parseAdvertisement,
parsePayInfo,
PAY_INFO_PATH,
WITHDRAW_INFO_PATH,
type IndexFailure,
type IndexSource,
type IndexSuccess,
type IndexType,
type MintInfo,
} from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
import { probeLnurl } from './lnurl-probe.ts';
import {
insertFedimintFromInvite,
insertLnurlIfNew,
insertMintIfNew,
mintByHost,
mintByUrl,
upsertLnurl,
type MintRow,
} from './mints.ts';
import { applyLnurlResult, recordCashuOnline } from './probe.ts';
import { getMintDetail, resetStatsCache } from './queries.ts';
import { traceOnRelays } from './relay-lookup.ts';
import { checkDestination, guardedTextFetcher, MAX_PROBE_BYTES, safeFetchText } from './safe-fetch.ts';
import { cacheIcon } from './icons.ts';
export interface IndexOutcome {
status: 200 | 201 | 404 | 422 | 429;
body: IndexSuccess | IndexFailure;
}
function fail(
status: 404 | 422 | 429,
error: IndexFailure['error'],
message: string,
extra: Partial<IndexFailure> = {},
): IndexOutcome {
return { status, body: { error, message, ...extra } };
}
/**
* The response body for a row, in the shape `GET /api/mints/:host` returns.
*
* Read back through `getMintDetail` rather than assembled here, so the payload a
* freshly indexed mint arrives in is byte for byte the payload it will have on every
* request after this one. The 404 resolver renders the same page shell from both, and
* a second shape to keep in step would be a second shape to get wrong.
*/
async function payload(host: string, existing: boolean, source?: IndexSource): Promise<IndexOutcome> {
const detail = await getMintDetail(host);
if (!detail) {
// The row was written moments ago; a miss here means the write did not land.
return fail(422, 'invalid_response', 'The mint was indexed but could not be read back');
}
// The new row changes the counts three pages print in a sentence, and those are
// memoised for a minute. A reader who has just indexed a mint should see it counted.
if (!existing) resetStatsCache();
return {
status: existing ? 200 : 201,
body: { ...detail, existing, ...(source ? { indexed_from: source } : {}) },
};
}
/* ---------- what kind of bad input is this? ---------- */
/**
* Why `normalizeMintUrl` said no.
*
* It answers null for everything, which is right for a discovery loop reading tags and
* wrong for a person who just typed something: "that is not a URL" and "we will not
* fetch a private address" are different mistakes with different fixes.
*/
function rejectionFor(input: string): IndexFailure['error'] {
const raw = input.trim();
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(withScheme);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'bad_input';
return url.hostname ? 'blocked_host' : 'bad_input';
} catch {
return 'bad_input';
}
}
/* ---------- cashu ---------- */
/** What one guarded `/v1/info` request concluded. */
type InfoProbe =
| { state: 'mint'; info: MintInfo; latencyMs: number }
| { state: 'answered'; detail: string }
| { state: 'blocked'; reason: string }
| { state: 'silent'; reason: string };
async function probeCashuInfo(url: string): Promise<InfoProbe> {
const started = Date.now();
const outcome = await safeFetchText(`${url}/v1/info`, {
accept: 'application/json',
maxBytes: MAX_PROBE_BYTES,
});
if (outcome.state === 'blocked') return { state: 'blocked', reason: outcome.reason };
if (outcome.state === 'unreachable') return { state: 'silent', reason: outcome.reason };
if (outcome.status < 200 || outcome.status >= 300) {
return { state: 'answered', detail: `HTTP ${outcome.status} from /v1/info` };
}
let body: unknown;
try {
body = JSON.parse(outcome.body) as unknown;
} catch {
return { state: 'answered', detail: 'the response was not JSON' };
}
if (!isNut06Info(body)) {
return { state: 'answered', detail: 'the response was not a NUT-06 mint info document' };
}
return { state: 'mint', info: body, latencyMs: Date.now() - started };
}
/* ---------- lnurl ---------- */
/**
* Is this host an LNURL mint, asked cheaply, for wrong-type detection only.
*
* The full `probeLnurl` fetches four endpoints and is what runs when LNURL is what was
* claimed. This is the other direction — a Cashu submission that did not answer as one
* — and only has to answer yes or no, so it reads the two advertisements and stops.
*/
async function looksLikeLnurl(url: string): Promise<boolean> {
const get = guardedTextFetcher();
const [withdraw, pay] = await Promise.all([
get(`${url}${WITHDRAW_INFO_PATH}`, 'application/json', MAX_PROBE_BYTES),
get(`${url}${PAY_INFO_PATH}`, 'application/json', MAX_PROBE_BYTES),
]);
const parsed = (raw: { body: string } | null, parse: (value: unknown) => unknown): boolean => {
if (!raw) return false;
try {
return parse(JSON.parse(raw.body) as unknown) !== null;
} catch {
return false;
}
};
return parsed(withdraw, parseAdvertisement) || parsed(pay, parsePayInfo);
}
/** The same question the other way round, for an LNURL submission that did not parse. */
async function looksLikeCashu(url: string): Promise<boolean> {
const probe = await probeCashuInfo(url);
return probe.state === 'mint';
}
/* ---------- writing a row that nothing answered for ---------- */
/**
* Record a mint that Nostr remembers and HTTPS does not: the rugged-mint case.
*
* `consecutive_fails` is set straight to the offline threshold rather than to the one
* failure that actually happened, and that is deliberate. The ladder in `statusForFails`
* only ever counts upwards, so a row stored as `offline` with one failure behind it
* would be *promoted* to `degraded` by its next failed probe — a mint appearing to
* recover by staying dark. Three failures is what "offline" means everywhere else in
* this database, so that is what an offline row carries.
*/
async function markOfflineFromAnnouncement(
row: MintRow,
meta: { name: string | null; about: string | null; picture: string | null },
now: number,
): Promise<void> {
const db = await getDb();
const iconFile = meta.picture ? await cacheIcon(row, meta.picture) : row.icon_file;
await db.run(
`UPDATE mints SET
name = COALESCE(name, ?),
description = COALESCE(description, ?),
icon_url = COALESCE(icon_url, ?),
icon_file = ?,
status = 'offline',
consecutive_fails = 3,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
meta.name,
meta.about,
meta.picture,
iconFile,
now,
now,
row.url,
);
// One real failed probe, because one real probe really did fail. The uptime figure
// and the sparkline should show that this site looked and got nothing.
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
}
/**
* The last resort for a URL nothing answered at: ask the relays, and index what they
* remember. Returns the outcome to send back, whichever way it went.
*/
async function fromRelays(
type: 'cashu' | 'lnurl',
url: string,
now: number,
): Promise<IndexOutcome> {
const trace = await traceOnRelays(type, url);
if (!trace.found) {
return fail(
404,
'unverifiable',
'Nothing answered at that address, and no announcement or review of it exists on Nostr',
);
}
/*
* An LNURL announcement is written through the same path discovery uses, so the row
* carries its features, network and announcer exactly as it would have — but only when
* the announcement is about *this* address. A `#d` filter can also match an
* announcement whose `u` is a different URL under the same host identifier, and that
* one belongs to a different row.
*/
const useAnnouncement = type === 'lnurl' && trace.lnurl?.baseUrl === url;
const created = useAnnouncement
? await upsertLnurl(trace.lnurl!, now)
: type === 'lnurl'
? await insertLnurlIfNew(url, now)
: await insertMintIfNew(url, now);
const key = created ?? (type === 'lnurl' ? lnurlKey(url) : url);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_response', 'The mint could not be indexed');
/*
* Only a row this call brought into being is marked offline.
*
* The `existing` check upstream means there is almost always one, but an upsert can
* land on a row that was already there — and a row the probe loop has an opinion about
* is not one an unreachable submission gets to overwrite. Its status is the loop's to
* decide; this returns what is on file instead.
*/
if (created === null && row.status !== 'unknown') return payload(row.host, true);
await markOfflineFromAnnouncement(
row,
{ name: trace.name, about: trace.about, picture: trace.picture },
now,
);
log.info('indexed from nostr', {
url: key,
type,
reviews: trace.reviews,
announced: trace.announcedAt !== null,
});
return payload(row.host, false, 'announcement');
}
/* ---------- the three type paths ---------- */
async function indexFedimint(
code: string,
federationId: string,
now: number,
): Promise<IndexOutcome> {
const key = fedimintKey(federationId);
await insertFedimintFromInvite(code, federationId, now);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_invite', 'The federation could not be indexed');
log.info('indexed from invite code', { url: key });
return payload(row.host, false, 'invite');
}
async function indexCashu(url: string, host: string, now: number): Promise<IndexOutcome> {
const probe = await probeCashuInfo(url);
if (probe.state === 'blocked') {
return fail(422, 'blocked_host', `That address cannot be checked: ${probe.reason}`);
}
if (probe.state === 'answered') {
if (await looksLikeLnurl(url)) {
return fail(422, 'wrong_type', 'That address answered as an LNURL mint, not a Cashu mint', {
detected_type: 'lnurl',
});
}
return fail(422, 'invalid_response', `That address answered, but ${probe.detail}`);
}
if (probe.state === 'silent') return fromRelays('cashu', url, now);
// A live mint. Insert, then write exactly what a successful probe writes.
await insertMintIfNew(url, now);
const row = await mintByUrl(url);
if (!row) {
/*
* The URL is fine and the mint answered, so the only way here is a slug already
* held by a different URL — `host/a-b` and `host/a/b` both slug to `host-a-b`. That
* is a real limitation of the routing scheme rather than anything the reader did,
* and it is worth saying so plainly instead of claiming their mint is invalid.
*/
const holder = await mintByHost(host);
return fail(
422,
'invalid_response',
holder
? `That mint's page address is already taken by ${holder.url}`
: 'The mint could not be indexed',
);
}
await recordCashuOnline(row, probe.info, probe.latencyMs, now);
log.info('indexed on demand', { url, type: 'cashu', status: 'online' });
return payload(row.host, false, 'probe');
}
async function indexLnurl(url: string, now: number): Promise<IndexOutcome> {
// The base URL's own address is checked once here, so a blocked host is reported as
// blocked rather than as four endpoints that happened not to answer.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return fail(422, 'bad_input', 'That is not a URL');
}
const verdict = await checkDestination(parsed);
if (verdict?.kind === 'blocked') {
return fail(422, 'blocked_host', `That address cannot be checked: ${verdict.reason}`);
}
// A host whose name no longer resolves is the rugged case, not a bad submission.
if (verdict) return fromRelays('lnurl', url, now);
let result: Awaited<ReturnType<typeof probeLnurl>>;
try {
result = await probeLnurl(url, guardedTextFetcher());
} catch {
// Neither endpoint answered at all. It may still be a mint that used to be one.
return fromRelays('lnurl', url, now);
}
if (result.outcome === 'invalid') {
if (await looksLikeCashu(url)) {
return fail(422, 'wrong_type', 'That address answered as a Cashu mint, not an LNURL mint', {
detected_type: 'cashu',
});
}
return fail(
422,
'invalid_response',
`That address answered, but not as an LNURL mint: ${result.invalidReason ?? 'unrecognised response'}`,
);
}
const key = lnurlKey(url);
await insertLnurlIfNew(url, now);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_response', 'The mint could not be indexed');
await applyLnurlResult(row, result, now);
log.info('indexed on demand', { url: key, type: 'lnurl', status: 'online' });
return payload(row.host, false, 'probe');
}
/* ---------- the entry point ---------- */
/**
* In-flight submissions, keyed by what they would create.
*
* Two readers pasting the same address at the same moment — which is exactly what
* happens when a link is shared — must produce one probe and one row, not a race
* between two inserts and two visits to a stranger's mint. The second caller awaits the
* first one's promise and gets its answer.
*
* Keyed on the *normalized* identifier rather than the raw input, so `mint.600.wtf` and
* `https://mint.600.wtf/` collapse into one entry for the same reason they collapse
* into one row.
*/
const inFlight = new Map<string, Promise<IndexOutcome>>();
/** How many submissions are being worked on right now. For the checks. */
export function inFlightCount(): number {
return inFlight.size;
}
/** How many probes may be in flight at once, across every submitter. */
const MAX_IN_FLIGHT = 8;
export async function indexSubmission(rawType: string, rawInput: unknown): Promise<IndexOutcome> {
if (!isIndexType(rawType)) {
return fail(422, 'bad_type', 'type must be one of cashu, fedimint or lnurl');
}
const type: IndexType = rawType;
if (typeof rawInput !== 'string' || rawInput.trim() === '') {
return fail(422, 'bad_input', 'input is required');
}
// Long enough for any real invite code (they run to ~400 characters), short enough
// that nothing absurd reaches a parser.
const input = rawInput.trim().slice(0, 2048);
let key: string;
let url = '';
let host = '';
if (type === 'fedimint') {
const federationId = federationIdFromInviteCode(input);
if (!federationId) return fail(422, 'invalid_invite', 'That is not a Fedimint invite code');
key = fedimintKey(federationId);
} else {
const normalized = normalizeMintUrl(input);
if (!normalized) {
const reason = rejectionFor(input);
return fail(
422,
reason,
reason === 'blocked_host'
? 'That address is not a public one this site will check'
: 'That is not a mint URL',
);
}
url = normalized.url;
host = normalized.host;
key = type === 'lnurl' ? lnurlKey(url) : url;
}
/*
* The dedup check comes before every await that follows, and that ordering is the
* whole guarantee. An earlier version looked the row up first and registered the
* in-flight entry afterwards, which left a window: two requests that arrived in the
* same tick both got past the lookup before either had registered, and both probed.
* Nothing may be awaited between computing the key and claiming it.
*/
const pending = inFlight.get(key);
if (pending) return pending;
if (inFlight.size >= MAX_IN_FLIGHT) {
/*
* A global ceiling on outbound probes, under the same reason code as the per-address
* limit because it is the same answer to the reader: not now, try shortly. The
* per-address budget already stops one browser tab from doing this; this stops many
* of them at once from turning this process into a load generator pointed at
* whatever host they picked.
*/
return fail(429, 'rate_limited', 'Too many checks in flight right now', { retry_after: 30 });
}
const now = Math.floor(Date.now() / 1000);
const work = (async (): Promise<IndexOutcome> => {
/*
* Is this identifier already a row? The same lookup for all three types, by key
* first and then by routing slug, because the slug is what the insert path dedupes
* on — `https://mint.example.com/Bitcoin` and its lowercase spelling are one mint
* under two URLs, and answering "not indexed" for the second would create the
* duplicate the normalizer exists to prevent. Nothing is probed on this path.
*/
const existing =
(await mintByUrl(key)) ?? (host ? await rowByHostOfType(host, type) : undefined);
if (existing) return payload(existing.host, true);
if (type === 'fedimint') {
return indexFedimint(input.toLowerCase(), key.slice('fedimint:'.length), now);
}
if (type === 'lnurl') return indexLnurl(url, now);
return indexCashu(url, host, now);
})().finally(() => inFlight.delete(key));
inFlight.set(key, work);
return work;
}
/**
* A row holding this routing slug, but only when it belongs to the ecosystem asked
* about.
*
* An LNURL mint and a Cashu mint can share a hostname, and the LNURL one then takes the
* `lnurl-` prefixed slug (see `insertLnurlIfNew`). Without the type test, submitting
* that LNURL mint's URL would find the *Cashu* row on the bare slug and answer "already
* indexed" with the wrong mint's page.
*/
async function rowByHostOfType(host: string, type: IndexType): Promise<MintRow | undefined> {
const row = await mintByHost(host);
return row && row.type === type ? row : undefined;
}
+54 -15
View File
@@ -1,4 +1,5 @@
import { serve } from '@hono/node-server';
import { announceLnurlMints } from './announce.ts';
import { config } from './config.ts';
import { closeDb, getDb, pruneProbes } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
@@ -17,17 +18,44 @@ 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());
/*
* Announcing runs after probing, in the same cycle, because it publishes only what
* the probe just confirmed — running it on its own timer would mean signing for a
* state that could be an interval old. Off by default; see `announceConfig`.
*
* Its own failures are caught here rather than allowed to mark the probe cycle
* failed: whether relays accepted an event says nothing about whether this site
* successfully checked its mints.
*/
await track(announceLnurlMints()).catch((err: unknown) => {
log.error('announce cycle failed', {
reason: err instanceof Error ? err.message : String(err),
});
});
} 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 +65,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 +89,13 @@ function main(): void {
config.discoveryIntervalMin * 60 * 1000,
);
const pruneTimer = setInterval(() => {
const removed = pruneProbes();
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 +111,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 +128,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);
});
+274
View File
@@ -0,0 +1,274 @@
/**
* Checking an LNURL mint.
*
* Unlike a federation, which has no public endpoint and is checked by reading somebody
* else's index, an LNURL mint answers over HTTPS and this site checks it itself — the
* same relationship it has with a Cashu mint. What differs is that "answered" and
* "working" are two questions here rather than one, and the endpoint layout is not what
* the software's own README describes. `NOTES-LNURL.md` records what is actually on the
* wire; the three rules that shape this file are:
*
* 1. **There is no bare `/p`.** The mint advertisement — withdraw limits, description,
* mint pubkey, node identity — lives at `/.well-known/lnurlw/_`. The payRequest at
* `/.well-known/lnurlp/_` carries the pay-side limits and the fee, and is the
* fallback when the withdraw side does not answer.
* 2. **HTTP 200 proves nothing.** Every registered route returns 200 with an LNURL
* `{"status":"ERROR"}` body for its errors. Only the parsed body decides.
* 3. **Absence is the signal.** `None` fields are dropped from responses entirely, so
* a missing `mintPubkey` is how "the funding source is not reachable" reaches the
* wire. That is the degraded-but-online state.
*
* Nothing here calls anything that mutates. `/p/cb` would make the mint issue a real
* invoice on its operator's node, and `/w/cb` burns notes; neither is something a
* directory gets to do to a stranger on a ten minute timer. The cost of that restraint
* is that LUD-21 verify cannot be detected at all — see the kind document, which makes
* that normative rather than incidental.
*/
import {
PAY_INFO_PATH,
WITHDRAW_INFO_PATH,
addressFromPayLink,
onionFromHtml,
parseAdvertisement,
parsePayInfo,
parseSoftware,
type LnurlAdvertisement,
type LnurlPayInfo,
} from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
/**
* Body caps, per endpoint.
*
* The JSON documents are a few hundred bytes; 64KB is room for a mint with an unusually
* chatty metadata blob and nothing more. The one-pager is real HTML with an inline QR
* SVG — 13.8KB on the live instance — so it gets its own, larger cap. Both bound an
* untrusted server, in bytes, on top of the abort timer that bounds it in seconds.
*/
const MAX_JSON_BYTES = 64 * 1024;
const MAX_HTML_BYTES = 512 * 1024;
/** What one probe of one LNURL mint concluded. */
export interface LnurlProbeResult {
/**
* `online` — a mint advertisement parsed, and the mint's Lightning node answered.
* `degraded-funding` — an advertisement parsed, but the node behind it did not, so
* minting and melting are unavailable while rotate/split/merge still work. Still
* an `ok` probe: the mint is up, and this is what warnings are for.
* `invalid` — the host answered with something that is not a mint advertisement.
* Neither up nor down, and counted as a failed probe with a reason attached.
*/
outcome: 'online' | 'degraded-funding' | 'invalid';
latencyMs: number;
/** Which path answered. Stored so a page can say what was actually checked. */
endpoint: string;
advertisement: LnurlAdvertisement | null;
pay: LnurlPayInfo | null;
lightningAddress: string | null;
onionUrl: string | null;
software: string | null;
/** Short, human-readable, and only set when `outcome` is `invalid`. */
invalidReason: string | null;
}
/**
* How this module reaches a mint.
*
* A parameter rather than a hard-wired `fetch`, because there are two callers with
* genuinely different threat models. The probe loop checks addresses that reached the
* database through discovery or the seed list, and uses the plain fetcher below.
* `POST /api/index` checks an address a stranger typed thirty seconds ago, and passes
* the guarded one from `safe-fetch.ts`, which resolves DNS and refuses private ranges
* before a socket opens. The parsing, the endpoint layout and every rule about what
* counts as a mint are identical either way, which is the point of the seam.
*/
export type TextFetcher = (
url: string,
accept: string,
maxBytes: number,
) => Promise<{ body: string; status: number } | null>;
/** A fetch that is bounded in time and in bytes, and never throws for a caller. */
const fetchBounded: TextFetcher = async (
url: string,
accept: string,
maxBytes: number,
): Promise<{ body: string; status: number } | null> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: accept, 'User-Agent': config.userAgent },
redirect: 'follow',
});
const body = await readBodyBounded(res, maxBytes);
if (!body) return null;
return { body: body.toString('utf8'), status: res.status };
} catch {
// Timed out, DNS failure, TLS failure, connection reset. All the same to a caller:
// nothing was learned.
return null;
} finally {
clearTimeout(timer);
}
};
/**
* What one JSON endpoint did.
*
* Three outcomes, not two, and the middle one is why this is not simply
* `Promise<unknown | null>`: a host that answered with something that will not parse is
* *reachable*, and the site owes its readers a different sentence for that than for a
* host that is not there at all. Collapsing them would file every misconfigured proxy
* and every parked domain under "offline".
*/
type JsonProbe =
| { state: 'unreachable' }
| { state: 'unparseable'; status: number }
| { state: 'parsed'; value: unknown; status: number };
async function fetchJson(url: string, get: TextFetcher): Promise<JsonProbe> {
const res = await get(url, 'application/json', MAX_JSON_BYTES);
if (!res) return { state: 'unreachable' };
try {
return { state: 'parsed', value: JSON.parse(res.body) as unknown, status: res.status };
} catch {
return { state: 'unparseable', status: res.status };
}
}
/** The parsed body, or null for anything that did not parse. */
function jsonValue(probe: JsonProbe): unknown {
return probe.state === 'parsed' ? probe.value : null;
}
/**
* The reason string for a response that arrived but was not a mint advertisement.
*
* Kept short and specific, because it is what the "responding but invalid" banner shows
* underneath and because the three cases are genuinely different problems: a host that
* is not this software at all, a host that is but is refusing, and a host serving
* something that is not JSON.
*/
function invalidReasonFor(probe: JsonProbe): string {
if (probe.state === 'unreachable') return 'no response';
if (probe.state === 'unparseable') {
// Almost always an HTML error page from a proxy, or a parked domain. The status
// code is the only part of it worth repeating back.
return `HTTP ${probe.status}, and the body was not JSON`;
}
const body = probe.value;
if (body === null || body === undefined) return 'empty JSON response';
if (typeof body !== 'object' || Array.isArray(body)) return 'response was not a JSON object';
const record = body as Record<string, unknown>;
// The LNURL error convention: 200 with a status/reason pair. Common and informative.
if (record['status'] === 'ERROR') {
const reason = typeof record['reason'] === 'string' ? record['reason'].slice(0, 120) : '';
return reason ? `mint replied: ${reason}` : 'mint replied with an LNURL error';
}
if (typeof record['tag'] === 'string') return `unexpected tag "${String(record['tag']).slice(0, 40)}"`;
if (record['detail'] !== undefined) return 'endpoint not found on this host';
return 'response was not a mint advertisement';
}
/**
* Probe one LNURL mint.
*
* The withdraw side first, because it is the only endpoint carrying everything the site
* renders. The payRequest is fetched too, but for different reasons in the two cases:
* alongside a good advertisement it adds the fee, the pay-side limits and a
* human-written description; when the advertisement failed it is the fallback that can
* still prove the host is a live LNURL mint whose withdraw alias is simply configured
* under a username this probe cannot guess.
*
* The two opportunistic reads — the one-pager for a Tor address, `/openapi.json` for a
* version — never affect the outcome. A mint is not less online because its operator
* turned off the docs endpoint.
*/
export async function probeLnurl(
baseUrl: string,
get: TextFetcher = fetchBounded,
): Promise<LnurlProbeResult> {
const started = Date.now();
const [withdrawProbe, payProbe] = await Promise.all([
fetchJson(`${baseUrl}${WITHDRAW_INFO_PATH}`, get),
fetchJson(`${baseUrl}${PAY_INFO_PATH}`, get),
]);
const advertisement = parseAdvertisement(jsonValue(withdrawProbe));
const pay = parsePayInfo(jsonValue(payProbe));
const latencyMs = Date.now() - started;
const base = {
latencyMs,
advertisement,
pay,
lightningAddress: addressFromPayLink(advertisement?.payLink) ?? null,
};
if (!advertisement && !pay) {
/*
* Nothing parsed on either side. Distinguish "the host said something" from "the
* host said nothing at all": a timeout is an ordinary failed probe and feeds the
* consecutive-fails machinery as a plain offline, while a reply that is not a mint
* advertisement is the invalid state and gets to say why.
*/
const answered =
withdrawProbe.state !== 'unreachable' || payProbe.state !== 'unreachable';
if (!answered) throw new Error('no response from either LNURL endpoint');
/*
* Report on whichever endpoint actually said something, preferring the withdraw
* side. A host whose withdraw alias times out while its payRequest returns an HTML
* error page should say what the payRequest did, not "no response".
*/
const reported = withdrawProbe.state === 'unreachable' ? payProbe : withdrawProbe;
return {
...base,
outcome: 'invalid',
endpoint: withdrawProbe.state === 'unreachable' ? PAY_INFO_PATH : WITHDRAW_INFO_PATH,
onionUrl: null,
software: null,
invalidReason: invalidReasonFor(reported),
};
}
// Only worth two extra requests once the host has proved it is a mint.
const [html, openapi] = await Promise.all([
get(`${baseUrl}/`, 'text/html', MAX_HTML_BYTES),
fetchJson(`${baseUrl}/openapi.json`, get),
]);
const extras = {
onionUrl: html ? onionFromHtml(html.body) : null,
software: parseSoftware(jsonValue(openapi)),
invalidReason: null,
};
if (!advertisement) {
/*
* The payRequest answered and the withdraw alias did not.
*
* Still online — the host is demonstrably a live LNURL mint — but the site has no
* withdraw limits, no mint pubkey and no node identity for it, and `funding_available`
* stays unknown rather than being guessed at from the pay side. `/.well-known/lnurlp/_`
* is recorded as the endpoint so the page says what was actually checked.
*/
return { ...base, ...extras, outcome: 'online', endpoint: PAY_INFO_PATH };
}
return {
...base,
...extras,
outcome: advertisement.fundingAvailable ? 'online' : 'degraded-funding',
endpoint: WITHDRAW_INFO_PATH,
};
}
+201
View File
@@ -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);
}
}
+450 -27
View File
@@ -1,23 +1,36 @@
import { normalizeMintUrl } from '@cashumints/shared';
import {
LNURL_SLUG_PREFIX, fedimintKey, fedimintSlug, lnurlIdentifier, lnurlKey, normalizeMintUrl,
type FedimintAnnouncement, type FedimintFields, type LnurlAnnouncement, type LnurlFields,
} 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', 'fedimint' or 'lnurl'. 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, `LnurlFields` for an LNURL
* mint, null for a Cashu one. Read it back through `parseEcosystem`.
*/
ecosystem_json: string | null;
nuts_json: string | null;
version: string | null;
status: string;
@@ -44,7 +57,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 +70,448 @@ 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 row's type-specific column back out.
*
* Generic, defaulting to `FedimintFields` so every existing call site reads exactly as
* it did. The caller already knows the row's `type` — it is what decided to call this
* at all — so the type argument is a statement of what was stored, not a guess.
*
* A column that will not parse degrades to null rather than throwing. Such a row still
* has a page and still resolves by its key; it simply has no ecosystem-specific fields
* on it, which is honest and is better than a probe cycle dying on one bad row.
*/
export function parseEcosystem<T = FedimintFields>(
row: Pick<MintRow, 'ecosystem_json'>,
): T | null {
if (!row.ecosystem_json) return null;
try {
return JSON.parse(row.ecosystem_json) as T;
} 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;
}
/**
* Insert a federation from an invite code alone, with no announcement behind it.
*
* For `POST /api/index`: a reader pastes the one thing they have, and there is nothing
* else to go on. The id comes out of the code itself (`federationIdFromInviteCode`), so
* the row is keyed exactly as an announced one would be and the two can never become
* two rows for one federation.
*
* `announced` rather than `unknown`, and the distinction is the same one the status
* carries everywhere else: `unknown` means nothing has checked yet, `announced` means
* there is nothing this site *can* check. A federation has no public endpoint, so an
* invite code is exactly as much as anyone will ever be able to confirm from here until
* the observer lookup covers it.
*
* Returns the row key when a row was created, null when one already existed.
*/
export async function insertFedimintFromInvite(
code: string,
federationId: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = fedimintKey(federationId);
const fields: FedimintFields = {
federation_id: federationId,
invite_codes: [code.trim().toLowerCase()],
// Everything an announcement would have carried. Discovery fills these in when one
// turns up, and until then the page says only what the code itself proves.
modules: [],
network: null,
announced_at: null,
announcer_pubkey: null,
status_source: null,
};
const result = await db.run(
`INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at)
VALUES (?, ?, 'fedimint', ?, 'announced', ?, ?)
ON CONFLICT DO NOTHING`,
url,
fedimintSlug(federationId),
JSON.stringify(fields),
now,
now,
);
return result.changes > 0 ? url : 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'`);
}
/* ---------- lnurl ---------- */
/**
* The routing slug an LNURL mint gets, given what the table already holds.
*
* `mints.host` carries a UNIQUE index across every ecosystem, so a Cashu mint and an
* LNURL mint on one hostname would compete for a single slug and — with the existing
* insert path — the loser would silently not be tracked at all. That is the one outcome
* worth writing code to avoid: a mint that exists, is reviewable, and has no page.
*
* So the clean slug is used whenever it is free, which is very nearly always, and a
* colliding row takes `lnurl-` in front instead. Decided once at insert and then stored,
* never recomputed: a mint that took the prefixed slug keeps it even if the row it
* collided with later disappears, because its URL is already indexed and linked and a
* silently moving page is worse than a slightly long one.
*/
async function lnurlSlug(preferred: string, url: string): Promise<string | null> {
const db = await getDb();
const takenBy = async (slug: string): Promise<string | null> => {
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', slug);
return row && row.url !== url ? row.url : null;
};
const clash = await takenBy(preferred);
if (!clash) return preferred;
const prefixed = LNURL_SLUG_PREFIX + preferred;
const secondClash = await takenBy(prefixed);
if (!secondClash) {
log.info('lnurl slug taken, using prefixed form', { url, slug: prefixed, existing: clash });
return prefixed;
}
log.warn('lnurl slug collision, mint not tracked', { url, slug: preferred, existing: clash });
return null;
}
/** The `ecosystem_json` an LNURL row starts life with, before anything has probed it. */
function blankLnurlFields(baseUrl: string, identifier: string): LnurlFields {
return {
lnurl_id: identifier,
base_url: baseUrl,
features: [],
network: null,
announced_at: null,
announcer_pubkey: null,
mint_pubkey: null,
funding_available: null,
probe_endpoint: null,
invalid_reason: null,
min_withdrawable_msat: null,
max_withdrawable_msat: null,
min_sendable_msat: null,
max_sendable_msat: null,
fee_base_msat: null,
fee_ppm: null,
lightning_address: null,
onion_url: null,
node_alias: null,
node_uri: null,
node_capacity_msat: null,
node_channels: null,
node_peers: null,
observed_features: [],
};
}
/**
* Insert an LNURL mint by URL alone, with no announcement behind it.
*
* For the seed list and for operators added by hand. `status` starts `unknown` and the
* very next probe cycle decides it, exactly as a seeded Cashu mint works — nothing here
* claims the mint is up, and `insertMintIfNew`'s rule that an existing row is never
* touched applies just as strictly.
*
* Returns the row key when a row was created, null otherwise.
*/
export async function insertLnurlIfNew(
rawUrl: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
reportedSkips.add(rawUrl);
log.warn('skipped lnurl url', { url: rawUrl.slice(0, 80) });
}
return null;
}
const db = await getDb();
const url = lnurlKey(normalized.url);
const existing = await db.get<{ url: string }>('SELECT url FROM mints WHERE url = ?', url);
if (existing) return null;
const host = await lnurlSlug(normalized.host, url);
if (host === null) return null;
const result = await db.run(
`INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at)
VALUES (?, ?, 'lnurl', ?, 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
JSON.stringify(blankLnurlFields(normalized.url, lnurlIdentifier(normalized.url, null))),
now,
now,
);
return result.changes > 0 ? url : null;
}
/**
* Insert or refresh an LNURL mint from a kind 38174 announcement.
*
* **Deduped on the normalized base URL, not on the `d` tag**, and that is the whole
* reason this function is not a copy of `upsertFedimint`. An LNURL mint's identifier is
* its funding node's pubkey when it has one and its host otherwise, so the same mint
* legitimately appears under two different `d` values across its life — announced by
* host before a funding source was configured, by pubkey afterwards. Keying rows on `d`
* would split one mint into two pages with half its reviews on each. The `u` tag is the
* one value present in every form of the event, so it is the key.
*
* Unlike `insertMintIfNew` this does update an existing row, because an announcement is
* the only source of `features`, the network and the operator's own name for the mint.
* Older announcements are ignored (`announced_at` goes forwards only) so a replayed
* event from last year cannot overwrite this week's capability list.
*
* Two things are deliberately never written here:
*
* - **The status columns.** Whether a mint is up is a probe's answer. An announcement
* is not evidence of anything being up.
* - **Anything a probe learned.** `mint_pubkey`, the limits, the address, the node
* fields and `funding_available` are all carried over from the existing row
* untouched. In particular a `mint_pubkey` already observed is *never* cleared by an
* announcement that lacks one: identity is sticky, per the kind document.
*
* Returns the row's key when a row was created, null when one was merely updated.
*/
export async function upsertLnurl(
announcement: LnurlAnnouncement,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = lnurlKey(announcement.baseUrl);
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
const previous = existing ? parseEcosystem<LnurlFields>(existing) : null;
if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null;
/*
* The stored pubkey wins over the announcement's.
*
* A probe reads `mintPubkey` off the mint itself; an announcement is a stranger's
* claim about it. Where both exist the observed one is the better evidence, and where
* only the announcement has one it is still worth keeping, so this prefers the probe
* and falls back rather than overwriting in either direction.
*/
const mintPubkey = previous?.mint_pubkey ?? announcement.mintPubkey;
const fields: LnurlFields = {
...(previous ?? blankLnurlFields(announcement.baseUrl, announcement.identifier)),
lnurl_id: lnurlIdentifier(announcement.baseUrl, mintPubkey),
base_url: announcement.baseUrl,
features: announcement.features,
network: announcement.network,
announced_at: announcement.announcedAt,
announcer_pubkey: announcement.announcerPubkey,
mint_pubkey: mintPubkey,
};
if (!existing) {
const host = await lnurlSlug(announcement.slug, url);
if (host === null) return null;
await db.run(
`INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json,
status, first_seen, updated_at)
VALUES (?, ?, 'lnurl', ?, ?, ?, ?, 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
now,
);
return url;
}
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),
now,
url,
);
return null;
}
/** Every LNURL row, for the probe cycle and for review resolution. */
export async function lnurlRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
}
/** 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;
}
+517 -48
View File
@@ -1,9 +1,15 @@
import { parseNuts, type MintInfo } from '@cashumints/shared';
import {
lnurlIdentifier, observedFeatures, parseNuts,
type FedimintFields, type LnurlFields, 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 { probeLnurl } from './lnurl-probe.ts';
import { allMintRows, mintByUrl, parseEcosystem, type MintRow } from './mints.ts';
export interface ProbeResult {
url: string;
@@ -19,45 +25,34 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
return fails < 3 ? 'degraded' : 'offline';
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const info = (await res.json()) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* 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;
/**
* Probe one mint and write the result.
* Write everything a successful Cashu probe learned, and record the probe.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
* Split out of `probeMint` because the on-demand indexer has already made this exact
* request: a reader pastes a mint URL, `POST /api/index` fetches `/v1/info` once
* through the guarded fetcher to decide whether the address is a mint at all, and then
* needs the row to end up in precisely the state a probe cycle would have left it in.
* Re-probing to achieve that would hit a stranger's mint twice for one submission and
* would leave two ways for "what a good probe writes" to drift apart.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
export async function recordCashuOnline(
row: MintRow,
info: MintInfo,
latencyMs: number,
now = Math.floor(Date.now() / 1000),
): Promise<void> {
const db = await getDb();
const nuts = parseNuts(info.nuts);
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 +68,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,11 +82,52 @@ 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,
);
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
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 };
} finally {
clearTimeout(timer);
}
}
/**
* Probe one mint and write the result.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* 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 = await getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
await recordCashuOnline(row, info, latencyMs, now);
const changed = row.status !== 'online';
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: 'online' });
@@ -102,12 +137,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 +168,403 @@ 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;
}
/* ---------- lnurl ---------- */
/**
* Probe one LNURL mint and write the result.
*
* Same shape as `probeMint` and the same guarantee: on failure the cached metadata
* columns are left untouched, which is what lets an offline LNURL mint still render its
* full page with a banner over it and a working review form. The rug-review guarantee
* covers all three ecosystems, and this is the half of it that lives in the indexer.
*
* Three outcomes rather than two, and the middle one is the point:
*
* - **online** — an advertisement parsed and the mint's Lightning node answered.
* - **degraded-funding** — an advertisement parsed and the node did not. The mint is
* up: it is serving, its limits are real, and rotate/split/merge still work. It is
* recorded as a *successful* probe with `funding_available: false`, which the
* warnings turn into "minting and melting unavailable". Storing it as `degraded`
* status instead would be wrong twice over — that value already means "one or two
* checks failed" here, and none did — and this is exactly the shape the Cashu side
* already uses for a mint that answers perfectly while refusing to move sats.
* - **invalid** — the host answered with something that is not a mint advertisement.
* Counted as a failed probe, because nothing about the mint was confirmed, but with
* `invalid_reason` recorded so the page can say "responding but invalid" instead of
* the flatly wrong "offline".
*/
async function probeLnurlMint(row: MintRow): Promise<ProbeResult> {
const now = Math.floor(Date.now() / 1000);
const previous = parseEcosystem<LnurlFields>(row);
const baseUrl = previous?.base_url ?? row.url.replace(/^lnurl:/, '');
try {
return await applyLnurlResult(row, await probeLnurl(baseUrl), now);
} catch (err) {
return await recordLnurlFailure(row, previous, err, now);
}
}
/**
* Write what one LNURL probe concluded, whichever of the three things it concluded.
*
* Split out of `probeLnurlMint` for the same reason `recordCashuOnline` was: the
* on-demand indexer runs `probeLnurl` itself, through the guarded fetcher, and the row
* it creates has to land in exactly the state a probe cycle would have left it in. One
* writer, two callers.
*/
export async function applyLnurlResult(
row: MintRow,
result: Awaited<ReturnType<typeof probeLnurl>>,
now = Math.floor(Date.now() / 1000),
): Promise<ProbeResult> {
const db = await getDb();
const previous = parseEcosystem<LnurlFields>(row);
const baseUrl = previous?.base_url ?? row.url.replace(/^lnurl:/, '');
if (result.outcome === 'invalid') {
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
const fields: LnurlFields = {
...(previous ?? emptyLnurlFields(baseUrl)),
invalid_reason: result.invalidReason,
probe_endpoint: result.endpoint,
};
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, ecosystem_json = ?,
last_probe = ?, updated_at = ?
WHERE url = ?`,
fails,
status,
JSON.stringify(fields),
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url, from: row.status, to: status, fails, reason: result.invalidReason,
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
const ad = result.advertisement;
/*
* `mint_pubkey` is sticky: `?? previous?.mint_pubkey` and never the other way round.
* A node that was unreachable for this one request drops the field from the
* response, and letting that clear a pubkey the site has already seen would change
* the mint's Nostr identity — its `d` tag — every time its node hiccuped. See
* "When a mint gains a pubkey after being announced by host" in the kind document.
*/
const mintPubkey = ad?.mintPubkey ?? previous?.mint_pubkey ?? null;
const fields: LnurlFields = {
...(previous ?? emptyLnurlFields(baseUrl)),
lnurl_id: lnurlIdentifier(baseUrl, mintPubkey),
base_url: baseUrl,
mint_pubkey: mintPubkey,
/*
* Left as whatever it was when the payRequest fallback answered and the withdraw
* side did not: that endpoint carries no node section at all, so its silence is
* not evidence either way. Only a parsed advertisement gets to set this.
*/
funding_available: ad ? ad.fundingAvailable : previous?.funding_available ?? null,
probe_endpoint: result.endpoint,
invalid_reason: null,
min_withdrawable_msat: ad?.minWithdrawableMsat ?? previous?.min_withdrawable_msat ?? null,
max_withdrawable_msat: ad?.maxWithdrawableMsat ?? previous?.max_withdrawable_msat ?? null,
min_sendable_msat: result.pay?.minSendableMsat ?? previous?.min_sendable_msat ?? null,
max_sendable_msat: result.pay?.maxSendableMsat ?? previous?.max_sendable_msat ?? null,
fee_base_msat: result.pay?.feeBaseMsat ?? null,
fee_ppm: result.pay?.feePpm ?? null,
lightning_address: result.lightningAddress ?? previous?.lightning_address ?? null,
onion_url: result.onionUrl ?? null,
node_alias: ad?.nodeAlias ?? null,
node_uri: ad?.nodeUri ?? null,
node_capacity_msat: ad?.nodeCapacityMsat ?? null,
node_channels: ad?.nodeChannels ?? null,
node_peers: ad?.nodePeers ?? null,
/*
* What this probe actually saw, kept beside — never merged into — whatever the
* operator announced. It is the only capability list a mint nobody has announced
* has, which today is every LNURL mint on the network, and it is what the page
* renders when `features` is empty. `displayFeatures` does the joining.
*/
observed_features: observedFeatures({
fundingAvailable: ad ? ad.fundingAvailable : null,
maxWithdrawableMsat: ad?.maxWithdrawableMsat ?? null,
maxSendableMsat: result.pay?.maxSendableMsat ?? null,
lightningAddress: result.lightningAddress,
mintPubkey,
onionUrl: result.onionUrl,
}),
};
const iconFile = await cacheIcon(row, row.icon_url);
/*
* `COALESCE(description, ?)`, the opposite way round from the Cashu probe.
*
* There, `/v1/info` is the mint's own word about itself and rightly overwrites a
* cached value. Here the candidate is a string the mint *software* generates —
* "Mint an lnurlcash bearer note on {host}" is a template, not a sentence anyone
* wrote — so it fills a gap and never displaces an operator's own announcement
* metadata.
*
* `name` is deliberately not written at all. The only candidate the endpoints offer
* is `nodeAlias`, and that is the *Lightning node's* name, not the mint's: the
* software's own one-pager prints it under a "Node" heading, separate from the
* mint's title. Calling a mint after its node would be a small invention, and the
* existing fallback — the hostname — is both true and what a reader typed to get
* here. The alias is kept in `ecosystem_json` and shown in the sidebar, where it is
* labelled as what it is.
*/
await db.run(
`UPDATE mints SET
description = COALESCE(description, ?),
icon_file = ?,
ecosystem_json = ?,
version = COALESCE(?, version),
status = 'online',
consecutive_fails = 0,
last_online = ?,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
result.pay?.description ?? ad?.defaultDescription ?? null,
iconFile,
JSON.stringify(fields),
result.software,
now,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
row.url,
now,
result.latencyMs,
);
const changed = row.status !== 'online' || previous?.funding_available !== fields.funding_available;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: 'online',
funding: fields.funding_available === false ? 'unavailable' : 'ok',
});
}
return { url: row.url, ok: true, latencyMs: result.latencyMs, status: 'online', changed };
}
/**
* Write a failed LNURL probe: the host said nothing at all.
*
* Its own function only because two callers reach it — the probe cycle and the
* on-demand indexer, which treats a host that never answered as a candidate for the
* Nostr lookup rather than as a failure to report immediately.
*/
async function recordLnurlFailure(
row: MintRow,
previous: LnurlFields | null,
err: unknown,
now: number,
): Promise<ProbeResult> {
const db = await getDb();
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
/*
* A host that said nothing at all is offline, not invalid. Clearing `invalid_reason`
* here matters: a mint that spent a day serving junk and then went dark should stop
* showing "responding but invalid" the moment it stops responding.
*/
if (previous?.invalid_reason) {
await db.run(
'UPDATE mints SET ecosystem_json = ? WHERE url = ?',
JSON.stringify({ ...previous, invalid_reason: null } satisfies LnurlFields),
row.url,
);
}
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
fails,
status,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: status,
fails,
reason: err instanceof Error ? err.message : String(err),
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
/** The `ecosystem_json` shape a row falls back to when its own will not parse. */
function emptyLnurlFields(baseUrl: string): LnurlFields {
return {
lnurl_id: lnurlIdentifier(baseUrl, null),
base_url: baseUrl,
features: [],
network: null,
announced_at: null,
announcer_pubkey: null,
mint_pubkey: null,
funding_available: null,
probe_endpoint: null,
invalid_reason: null,
min_withdrawable_msat: null,
max_withdrawable_msat: null,
min_sendable_msat: null,
max_sendable_msat: null,
fee_base_msat: null,
fee_ppm: null,
lightning_address: null,
onion_url: null,
node_alias: null,
node_uri: null,
node_capacity_msat: null,
node_channels: null,
node_peers: null,
observed_features: [],
};
}
/** 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 +577,62 @@ 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);
/*
* Three ecosystems, three checks.
*
* Two of them are an HTTP fetch per row and one is a single lookup covering every
* federation, so the federations are split out rather than each waiting behind a slot
* in the fetch pool. Cashu and LNURL mints share that pool — they are the same kind of
* work, a handful of small HTTPS requests each — and are told apart inside the worker
* by `type` rather than by two pools that would each idle waiting for the other.
*
* The default arm is Cashu, not LNURL: a row of some type this build has never heard
* of is far more likely to be an older ecosystem than a newer one, and `/v1/info` is
* what every row written before the column existed means.
*/
const federations = targets.filter((row) => row.type === 'fedimint');
const fetched = 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(fetched, config.probeConcurrency, async (row) => {
results.push(row.type === 'lnurl' ? await probeLnurlMint(row) : 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: fetched.filter((row) => row.type !== 'lnurl').length,
lnurl: fetched.filter((row) => row.type === 'lnurl').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);
}
+290 -87
View File
@@ -3,18 +3,22 @@ import {
compareMints,
NEUTRAL_PRIOR_MEAN,
parseNuts,
readCapabilities,
type Health,
type MintDetail,
type MintInfo,
type MintListItem,
type MintStatus,
type MintType,
type ProbeSample,
type RatingDistribution,
type LnurlFields,
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 { lastDiscoveryReport } from './discovery.ts';
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
/**
* One review per author per mint, newest wins.
@@ -23,15 +27,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 +48,26 @@ interface AggRow {
last_review_at: number | null;
}
function aggregates(): Map<string, AggRow> {
const rows = getDb()
.prepare(
/**
* 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 GROUP BY mint_url`,
)
.all() as AggRow[];
FROM latest ${where} GROUP BY mint_url`,
...params,
);
return new Map(rows.map((r) => [r.mint_url, r]));
}
@@ -64,7 +80,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 +89,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;
@@ -90,6 +107,39 @@ function round1(n: number | null): number | null {
return n === null ? null : Math.round(n * 10) / 10;
}
/**
* The NUT numbers a row publishes.
*
* `nuts_json` is what the prober wrote and wins; `info.nuts` is the raw NUT-06 object it
* was derived from, kept as a fallback for rows written before that column existed. One
* function so a list card and a detail page can never read a different answer off the
* same row.
*/
function rowNuts(row: MintRow, info: MintInfo | null): string[] {
if (row.nuts_json) {
try {
const parsed = JSON.parse(row.nuts_json) as string[];
if (parsed.length > 0) return parsed;
} catch {
// Fall through to the info object below.
}
}
return info ? parseNuts(info.nuts) : [];
}
/**
* One list item.
*
* The chip fields at the bottom are why this now parses `info_json`. The alternative was
* what /mints and /lnurl-mints used to do: fetch `GET /api/mints/:host` once per mint to
* read two booleans off each one. That is an acceptable price for a build machine
* rendering fifty-five cards once a night and an unacceptable one for every browser that
* opens the page, which is what the list has to survive now that it hydrates.
*
* Facts, not sentences. `capabilities` is two booleans and `mintChip` turns them into
* "Melt only" in the reader's language, wherever the card is being drawn. Rendering the
* label here would ship one language to twenty-four locales.
*/
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
const base = {
review_count: agg?.review_count ?? 0,
@@ -98,11 +148,21 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
last_review_at: agg?.last_review_at ?? null,
};
const info = parseInfo(row.info_json);
// A federation and an LNURL mint have no `info_json` and so get null, which is the
// honest value: not "both NUTs are enabled", but "there is nothing here to read".
const capabilities = info ? readCapabilities(info.nuts) : null;
// Only the two facts the LNURL chip is drawn from, not the whole ecosystem blob: this
// payload is fetched by every visitor on three pages.
const lnurl = row.type === 'lnurl' ? parseEcosystem<LnurlFields>(row) : null;
return {
url: row.url,
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,
@@ -110,29 +170,49 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
score: bayesianScore(base, mean, now),
last_review_at: base.last_review_at,
version: row.version,
nuts: rowNuts(row, info),
capabilities,
...(lnurl
? {
max_withdrawable_msat: lnurl.max_withdrawable_msat ?? null,
funding_available: lnurl.funding_available ?? null,
}
: {}),
};
}
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(
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`,
)
.all(mintUrl) as { rating: number; n: number }[];
mintUrl,
);
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
for (const r of rows) {
@@ -142,34 +222,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(
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 >= ?`,
)
.get(mintUrl, cutoff) as { n: number };
return row.n;
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(
const db = await getDb();
return db.all<ProbeSample>(
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
)
.all(mintUrl, cutoff) as ProbeSample[];
mintUrl,
cutoff,
);
}
function parseInfo(json: string | null): MintInfo | null {
@@ -181,27 +266,42 @@ 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);
const info = parseInfo(row.info_json);
// 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),
]);
let nuts: string[] = [];
if (row.nuts_json) {
try {
nuts = JSON.parse(row.nuts_json) as string[];
} catch {
nuts = [];
}
}
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
const item = toListItem(row, agg.get(row.url), mean, now);
const info = parseInfo(row.info_json);
// `item.nuts` is the same read, through `rowNuts`. It used to be computed a second
// time here with a subtly different fallback rule; one function now answers for both.
const nuts = item.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,67 +309,170 @@ 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, lnurl, lnurlFunding, reviews, fedimintReviews, lnurlReviews] =
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(*) 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<{ total: number; online: number; offline: number }>(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'online') AS online,
COUNT(*) FILTER (WHERE status = 'offline') AS offline
FROM mints WHERE type = 'lnurl'`,
),
/*
* The degraded-funding count is read in JavaScript rather than in SQL, because the
* flag lives inside `ecosystem_json` and neither a `LIKE '%"funding_available":false%'`
* (which depends on how the two drivers happen to serialise) nor a JSON operator
* (which SQLite and Postgres spell differently) is portable. There are a handful of
* these rows, the whole result is memoised for a minute, and a correct answer on
* both backends is worth one small scan.
*/
db.all<{ ecosystem_json: string | null }>(
`SELECT ecosystem_json FROM mints WHERE type = 'lnurl' AND status = 'online'`,
),
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')`,
),
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 = 'lnurl')`,
),
]);
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 };
let lnurlDegradedFunding = 0;
for (const row of lnurlFunding) {
const fields = parseEcosystem<LnurlFields>(row);
if (fields?.funding_available === false) lnurlDegradedFunding++;
}
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,
lnurl_total: lnurl?.total ?? 0,
lnurl_online: lnurl?.online ?? 0,
lnurl_offline: lnurl?.offline ?? 0,
lnurl_degraded_funding: lnurlDegradedFunding,
lnurl_reviews: lnurlReviews?.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.
*
* Three things can degrade it, and they are three different failures:
*
* probeStale nothing has checked a mint in three intervals
* !discoveryOk the last discovery cycle threw
* report.starved the last backfill read less than BACKFILL_MIN_EVENTS
*
* The third is the one added after the postmortem, and it is the only one that would
* have caught a year of the index sitting at eight mints: the cycles were completing,
* `ok` was true, the probes were fresh, and the relay list simply did not contain the
* relay holding the archive. "Ran without throwing" is not the same claim as "read
* anything", and only the second one is worth a green light.
*
* A deployment with an empty database reports degraded until its first backfill lands,
* because until then nothing has confirmed the relay set reads anything at all. That is
* intended: it holds `cashumints-web.service` at its health gate rather than letting it
* publish a site built from nothing.
*/
export async function getHealth(): Promise<Health> {
const now = Math.floor(Date.now() / 1000);
const db = await getDb();
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = 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'),
lastDiscoveryReport(),
]);
const discoveryOk = discoveryOkRaw !== '0';
const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
// No report at all is starvation by default: see the note above.
const starved = report?.starved ?? true;
return {
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
status: probeStale || !discoveryOk || starved ? 'degraded' : 'ok',
uptime_s: now - startedAt,
last_probe_at: lastProbe,
last_discovery_at: lastDiscovery,
mints_tracked: tracked,
mints_tracked: tracked?.n ?? 0,
updated_at: now,
discovery_relays: report?.relays ?? [],
last_discovery_events: report?.events ?? null,
last_discovery_mode: report?.mode ?? null,
discovery_starved: starved,
backfill_min_events: config.backfillMinEvents,
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* A per-address budget for the one endpoint that does work on a stranger's behalf.
*
* Every other route reads rows this process already has. `POST /api/index` resolves
* DNS, opens a socket to an address somebody chose, and may query five relays, so it is
* the one place where a loop in a browser tab costs this server real outbound work —
* and costs whoever is at the other end an unexpected visitor.
*
* In-process and in-memory, deliberately. The alternatives were considered and both are
* worse here: a table would put a write on the path of every submission for a counter
* that may be forgotten at any time, and doing it in nginx would put the limit in a
* file the application cannot see, cannot test, and does not ship — this repository's
* README documents an nginx block that a deployment is free to edit, so a limit that
* lives only there is a limit that silently varies per deployment. The cost of keeping
* it here is that a restart forgives everyone and a second process would double the
* allowance; at ten an hour, neither matters. BACKEND.md records the choice.
*/
/**
* Submissions one address may make per window.
*
* Ten by default, which is BACKEND.md's number and is several times what any honest use
* of the dialog needs. `INDEX_RATE_LIMIT` raises or lowers it, which exists for two
* real cases rather than as reflexive configurability: exercising the whole flow
* end to end trips a limit of ten in about a minute, and a deployment behind a proxy
* that does not forward the client address has every visitor sharing one bucket until
* it does. Both are better served by a number than by a code change.
*/
export const RATE_LIMIT = (() => {
const raw = Number.parseInt(process.env['INDEX_RATE_LIMIT'] ?? '', 10);
return Number.isFinite(raw) && raw > 0 ? raw : 10;
})();
/** The window those submissions are counted over. */
export const RATE_WINDOW_MS = 60 * 60 * 1000;
/** Addresses tracked at once. Past this the oldest bucket is dropped, not the newest. */
const MAX_TRACKED = 5000;
const hits = new Map<string, number[]>();
export interface RateVerdict {
ok: boolean;
/** Submissions left in this window after this one. */
remaining: number;
/** Seconds until the oldest counted submission falls out of the window. */
retryAfter: number;
}
/**
* Count one submission from `key`, and say whether it is allowed.
*
* A sliding window over timestamps rather than a fixed bucket, so ten submissions at
* 10:59 do not give a fresh ten at 11:00. A refused submission is *not* counted: being
* over the limit should not extend the wait every time the reader presses the button
* again, which is what turns a rate limit into a lockout.
*/
export function takeToken(key: string, now = Date.now()): RateVerdict {
const cutoff = now - RATE_WINDOW_MS;
const recent = (hits.get(key) ?? []).filter((at) => at > cutoff);
if (recent.length >= RATE_LIMIT) {
hits.set(key, recent);
const oldest = recent[0] ?? now;
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((oldest + RATE_WINDOW_MS - now) / 1000)),
};
}
recent.push(now);
hits.set(key, recent);
if (hits.size > MAX_TRACKED) sweep(cutoff);
return { ok: true, remaining: RATE_LIMIT - recent.length, retryAfter: 0 };
}
/** Drop every bucket with nothing left in the window. Called only when the map grows. */
function sweep(cutoff: number): void {
for (const [key, times] of hits) {
const live = times.filter((at) => at > cutoff);
if (live.length === 0) hits.delete(key);
else hits.set(key, live);
}
}
/** Forget every counter. For tests, which must not inherit each other's budgets. */
export function resetRateLimits(): void {
hits.clear();
}
+173
View File
@@ -0,0 +1,173 @@
/**
* "Has anyone ever heard of this mint?"
*
* The last question `POST /api/index` asks before giving up, and the one that makes the
* rugged-mint case work. A reader pastes an address; nothing answers at it. That is two
* different situations wearing the same silence:
*
* - a typo, a dead domain, an address nobody has ever used — nothing to index; or
* - a mint that ran for two years, took people's money, and went dark last week.
*
* The second is exactly the mint somebody most wants to write a review of, and it is
* the one this site exists to keep a page for. HTTP cannot tell them apart, but Nostr
* can: a mint that was ever real has an announcement, or reviews, or both, and a typo
* has neither. So before answering "we cannot verify that", the endpoint spends one
* bounded query asking the relay pool.
*
* Bounded is the operative word. This runs inside a request a person is waiting on, so
* it gets one round of filters and `RELAY_LOOKUP_MS` to answer them; whatever has
* arrived by then is the answer. The discovery loop is where exhaustive paging lives —
* it runs on a timer, with nobody watching — and the row this creates is an ordinary
* row that the next discovery cycle will fill in properly.
*/
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
KIND_LNURL_ANNOUNCEMENT,
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
hostIdentifier,
mintUrlSpellings,
parseAnnouncementMetadata,
parseLnurlAnnouncement,
reviewEcosystem,
type LnurlAnnouncement,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getPool } from './discovery.ts';
import { log } from './log.ts';
/**
* How long the relays get, in total, for the whole lookup.
*
* Three seconds, which is the number BACKEND.md now records for this endpoint. It is
* chosen against the two facts that matter: the pool's own `querySync` resolves as soon
* as every relay has sent EOSE, which on this pool is usually well under a second, and
* the request this sits inside has already spent up to five seconds failing to reach
* the mint. Eight seconds of a reader watching a spinner is the outer bound of what
* this feature may cost, and three is what buys nearly all of the recall — the events
* being asked for are single, indexed, tag-filtered lookups, not a sweep.
*/
export const RELAY_LOOKUP_MS = 3000;
/** Bounded so a hostile or confused relay cannot answer with a hundred thousand rows. */
const LOOKUP_LIMIT = 60;
/** What the relays remember about an address that does not answer over HTTPS. */
export interface RelayTrace {
/** True when anything at all was found: an announcement, or a review, or both. */
found: boolean;
/** How many kind 38000 events name this address. Nothing is stored from them here. */
reviews: number;
/** Metadata from the newest announcement, when there was one. */
name: string | null;
picture: string | null;
about: string | null;
announcerPubkey: string | null;
announcedAt: number | null;
/** The parsed 38174, for an LNURL mint: the only source of its features and network. */
lnurl: LnurlAnnouncement | null;
}
const EMPTY: RelayTrace = {
found: false,
reviews: 0,
name: null,
picture: null,
about: null,
announcerPubkey: null,
announcedAt: null,
lnurl: null,
};
/**
* Run several filters against the pool at once, with one deadline over all of them.
*
* `querySync`'s own `maxWait` bounds each call, but a relay that accepts a connection
* and then never sends EOSE can outlive it; the race is what guarantees the caller gets
* an answer inside the budget whatever the sockets do.
*/
async function query(filters: Filter[]): Promise<NostrEvent[]> {
const started = Date.now();
const events = new Map<string, NostrEvent>();
const work = Promise.all(
filters.map((filter) =>
getPool()
.querySync(config.relays, filter, { maxWait: RELAY_LOOKUP_MS })
.then((batch) => {
for (const event of batch) events.set(event.id, event);
})
.catch(() => undefined),
),
);
await Promise.race([work, new Promise((resolve) => setTimeout(resolve, RELAY_LOOKUP_MS))]);
log.info('relay trace', { filters: filters.length, events: events.size, ms: Date.now() - started });
return [...events.values()];
}
/**
* Everything the relays know about one unreachable address.
*
* The filters are the same ones the discovery loop uses to resolve a review, asked in
* the other direction: there, "which mint is this review about?"; here, "are there any
* reviews, and any announcement, for this address?". Reusing `mintUrlSpellings` is what
* makes the two agree — a mint announced with a trailing slash is found by an address
* typed without one.
*/
export async function traceOnRelays(type: string, url: string): Promise<RelayTrace> {
const spellings = mintUrlSpellings(url);
const announcementKind = type === 'lnurl' ? KIND_LNURL_ANNOUNCEMENT : KIND_MINT_ANNOUNCEMENT;
const filters: Filter[] = [
{ kinds: [announcementKind], '#u': spellings, limit: LOOKUP_LIMIT },
{ kinds: [KIND_REVIEW], '#u': spellings, limit: LOOKUP_LIMIT },
];
/*
* An LNURL mint with no funding source is announced — and reviewed — under its bare
* host in `d` rather than under a pubkey, and a client that only writes `d` leaves no
* `u` to match on. One extra filter covers those; a Cashu mint's `d` is a pubkey
* nobody can derive from a URL, so it has no equivalent.
*/
if (type === 'lnurl') {
const identifier = hostIdentifier(url);
filters.push({ kinds: [announcementKind, KIND_REVIEW], '#d': [identifier], limit: LOOKUP_LIMIT });
}
const events = await query(filters);
if (events.length === 0) return EMPTY;
let reviews = 0;
let newest: NostrEvent | null = null;
for (const event of events) {
if (event.kind === KIND_REVIEW) {
// A review found by `#u` could be about any ecosystem; only count the ones whose
// `k` agrees, so a federation review carrying a stray URL is not evidence here.
if (reviewEcosystem(event) === type) reviews++;
continue;
}
if (event.kind !== announcementKind) continue;
if (!newest || event.created_at > newest.created_at) newest = event;
}
if (!newest) {
return reviews > 0 ? { ...EMPTY, found: true, reviews } : EMPTY;
}
const lnurl = type === 'lnurl' ? parseLnurlAnnouncement(newest) : null;
const meta = parseAnnouncementMetadata(newest.content);
return {
found: true,
reviews,
name: lnurl?.name ?? meta.name,
picture: lnurl?.picture ?? meta.picture,
about: lnurl?.about ?? meta.about,
announcerPubkey: newest.pubkey,
announcedAt: newest.created_at,
lnurl,
};
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Fetching a URL a stranger typed.
*
* Every other fetch in this codebase goes to an address that reached the database
* through discovery or the seed list. `POST /api/index` is the first one that does not:
* a reader pastes something, and this server opens a socket to it. That inverts who the
* untrusted party is — it is no longer just the *response* that cannot be trusted, it
* is the *destination* — so this module exists to answer one question before any packet
* leaves: is that address somewhere this server has any business connecting to?
*
* Four rules, and each is here because leaving it out is a known exploit:
*
* 1. **https only.** Not http-upgraded-to-https, not any other scheme. `file:` reads
* the disk, `gopher:` used to be a way to make a server speak arbitrary protocols,
* and plaintext http to a stranger's address is a downgrade nobody asked for.
* 2. **Resolve first, judge the addresses, then connect.** A hostname check alone
* stops `http://127.0.0.1` and nothing else: `internal.example.com` is a perfectly
* ordinary public name that resolves to `10.0.0.5`, and only DNS can say so. Every
* address the name resolves to is checked, not the first: a name with one public
* and one private A record must be refused, not raced.
* 3. **Every redirect hop is a new destination.** A public URL that 302s to
* `http://169.254.169.254/latest/meta-data/` is the cloud-metadata attack in its
* classic form. Redirects are followed manually, capped at two, and each hop goes
* through the same check as the first.
* 4. **Bounded in bytes, in time, and in content type.** A stranger's server can be
* slow forever and large forever; `readBodyBounded` and an abort timer cap both,
* and a response that is not the media type asked for is not read at all.
*
* What this cannot close is the DNS rebinding window: the name is resolved here, and
* the socket resolves it again a moment later, and a hostile resolver can answer
* differently the second time. Closing it means dialing the vetted IP with the hostname
* pinned for TLS, and Node exposes no supported way to do that through `fetch` (undici's
* dispatchers are not a public API here). The exposure is a single GET whose body is
* parsed as JSON and discarded unless it is a valid mint advertisement, so the practical
* gain from a rebind is one unauthenticated GET — noted here rather than left implicit.
*/
import { lookup } from 'node:dns/promises';
import { isBlockedHostname, isPrivateIpAddress } from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
/** BACKEND.md's cap for this endpoint: no probe response may exceed it. */
export const MAX_PROBE_BYTES = 256 * 1024;
/** How many redirects a probe follows before giving up. */
export const MAX_REDIRECTS = 2;
/**
* What one guarded fetch did.
*
* `blocked` and `unreachable` are kept apart because the endpoint answers them
* differently: a blocked address is a refusal this site made and can explain, while an
* unreachable one is a mint that may still be real and falls through to the Nostr
* lookup. Collapsing them would file "you may not point us at 10.0.0.1" under "we
* checked Nostr and found nothing", which is not what happened.
*/
export type FetchOutcome =
| { state: 'ok'; status: number; body: string; contentType: string | null; url: string }
| { state: 'blocked'; reason: string }
| { state: 'unreachable'; reason: string };
/**
* The two pieces of the outside world this module touches.
*
* Injectable so the SSRF rules can be tested without a network: `check-index.ts` hands
* in a resolver that answers `10.0.0.1` for an ordinary-looking name, and a fetch that
* returns a redirect to a private address, and asserts that neither is ever connected
* to. Those are the two attacks this file exists to stop, and a rule that is only
* exercised against the real internet is a rule that is not exercised.
*/
export interface FetchDeps {
resolve?: (hostname: string) => Promise<string[] | null>;
fetchImpl?: typeof fetch;
}
/** Every address a hostname resolves to, or null when it does not resolve at all. */
async function resolveAll(hostname: string): Promise<string[] | null> {
try {
const records = await lookup(hostname, { all: true, verbatim: true });
const addresses = records.map((record) => record.address).filter(Boolean);
return addresses.length > 0 ? addresses : null;
} catch {
return null;
}
}
/**
* Why an address was not connected to.
*
* Two kinds, and keeping them apart is load-bearing rather than tidy. `blocked` is a
* refusal this site made — the address is one it will not fetch, whoever asked — and
* the endpoint reports it as such. `unresolved` is the network saying nothing, and a
* name that no longer resolves is the *normal* state of a mint whose operator walked
* away: that submission has to fall through to the Nostr lookup, not be rejected as if
* the reader had typed something inadmissible.
*/
export interface DestinationVerdict {
kind: 'blocked' | 'unresolved';
reason: string;
}
/**
* May this server connect to this URL?
*
* Returns null when it may. Exported because the on-demand indexer runs it once up
* front — before it decides whether to probe at all — and because the redirect loop
* below runs it again on every hop.
*/
export async function checkDestination(
target: URL,
deps: FetchDeps = {},
): Promise<DestinationVerdict | null> {
const blocked = (reason: string): DestinationVerdict => ({ kind: 'blocked', reason });
if (target.protocol !== 'https:') return blocked('only https addresses are checked');
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (!hostname) return blocked('no hostname');
// localhost, .onion, .local, a bare label, an IP literal in a private range: all
// refusable without asking a resolver anything.
if (isBlockedHostname(target.hostname)) return blocked('not a public address');
// An IP literal has already been judged by the line above; a name has to be resolved.
if (/^[\d.]+$/.test(hostname) || hostname.includes(':')) return null;
const addresses = await (deps.resolve ?? resolveAll)(hostname);
if (addresses === null) return { kind: 'unresolved', reason: 'the address does not resolve' };
// Every answer, not the first: a name with one public and one private record must be
// refused outright rather than depending on which one the socket happens to pick.
const private_ = addresses.find((address) => isPrivateIpAddress(address));
return private_ === undefined ? null : blocked(`resolves to a private address (${private_})`);
}
/**
* A response's media type, without its parameters. `application/json; charset=utf-8`
* and `application/json` are the same answer.
*/
function mediaType(res: Response): string | null {
const header = res.headers.get('content-type');
return header ? (header.split(';')[0] ?? '').trim().toLowerCase() : null;
}
/**
* Is this media type plausible for what was asked for?
*
* Deliberately lenient in one direction only. A mint serving JSON as `text/plain` is a
* misconfiguration this site should still read — several real ones do — so anything
* text-shaped or unlabelled passes. What it refuses is a response that is *positively*
* something else: an image, a video, an archive, an executable. Those are never a mint
* advertisement, and reading 256KB of one to find out costs bandwidth for nothing.
*/
function plausibleType(type: string | null, accept: string): boolean {
if (type === null) return true; // Unlabelled. The parser is the real check.
if (type.startsWith('text/') || type.includes('json') || type.includes('xml')) return true;
if (accept.includes('html') && type.includes('html')) return true;
return false;
}
export interface SafeFetchOptions {
accept: string;
maxBytes?: number;
timeoutMs?: number;
}
/**
* GET a stranger's URL, following at most two redirects and checking every hop.
*
* `redirect: 'manual'` rather than `follow`, which is the crux: with `follow`, undici
* resolves and connects to the redirect target itself and this code never sees the
* address. Doing the hops by hand is what makes rule 3 above enforceable at all.
*/
export async function safeFetchText(
rawUrl: string,
options: SafeFetchOptions,
deps: FetchDeps = {},
): Promise<FetchOutcome> {
const maxBytes = options.maxBytes ?? MAX_PROBE_BYTES;
const timeoutMs = options.timeoutMs ?? config.probeTimeoutMs;
let target: URL;
try {
target = new URL(rawUrl);
} catch {
return { state: 'blocked', reason: 'not a URL' };
}
/*
* One timer for the whole chain, not one per hop.
*
* Otherwise two redirects turn the endpoint's "standard 5s timeout" into fifteen
* seconds of a reader watching a spinner, and a hostile server can extend that for
* as long as the redirect cap allows.
*/
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const verdict = await checkDestination(target, deps);
if (verdict) {
// A name that does not resolve is not a refusal, it is a mint that is not there,
// and the caller has a whole extra step for that case.
if (verdict.kind === 'unresolved') {
return { state: 'unreachable', reason: verdict.reason };
}
return {
state: 'blocked',
reason:
hop === 0
? verdict.reason
: `redirected to an address we will not fetch: ${verdict.reason}`,
};
}
let res: Response;
try {
res = await (deps.fetchImpl ?? fetch)(target, {
signal: controller.signal,
redirect: 'manual',
headers: { Accept: options.accept, 'User-Agent': config.userAgent },
});
} catch (err) {
return {
state: 'unreachable',
reason: err instanceof Error ? err.message : 'connection failed',
};
}
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
// Drain rather than leak the socket: a redirect body is never read.
await res.body?.cancel().catch(() => undefined);
if (!location) return { state: 'unreachable', reason: `HTTP ${res.status} with no target` };
if (hop === MAX_REDIRECTS) return { state: 'unreachable', reason: 'too many redirects' };
try {
target = new URL(location, target);
} catch {
return { state: 'unreachable', reason: 'redirect target is not a URL' };
}
continue;
}
const type = mediaType(res);
if (!plausibleType(type, options.accept)) {
await res.body?.cancel().catch(() => undefined);
return { state: 'unreachable', reason: `unexpected content type ${type ?? 'none'}` };
}
const body = await readBodyBounded(res, maxBytes);
if (!body) {
return { state: 'unreachable', reason: `body larger than ${maxBytes} bytes or unreadable` };
}
return {
state: 'ok',
status: res.status,
body: body.toString('utf8'),
contentType: type,
url: target.href,
};
}
// Unreachable in practice: the loop returns or continues, and the last iteration
// refuses to continue. Kept so the function has one type on every path.
return { state: 'unreachable', reason: 'too many redirects' };
} finally {
clearTimeout(timer);
}
}
/**
* The `TextFetcher` shape `probeLnurl` takes, backed by the guarded fetch above.
*
* The probe loop keeps its own plain fetcher: those rows are addresses this site chose
* to track and have already been through `normalizeMintUrl`. This one is for the path
* where the address arrived seconds ago from a stranger.
*/
export function guardedTextFetcher(deps: FetchDeps = {}) {
return async (
url: string,
accept: string,
maxBytes: number,
): Promise<{ body: string; status: number } | null> => {
const outcome = await safeFetchText(url, { accept, maxBytes }, deps);
return outcome.state === 'ok' ? { body: outcome.body, status: outcome.status } : null;
};
}
+62 -14
View File
@@ -10,7 +10,7 @@
import { closeDb, getDb } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { insertMintIfNew } from './mints.ts';
import { insertLnurlIfNew, insertMintIfNew } from './mints.ts';
import { probeAll } from './probe.ts';
import { listMints } from './queries.ts';
@@ -54,16 +54,42 @@ const SEED_MINTS = [
'https://kashu.me',
];
/**
* LNURL mints, seeded by URL alone.
*
* Short for the obvious reason: `kind:38174` is a proposed kind (see
* `docs/KIND-LNURL-MINT.md`) and there are no announcements on the network yet, so
* unlike Cashu — where this list was read off real 38172 events — there is nothing to
* read it off. This is the reference instance the ecosystem was built against, and
* discovery takes over the moment operators start announcing.
*
* Seeded with no status: `insertLnurlIfNew` writes `unknown` and the first probe cycle
* decides, exactly as it does for a Cashu mint. Being on this list is not a claim that
* a mint is up, and is not an endorsement of it.
*/
const SEED_LNURL = [
'https://lnurl.21mint.me',
];
function pad(s: string, width: number): string {
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width);
}
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++;
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
for (const url of SEED_MINTS) if (await insertMintIfNew(url)) added++;
let addedLnurl = 0;
for (const url of SEED_LNURL) if (await insertLnurlIfNew(url)) addedLnurl++;
log.info('seed list ingested', {
listed: SEED_MINTS.length + SEED_LNURL.length,
added: added + addedLnurl,
lnurl: addedLnurl,
});
await probeAll();
const discovery = await runDiscovery(true);
@@ -75,13 +101,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 +116,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 +132,36 @@ 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 lnurlMints = listed.filter((m) => m.type === 'lnurl');
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(
`${lnurlMints.length} LNURL mints (${count(lnurlMints, 'online')} online, ` +
`${count(lnurlMints, 'offline')} offline)`,
);
console.log(`${reviews} reviews indexed`);
closePool();
closeDb();
await closeDb();
process.exit(0);
}
+99 -8
View File
@@ -1,9 +1,41 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
import { serveStatic } from '@hono/node-server/serve-static';
import path from 'node:path';
import { config } from './config.ts';
import { indexSubmission } from './index-mint.ts';
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
import { RATE_LIMIT, takeToken } from './rate-limit.ts';
/** The largest `POST /api/index` body read. A JSON object with two short strings. */
const MAX_BODY_BYTES = 8 * 1024;
/**
* Who is submitting, for the rate limiter.
*
* The socket's peer address, unless that peer is the loopback interface — in which case
* this process is behind the reverse proxy the README documents, every request has the
* same peer, and `X-Forwarded-For` is the only thing that tells two visitors apart.
*
* The *last* entry of that header, not the first. nginx's `$proxy_add_x_forwarded_for`
* appends the peer it actually saw to whatever the client sent, so the first entry is a
* value a client can write for itself — a free way around the limit — and the last is
* the one the proxy vouched for. When the peer is not loopback the header is ignored
* entirely, because then there is no proxy to have vouched for anything.
*/
function clientKey(c: Context): string {
const socket = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined)
?.incoming?.socket;
const peer = socket?.remoteAddress ?? '';
const loopback = peer === '' || peer === '::1' || peer === '127.0.0.1' || peer.startsWith('::ffff:127.');
if (!loopback) return peer;
const forwarded = c.req.header('x-forwarded-for') ?? '';
const hops = forwarded.split(',').map((hop) => hop.trim()).filter(Boolean);
return hops[hops.length - 1] ?? peer ?? 'unknown';
}
export function createApp(): Hono {
const app = new Hono();
@@ -12,33 +44,92 @@ 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);
});
/*
* The one endpoint that writes: index a mint nobody has announced yet.
*
* It is a POST because it creates a row, and it is rate limited because it is the
* only route that makes this server fetch an address somebody else chose. Everything
* it actually does lives in `index-mint.ts`; what is here is the shape of the request
* and the two things that can only be decided at the edge — who is asking, and how
* much body to read from them.
*/
app.post('/api/index', async (c) => {
const verdict = takeToken(clientKey(c));
if (!verdict.ok) {
c.header('Retry-After', String(verdict.retryAfter));
return c.json(
{
error: 'rate_limited',
message: `At most ${RATE_LIMIT} submissions an hour from one address`,
retry_after: verdict.retryAfter,
},
429,
);
}
// An invite code runs to a few hundred characters; nothing legitimate is near this.
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
return c.json({ error: 'bad_input', message: 'Request body too large' }, 422);
}
let body: { type?: unknown; input?: unknown };
try {
body = (await c.req.json()) as { type?: unknown; input?: unknown };
} catch {
return c.json({ error: 'bad_input', message: 'Body must be JSON' }, 422);
}
const outcome = await indexSubmission(String(body?.type ?? ''), body?.input);
if (outcome.status === 429 && 'retry_after' in outcome.body) {
c.header('Retry-After', String(outcome.body.retry_after ?? 30));
}
return c.json(outcome.body, outcome.status);
});
// Cached mint icons, so an offline mint keeps its icon.
app.use(
'/icons/*',
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');
},
}),
);
+311
View File
@@ -0,0 +1,311 @@
/**
* A Nostr relay, in one file, for tests.
*
* Enough of NIP-01 to accept an EVENT, answer a REQ and close a subscription, plus the
* addressable-replacement rule, which is not optional here: the whole point of the
* round-trip test is a `kind:38174`, and an addressable kind that a relay stores twice
* would let a broken publisher pass.
*
* Written rather than depended on, deliberately. The alternatives were a real relay
* binary (which CI would have to install and keep running) or a `ws` dependency added to
* the lockfile for test-only code. Node 22 ships a WebSocket *client* — which is what
* nostr-tools uses — but no server, so the handshake and framing below are the actual
* cost of not adding either, and RFC 6455 is small when the only frames that matter are
* short unfragmented text ones.
*
* **Not for production.** No authentication, no persistence, no NIP-42, no rate limits,
* no fragmentation support beyond a single continuation, and everything lives in a Map
* until the process exits. `api/src/check-lnurl.ts` is the only caller.
*/
import { createHash } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { Duplex } from 'node:stream';
/** RFC 6455's fixed handshake GUID. */
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
interface StoredEvent {
id: string;
pubkey: string;
kind: number;
created_at: number;
content: string;
tags: string[][];
sig?: string;
}
type Filter = Record<string, unknown>;
/* ---------- framing ---------- */
/** Encode one unfragmented text frame, server to client, never masked. */
function encodeText(text: string): Buffer {
const payload = Buffer.from(text, 'utf8');
const length = payload.length;
let header: Buffer;
if (length < 126) {
header = Buffer.from([0x81, length]);
} else if (length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
return Buffer.concat([header, payload]);
}
interface DecodedFrame {
opcode: number;
payload: Buffer;
/** Total bytes consumed, so the caller can advance its buffer. */
size: number;
}
/**
* Decode one frame from the front of `buffer`, or null when it is not all there yet.
*
* Client frames are always masked, per the spec, so the mask is applied unconditionally
* when the bit is set and ignored when it is not — a browser or Node client never omits
* it, and a test relay has no reason to reject one that did.
*/
function decodeFrame(buffer: Buffer): DecodedFrame | null {
if (buffer.length < 2) return null;
const first = buffer[0]!;
const second = buffer[1]!;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < offset + 2) return null;
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.length < offset + 8) return null;
const big = buffer.readBigUInt64BE(offset);
// A test relay has no business buffering a 4GB frame.
if (big > 8n * 1024n * 1024n) throw new Error('frame too large');
length = Number(big);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.length < offset + 4) return null;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + length) return null;
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) {
for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ mask[i % 4]!;
}
return { opcode, payload, size: offset + length };
}
/* ---------- filters ---------- */
function matchesFilter(event: StoredEvent, filter: Filter): boolean {
const ids = filter['ids'] as string[] | undefined;
if (ids && !ids.includes(event.id)) return false;
const authors = filter['authors'] as string[] | undefined;
if (authors && !authors.includes(event.pubkey)) return false;
const kinds = filter['kinds'] as number[] | undefined;
if (kinds && !kinds.includes(event.kind)) return false;
const since = filter['since'] as number | undefined;
if (typeof since === 'number' && event.created_at < since) return false;
const until = filter['until'] as number | undefined;
if (typeof until === 'number' && event.created_at > until) return false;
// `#e`, `#p`, `#d`, `#k`, `#u`: match any value of that single-letter tag.
for (const [key, wanted] of Object.entries(filter)) {
if (!key.startsWith('#') || key.length !== 2) continue;
const name = key.slice(1);
const values = wanted as string[];
const present = event.tags.filter((t) => t[0] === name).map((t) => t[1]);
if (!present.some((value) => value !== undefined && values.includes(value))) return false;
}
return true;
}
/**
* The storage key for an event.
*
* Addressable kinds (30000–39999) are keyed by `kind:pubkey:d`, so a second announcement
* from the same publisher for the same mint replaces the first instead of accumulating —
* which is exactly the behaviour `kind:38174` depends on and therefore exactly what a
* test of it must reproduce. Everything else is keyed by its own id.
*/
function storageKey(event: StoredEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
return `${event.kind}:${event.pubkey}:${d}`;
}
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
return `${event.kind}:${event.pubkey}`;
}
return event.id;
}
/* ---------- the relay ---------- */
export interface TestRelay {
/** `ws://127.0.0.1:<port>`, ready to hand to a pool. */
url: string;
/** Every event currently stored, newest first. */
events(): StoredEvent[];
/** Events of one kind, newest first. */
byKind(kind: number): StoredEvent[];
close(): Promise<void>;
}
/**
* Start a relay on an ephemeral port.
*
* Port 0 rather than a fixed one so two tests, or two CI jobs on one machine, cannot
* collide — the caller reads the real port back off `url`.
*/
export async function startTestRelay(): Promise<TestRelay> {
const stored = new Map<string, StoredEvent>();
const sockets = new Set<Duplex>();
const server: Server = createServer((_req, res) => {
// Not a websocket upgrade. NIP-11 would go here on a real relay.
res.writeHead(426, { 'Content-Type': 'text/plain' });
res.end('websocket only');
});
server.on('upgrade', (req: IncomingMessage, socket: Duplex) => {
const key = req.headers['sec-websocket-key'];
if (typeof key !== 'string') {
socket.destroy();
return;
}
const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
);
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => sockets.delete(socket));
const send = (message: unknown): void => {
if (!socket.destroyed) socket.write(encodeText(JSON.stringify(message)));
};
let buffer = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
let frame: DecodedFrame | null;
try {
frame = decodeFrame(buffer);
} catch {
socket.destroy();
return;
}
if (!frame) break;
buffer = buffer.subarray(frame.size);
if (frame.opcode === 0x8) {
socket.end();
return;
}
// Ping: answer with a pong carrying the same payload, per the spec.
if (frame.opcode === 0x9) {
const pong = encodeText('');
pong[0] = 0x8a;
socket.write(pong);
continue;
}
if (frame.opcode !== 0x1) continue;
let message: unknown;
try {
message = JSON.parse(frame.payload.toString('utf8'));
} catch {
continue;
}
if (!Array.isArray(message)) continue;
const [verb, ...rest] = message as [string, ...unknown[]];
if (verb === 'EVENT') {
const event = rest[0] as StoredEvent | undefined;
if (!event?.id) continue;
const existing = stored.get(storageKey(event));
// Older replacement for an addressable kind: keep what is there, and still
// answer OK, which is what a real relay does.
if (!existing || existing.created_at <= event.created_at) {
stored.set(storageKey(event), event);
}
send(['OK', event.id, true, '']);
continue;
}
if (verb === 'REQ') {
const subId = rest[0] as string;
const filters = rest.slice(1) as Filter[];
const all = [...stored.values()].sort((a, b) => b.created_at - a.created_at);
for (const filter of filters) {
const limit = typeof filter['limit'] === 'number' ? (filter['limit'] as number) : Infinity;
let sent = 0;
for (const event of all) {
if (sent >= limit) break;
if (!matchesFilter(event, filter)) continue;
send(['EVENT', subId, event]);
sent++;
}
}
send(['EOSE', subId]);
continue;
}
if (verb === 'CLOSE') {
send(['CLOSED', rest[0] as string, '']);
}
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('relay did not bind a port');
return {
url: `ws://127.0.0.1:${address.port}`,
events: () => [...stored.values()].sort((a, b) => b.created_at - a.created_at),
byKind: (kind) =>
[...stored.values()].filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at),
close: async () => {
for (const socket of sockets) socket.destroy();
sockets.clear();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
+16 -1
View File
@@ -7,7 +7,22 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noEmit": true,
/*
* This project is compiled now, rather than run straight off `src/*.ts`.
*
* The reason is a fifteen-hour crash loop: the unit's ExecStart named `src/index.ts`,
* the host's `/usr/bin/node` was 20, and native type stripping is 22.18 and newer, so
* every start died on ERR_UNKNOWN_FILE_EXTENSION and systemd restarted it 464 times
* without anything going red. Emitting plain `.js` removes the host's Node version
* from the set of things that can break a deploy.
*
* `rewriteRelativeImportExtensions` is what lets the source keep its explicit `.ts`
* specifiers — which is what makes `node --watch src/index.ts` work in development —
* while the emitted files import `./config.js` and run anywhere.
*/
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
+23
View File
@@ -0,0 +1,23 @@
# /etc/cashumints/alert.env
#
# Read by cashumints-alert@.service, which systemd starts when any of the three units
# fails. Everything here is optional: with the file absent or both values empty, an
# alert is still written to the journal at ERROR priority and is readable with
#
# journalctl -p err -t cashumints-alert
#
# Set one or both to have failures leave the machine.
#
# Install it root-owned and not world-readable — a webhook URL is a capability:
# sudo install -d -m 0755 /etc/cashumints
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
# sudo systemctl daemon-reload
# An ntfy topic URL. Free and public at ntfy.sh; pick a topic name nobody will guess,
# because anyone who knows it can read and post to it.
#NTFY_URL=https://ntfy.sh/cashumints-alerts-CHANGE-ME
# Anything that accepts a JSON POST. The body carries `unit`, `host`, `at`, `text` and
# `content` — the last of which is what Discord and most Slack-compatible endpoints read,
# so one payload fits all three.
#WEBHOOK_URL=https://discord.com/api/webhooks/…
+101
View File
@@ -0,0 +1,101 @@
# /etc/systemd/system/cashumints-alert@.service
#
# The unit that makes a failure audible.
#
# The other three units each carry `OnFailure=cashumints-alert@%n.service`, so systemd
# starts one of these with the failed unit's name as the instance — `%i` below is
# literally `cashumints.service`, `cashumints-web.service` or `cashumints-site.service`.
#
# Why it exists: the API once crash looped 464 times over fifteen hours and nothing said
# so. `Restart=on-failure` with no start limit is an infinite loop that never reaches a
# `failed` state, so the journal filled with identical lines nobody was reading and
# every signal stayed green. The other half of the fix is StartLimitBurst= in each unit,
# which turns the loop into a failure; this is what carries that failure off the machine.
#
# Install:
# sudo install -m 0644 deploy/cashumints-alert@.service /etc/systemd/system/
# sudo install -d -m 0755 /etc/cashumints
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
# sudo systemctl daemon-reload
#
# No [Install] section and never enabled: OnFailure= starts it, and a unit that also
# started at boot would page on every reboot.
[Unit]
Description=Notify that %i failed
# No OnFailure= here. An alerter that alerts about its own failure is a loop, and this
# one is written so its worst case is a journal line rather than a retry.
[Service]
Type=oneshot
# The one file an operator edits, and the only reason this unit is configurable at all.
# Absent is a supported state — the leading `-` says so — and then the ExecStart below
# still writes to the journal at ERROR, which is what `systemctl status` and
# `journalctl -p err` read. See alert.env.example.
EnvironmentFile=-/etc/cashumints/alert.env
# So `journalctl -t cashumints-alert` finds every alert, whichever unit triggered it.
SyslogIdentifier=cashumints-alert
# Everything is inside one shell so the "nothing configured" branch is reachable without
# a second unit. The pieces, in order:
#
# - `printf '<3>…'` on stdout. systemd reads that syslog prefix off a journal stream
# and files the line at priority 3, ERROR, so `journalctl -p err` is a complete
# history of failures on a host with no webhook configured at all. `<4>` is warning.
# A prefix rather than systemd-cat, so the unit needs nothing from the filesystem it
# has just sandboxed itself away from.
# - NTFY_URL is a topic URL (https://ntfy.sh/your-topic). It gets a plain-text body
# naming the failed unit, plus the header names ntfy understands.
# - WEBHOOK_URL gets a JSON POST instead, for Discord, Slack or anything that speaks
# `{"content": …}` — every key is sent, so one payload fits all of them.
# - `--max-time 10` and a `||` fallback on each: an alert that hangs would hold the
# failed unit's job open, and an alert that fails must not itself become a second
# failed unit for somebody to notice. The shell ends in `true` for the same reason.
#
# `%i` is the failed unit's name, passed as an argument rather than interpolated into
# the shell text: systemd expands specifiers before /bin/sh ever sees the line, and a
# unit name is not a thing to trust to quoting.
ExecStart=/bin/sh -c '\
UNIT="$1"; \
HOST="$(hostname)"; \
WHEN="$(date -Is)"; \
TEXT="$UNIT failed on $HOST at $WHEN"; \
printf "<3>%s\\n" "$TEXT"; \
if [ -n "$NTFY_URL" ]; then \
/usr/bin/curl -fsS --max-time 10 \
-H "Title: cashumints: $UNIT failed" \
-H "Priority: high" \
-H "Tags: rotating_light" \
-d "$TEXT" "$NTFY_URL" >/dev/null \
|| printf "<3>%s\\n" "alert: POST to NTFY_URL failed"; \
fi; \
if [ -n "$WEBHOOK_URL" ]; then \
/usr/bin/curl -fsS --max-time 10 \
-H "Content-Type: application/json" \
-d "{\\"unit\\":\\"$UNIT\\",\\"host\\":\\"$HOST\\",\\"at\\":\\"$WHEN\\",\\"text\\":\\"$TEXT\\",\\"content\\":\\"$TEXT\\"}" \
"$WEBHOOK_URL" >/dev/null \
|| printf "<3>%s\\n" "alert: POST to WEBHOOK_URL failed"; \
fi; \
if [ -z "$NTFY_URL" ] && [ -z "$WEBHOOK_URL" ]; then \
printf "<4>%s\\n" "alert: no NTFY_URL or WEBHOOK_URL in /etc/cashumints/alert.env, journal only"; \
fi; \
true' _ %i
# It sends one HTTP request and writes one line. It needs no identity of its own, and
# DynamicUser gives it a throwaway one rather than sharing `nobody` with everything else
# on the host that also could not be bothered to make a user.
DynamicUser=yes
NoNewPrivileges=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
# An alert that cannot reach the network in ten seconds is not worth a stuck job.
TimeoutStartSec=30
+67
View File
@@ -0,0 +1,67 @@
# /etc/systemd/system/cashumints-site.service
#
# Serves the built site on loopback. nginx proxies to it and never opens a file itself,
# which is the point: when nginx held a `root` inside /home/cashumints, every directory
# down to dist had to be traversable by www-data, and the one that was not took the
# whole site down as a blanket 404 with nothing in the error log naming the cause.
#
# This is a long-running daemon, unlike cashumints-web.service next to it — that one is
# the oneshot that produces what this one serves.
[Unit]
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
# Give up after five failures in two minutes instead of restarting forever. A process
# that cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a far louder signal than a journal scrolling past. The window
# matches cashumints.service; see the note there for why it is 120s and not 60s. These
# two are [Unit] keys; systemd ignores them under [Service] with only a warning.
StartLimitIntervalSec=120
StartLimitBurst=5
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/web
# The tree comes from cashumints-web.service, which rsyncs it here after a build.
# Serving web/dist directly would mean a rebuild empties the site for the length of it.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=SITE_PORT=8789
Environment=SITE_HOST=127.0.0.1
Environment=WEB_ROOT=/var/lib/cashumints/web
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
# In-flight responses finish; idle keep-alive connections are closed at once.
TimeoutStopSec=15s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
+141
View File
@@ -0,0 +1,141 @@
# /etc/systemd/system/cashumints-web.service
#
# The frontend is static: `output: 'static'` in astro.config.mjs, and the whole site is
# produced ahead of time. There is no frontend build to keep alive, so this unit is a
# build rather than a daemon — one shot of `pnpm build`, which compiles shared/, renders
# a social card per mint and prerenders every page from the live API. The daemon that
# hands the result out is cashumints-site.service.
#
# Run it after a deploy, and only after a deploy:
# sudo systemctl start cashumints-web
#
# There used to be a cashumints-web.timer firing this at 03:30 every night, because the
# mint list was a snapshot of whatever the API held when the build ran and a nightly
# rebuild was the only way it ever changed. The list hydrates from the API after paint
# now, so a new mint, a new review count and a changed status all reach the page within
# a second of load, and rebuilding 2,000 pages at 03:30 to refresh numbers that refresh
# themselves is 20 minutes of CPU for nothing.
#
# What a build still produces, and therefore what a deploy is still for: the prerendered
# HTML a crawler reads, the social card per mint, the sitemap, and a `/mint/{host}` page
# for every mint known at build time. A mint indexed since the last deploy has no page of
# its own until the next one; the 404 fallback resolves it against the live API, so it is
# readable and reviewable in the meantime. That was already true between nightly builds.
#
# There is deliberately no [Install] section — this belongs to a deploy, not to a boot.
[Unit]
Description=Rebuild the cashumints.space static site
# Every page's data comes from the API over loopback, so the API has to be up.
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
# site with an empty one.
Requires=cashumints.service
After=cashumints.service network-online.target
Wants=network-online.target
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
[Service]
Type=oneshot
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space
# Where the published copy lands. Shared with the API and the site server, and created
# by systemd with this unit's ownership if it is not there yet.
StateDirectory=cashumints
Environment=NODE_ENV=production
# Where the build reaches the API. Must match PORT= in cashumints.service.
Environment=API_URL=http://127.0.0.1:8788
Environment=SITE_URL=https://cashumints.space
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
# forwards it. Set this only if the API ever moves to its own hostname. Declared here
# even though it is empty, because systemd's environment wins over .env — so what a
# production build emits cannot drift with an edit to that file.
Environment=PUBLIC_API_URL=
# After= orders the start; it does not wait for the port to accept connections. At boot
# the API is still opening its database and probing, so block until it reports healthy
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
# Then: does the API actually have an index to build a site out of?
#
# Health answering 200 says the process is up and its last backfill read something. It
# does not say how many mints are in the table, and those are different questions — the
# year of ~31-event backfills had a healthy API serving a real, complete, correct list of
# eight mints. A build against that succeeds, prerenders eight cards, and rsync happily
# replaces fifty-five with eight.
#
# So count the list before spending twenty minutes building from it. Below the floor
# this exits non-zero, systemd abandons the unit at ExecStartPre, and — because publishing
# is ExecStartPost, after the build — the previously published site is never touched. The
# site stays exactly as it was and the OnFailure alert says why.
#
# Counted by the `"host":` key, one per item, rather than by counting `{`: the list
# payload carries a nested object per mint (its NUT capability switches), so brace
# counting would report roughly double. No jq: it is not installed on this host and a
# build gate should not add a dependency to run.
#
# `Q` is a double-quote character, built with printf rather than written literally,
# because this whole command is already inside systemd's single quotes and a quote of
# either kind in the grep pattern would end the argument early.
#
# A curl that fails for any reason leaves `n` empty, `$${n:-0}` reads that as zero, and
# zero is below every floor — so an API that fell over between the health check above and
# this line refuses the build rather than sailing through it.
Environment=MIN_MINTS_FOR_BUILD=20
ExecStartPre=/bin/sh -c 'Q=$$(printf "\\042"); \
n=$$(curl -sf --max-time 30 http://127.0.0.1:8788/api/mints | grep -o "$${Q}host$${Q}:" | wc -l); \
if [ "$${n:-0}" -lt "$$MIN_MINTS_FOR_BUILD" ]; then \
printf "<3>%s\\n" "refusing to build: /api/mints returned $${n:-0} mints, floor is $$MIN_MINTS_FOR_BUILD. Previous site left untouched."; \
exit 1; \
fi; \
printf "%s\\n" "build gate: $$n mints, floor $$MIN_MINTS_FOR_BUILD"'
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
# and systemd's PATH does not include it.
ExecStart=/usr/bin/pnpm build
# Publish, as a separate step from building.
#
# `astro build` empties dist before it writes, so the site server cannot read dist
# directly — a rebuild would be a minute of 404s. It serves this copy instead, and the
# copy is only touched once a build has succeeded: a failed build leaves the previous
# site up rather than replacing it with a half-written one, which is the same reason
# Requires=cashumints.service is above and the same reason the mint-count gate is an
# ExecStartPre rather than a check after the fact.
#
# --delay-updates stages the changed files and renames them in at the end, so the window
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
# --delete-after keeps removals from landing before their replacements. Unchanged files
# — every hashed asset and card, which is nearly all of it — are not touched at all.
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
# ~200 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
# TimeoutStartSec is what bounds a Type=oneshot.
TimeoutStartSec=1800
# A build should not starve the API it is reading from.
Nice=10
# The site server runs as cashumints and reads its own files, so this no longer has to
# be world-readable — it was 0022 for nginx, back when nginx opened the files as
# www-data. Kept at 0022 anyway: rsync preserves these modes into the published copy,
# and a readable static site is easier to inspect than one that needs sudo.
UMask=0022
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
# ProtectHome is deliberately absent, unlike in cashumints.service: this unit writes
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
# store are all under it.
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+70
View File
@@ -0,0 +1,70 @@
# /etc/systemd/system/cashumints.service
[Unit]
Description=cashumints.space indexer and API
Wants=network-online.target
After=network-online.target
# Stop after five failures in two minutes rather than restarting forever.
#
# The window is 120s and not 60s because RestartSec=5s below means five attempts take
# a little over twenty seconds of restarts plus however long each attempt lives before
# it dies. A process that fails *slowly* — a database that times out, a port that takes
# four seconds to refuse — can spread five failures past a sixty second window and reset
# the counter forever, which is the loop this is supposed to stop. 120s covers that.
#
# The failure this exists for: ExecStart named a .ts file, /usr/bin/node was 20, and
# every start died in under a second. 464 restarts over fifteen hours, and because
# Restart=on-failure without a start limit never reaches a `failed` state, nothing
# anywhere went red. Both keys belong to [Unit] — under [Service] systemd only warns and
# ignores them.
StartLimitIntervalSec=120
StartLimitBurst=5
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/api
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=PORT=8788
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# Compiled JavaScript, run by the distribution's own node.
#
# This line used to read `src/index.ts`, which made every start depend on the host
# having Node 22.18 or newer for native type stripping. A host with Node 20 answered
# that with ERR_UNKNOWN_FILE_EXTENSION in under a second, 464 times over fifteen hours,
# and nothing anywhere went red. `pnpm build` now emits api/dist, so what runs here is
# ordinary ESM and any Node from 20.18 up will start it.
#
# Deliberately /usr/bin/node and nothing else: an nvm or fnm path is invisible to this
# unit's ProtectHome and breaks silently at the next version bump.
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=30s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+160
View File
@@ -0,0 +1,160 @@
# /etc/nginx/sites-available/cashumints.space
#
# nginx terminates TLS and proxies. It opens no file belonging to this project — not the
# built site, not an icon — and that is deliberate.
#
# It used to point a `root` at web/dist. Because nginx runs as www-data and everything
# this project owns runs as cashumints, that arrangement required every directory from /
# down to dist to be traversable by a user with no other business in the tree. A home
# directory at its default 0700 anywhere in that chain broke the entire site, and it
# broke it invisibly: `try_files` treats a permission error as a plain miss, so the
# symptom was a blanket 404, or an internal-redirect loop that ended in a 500 with the
# real cause named nowhere.
#
# Two upstreams now, both on loopback, both owned by the same user that built what they
# serve:
#
# 127.0.0.1:8789 cashumints-site.service the prerendered site
# 127.0.0.1:8788 cashumints.service /api/* and /icons/*
#
# Routing that used to live here lives with the thing that owns it. The locale 404 rule
# in particular was a hand-maintained alternation of 23 codes in a file that is not in
# the repository; adding a language meant remembering to edit it, and forgetting was
# silent. web/server.mjs resolves those from the built tree.
proxy_cache_path /var/cache/nginx/cashumints
levels=1:2
keys_zone=cashumints:10m
max_size=256m
inactive=10m
use_temp_path=off;
# Keep a few connections open to each upstream rather than reconnecting per request.
# Both processes hold idle sockets longer than nginx does, so nginx is always the side
# that closes and there is no window where it reuses a socket the upstream just dropped
# — that race is what produces sporadic 502s under load.
upstream cashumints_site {
server 127.0.0.1:8789;
keepalive 16;
}
upstream cashumints_api {
server 127.0.0.1:8788;
keepalive 8;
}
server {
listen 80;
listen [::]:80;
server_name cashumints.space;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://cashumints.space$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name cashumints.space;
# nginx 1.25 and later want `http2 on;` on its own line and warn about the form above.
# Left as is because it is the form that works on both, and Debian 12 ships 1.22.
ssl_certificate /etc/letsencrypt/live/cashumints.space/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cashumints.space/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Set here rather than upstream: this is the only part of the stack that knows a
# request arrived over TLS. Add `preload` only once you are content never to serve
# this name over plain HTTP again.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# The upstreams send no Content-Encoding, so compression is nginx's to do. gzip_proxied
# any is required — without it nginx refuses to compress a proxied response at all.
gzip on;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json
application/manifest+json application/xml image/svg+xml;
# Nothing here accepts an upload. The API's largest body is an 8 KB JSON submission,
# and rejecting the oversized ones at the edge keeps them off the Node process.
client_max_body_size 16k;
# Both upstreams are a process on this machine. A slow response is a bug, not a
# network condition, and failing fast beats holding a worker for a minute.
proxy_connect_timeout 2s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# HTTP/1.1 with an empty Connection header is what makes the keepalive pools above
# work; the default 1.0 opens a new socket per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
# Every proxied location includes Debian's /etc/nginx/proxy_params, which sets Host,
# X-Real-IP, X-Forwarded-For and X-Forwarded-Proto. That include is load-bearing, not
# decorative: the API's rate limiter reads the last hop of X-Forwarded-For to tell two
# visitors apart, and without it every request arrives from the loopback peer and
# shares one bucket. On a distro that ships no proxy_params, set those four by hand.
# Do not also set X-Forwarded-For alongside the include — declaring it twice is what
# produces nginx's "could not build optimal proxy_headers_hash" warning.
# The prerendered site. Cache-Control comes from server.mjs — a year and immutable for
# anything with a content hash in its name, revalidate-every-time for markup — so
# there is nothing to restate here.
location / {
proxy_pass http://cashumints_site;
include proxy_params;
}
# Health must always reflect the live process.
location = /api/health {
proxy_pass http://cashumints_api;
proxy_cache off;
# add_header replaces rather than merges: declaring one here drops every add_header
# inherited from the server block, so HSTS has to be restated alongside it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "no-store" always;
include proxy_params;
}
# Briefly cache the read-heavy endpoints.
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Restated for the same reason as in /api/health above.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Cache-Status $upstream_cache_status always;
include proxy_params;
}
location /api/ {
proxy_pass http://cashumints_api;
include proxy_params;
}
location /icons/ {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
include proxy_params;
}
# No error_page and no try_files. A miss is the site server's 404 page, in the right
# language and with a 404 status; an nginx error page here would replace it with a
# blank one and hide which upstream failed.
}
+384
View File
@@ -0,0 +1,384 @@
# NIP-87 extension: `kind:38174`, LNURL mint announcements
`draft` `optional`
> **Status.** This is a proposed extension to [NIP-87][nip87], written to be submitted
> as a PR to [nostr-protocol/nips][nips]. It is not part of NIP-87 today. It is
> implemented and published by [cashumints.space](https://cashumints.space), which is
> also where the reference indexer for it lives. Nothing here changes 38172, 38173 or
> 38000; a client that implements only NIP-87 as written keeps working unchanged, and
> gets partial support for these events for free (see [Reviews](#reviews)).
[nip87]: https://github.com/nostr-protocol/nips/blob/master/87.md
[nips]: https://github.com/nostr-protocol/nips
---
## Rationale
NIP-87 gives ecash mints a way to be discovered, and gives users a way to recommend
them: an operator publishes an addressable announcement, a user publishes a
`kind:38000` naming the announcement's kind in a `k` tag, and a client queries by that
`k` to find recommendations for one ecosystem without seeing the others.
It covers exactly two ecosystems, one kind each: `38172` for Cashu, `38173` for
Fedimint. A third has since shipped — **lnurlcash** ([LUD-25][lud25]), Lightning bearer
notes served over plain [LUD-03][lud03] `withdrawRequest` and [LUD-06][lud06]
`payRequest`, implemented by [dni/lnurl-mint][lnurlmint] — and it has no kind. The
result today is that an lnurlcash mint either goes unannounced or is announced as
something it is not.
The pattern generalises cleanly, and this document does nothing but apply it:
- an LNURL mint has a **stable identifier** (its funding node's public key) → `d`
- it has an **address you connect to** (its https base URL) → `u`
- it runs on a **network** → `n`
- it has a **capability list** → `features`, the analogue of `nuts` and `modules`
The one thing genuinely new here is the capability vocabulary, because Cashu's NUT
numbers and Fedimint's module names have no LNURL equivalent to borrow. It is defined
in full below, and every value in it is grounded in a specific code path in
`lnurl-mint`, because a vocabulary that outruns any implementation is a wish list.
[lud25]: https://github.com/lnurl/luds/blob/luds/25.md
[lud03]: https://github.com/lnurl/luds/blob/luds/03.md
[lud06]: https://github.com/lnurl/luds/blob/luds/06.md
[lud16]: https://github.com/lnurl/luds/blob/luds/16.md
[lud21]: https://github.com/lnurl/luds/blob/luds/21.md
[lnurlmint]: https://github.com/dni/lnurl-mint
---
## Choosing the kind number
`38174` — the next free slot in the NIP-87 family.
Checked before claiming it, on 2026-08-21:
| Source | Result |
| --- | --- |
| [`nips/README.md`][nipsreadme] kind index | `38172` and `38173` listed; `38174` **absent**, and nothing else in `38100–38199` is assigned |
| GitHub search over `nostr-protocol/nips`, issues **and** PRs, for `38174` | **0 results** (open or closed) |
`38174` is in the *addressable* range (`30000 ≤ kind < 40000`), which is required: an
announcement must be replaceable per `(pubkey, kind, d)` so an operator can update
their capability list in place, exactly as 38172 and 38173 are.
**If `38174` is claimed before this is merged**, take the next free kind in the same
range, prefer keeping the family contiguous, and record the collision and the
replacement here — implementations read the kind from one constant
(`KIND_LNURL_ANNOUNCEMENT`) precisely so that this is a one-line change.
[nipsreadme]: https://github.com/nostr-protocol/nips/blob/master/README.md
---
## The announcement event
LNURL mints SHOULD publish `kind:38174` to announce their capabilities and how to reach
them.
```json
{
"kind": 38174,
"pubkey": "<publisher-pubkey>",
"content": "<optional-kind:0-style-metadata>",
"tags": [
["d", "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555"],
["u", "https://lnurl.21mint.me"],
["features", "mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes"],
["n", "mainnet"]
]
}
```
### `d` — the identifier
The `d` tag MUST be one of the following, in this order of preference:
1. **The mint's `mintPubkey`**, lowercase hex, when the mint advertises one. This is
the funding node's own identity key — a 33-byte compressed secp256k1 public key, 66
hex characters, beginning `02` or `03` — exposed on the mint-address endpoint
whenever a funding source is configured, and the same key the mint signs notes with
under LUD-25 offline verification.
2. **The normalized host**, when it does not: the lowercase hostname, plus `:port` if
non-default, plus the base path with no trailing slash, and **no scheme**. For
`https://mint.example.com/lnurl/` that is `mint.example.com/lnurl`.
The two forms are unambiguous by construction: form 1 is always exactly 66 hex
characters, form 2 always contains a `.` and never matches `^[0-9a-f]{66}$`.
#### When a mint gains a pubkey after being announced by host
This is not hypothetical. `mintPubkey` is present only while a funding source is
configured **and reachable**; a mint run without one — or announced during an outage —
legitimately has no pubkey to publish, and gains one later.
The rules, which exist so that identity never silently forks:
- **Prefer the pubkey once it exists.** A publisher that has been announcing by host
and learns a `mintPubkey` SHOULD begin publishing under the pubkey `d`.
- **The identifier is sticky.** Once a mint has been announced under a pubkey `d`, a
publisher MUST NOT revert to the host form because the node happened to be
unreachable at publish time. Absence of `mintPubkey` in one response is a statement
about the node's availability in that instant, not about the mint's identity.
- **The old announcement SHOULD be left in place**, not deleted. It is addressable, so
it stays queryable, and reviews already pointing at the host `d` keep resolving.
Publishers MAY additionally re-publish the host-`d` event with the same `u` tag so
both identifiers lead to the same live address.
- **Consumers MUST accept either.** An indexer resolving a review's `d` SHOULD try, in
order: exact match on a known `mintPubkey`; exact match on a known normalized host;
then fall back to the `u` tag. It MUST NOT treat the two identifiers for one mint as
two mints — deduplication is by **normalized base URL** (`u`), which is the one value
present in every form of the event.
### `u` — the address
The https base URL of the mint, with no trailing slash: `https://lnurl.21mint.me`.
`u` is REQUIRED in this kind, unlike in 38172 where it is a SHOULD. An LNURL mint's
`d` may be a bare host string with no scheme, which is not fetchable, so without `u`
there would be events carrying no usable address at all. Multiple `u` tags MAY appear;
the first is canonical. `.onion` addresses MUST NOT be the canonical `u` — they belong
in `features` as `onion` and are discovered from the mint itself.
Everything a client needs hangs off this base:
| Path | Role |
| --- | --- |
| `{u}/.well-known/lnurlw/_` | mint advertisement: withdraw limits, `mintPubkey`, node identity |
| `{u}/.well-known/lnurlp/_` | LUD-06 payRequest (also `{u}/.well-known/lnurlp/{username}`) |
| `{u}/w?k1=…` | LUD-03 withdrawRequest for one note |
| `{u}/w/cb` | melt / rotate / split / merge |
| `{u}/p/cb` | LUD-06 callback, mints a note |
| `{u}/verify/{payment_hash}` | LUD-21, when `lud21` is advertised |
`_` is [LUD-16][lud16]'s reserved bare-domain username and reaches the same identity as
the operator's configured username, so a consumer can probe without knowing it.
### `n` — the network
Same values and same meaning as NIP-87: `mainnet`, `testnet`, `signet` or `regtest`.
Absent reads as `mainnet`. Consumers SHOULD accept `bitcoin` as a synonym for
`mainnet`, because every Fedimint announcement in the wild writes it and publishers
copy each other.
### `features` — the capability list
One tag, one comma-separated list of lowercase tokens, the direct analogue of Cashu's
`nuts` and Fedimint's `modules`:
```json
["features", "mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes"]
```
Consumers MUST tolerate whitespace after commas, MUST lowercase before comparing, and
MUST ignore tokens they do not recognise rather than rejecting the event — that is what
lets this vocabulary grow without a new kind.
#### The vocabulary
Every value below names something `lnurl-mint` actually implements. The "Grounded in"
column is the specific thing that makes the claim checkable.
**Note operations** — the four branches of the LUD-25 callback `GET /w/cb`, plus
minting. These are what the mint *does*.
| Value | Meaning | Grounded in |
| --- | --- | --- |
| `mint` | A new bearer note can be created by paying a Lightning invoice; the payment preimage becomes the note. | `router.get_pay_callback` (`/p/cb`). **Requires a funding source** — it issues the invoice. Also refused outright while `SUNSET_MINT` is on. |
| `melt` | A note can be redeemed back to a BOLT-11 payment. | `/w/cb` with a `pr` parameter. **Requires a funding source** — it pays the invoice. |
| `rotate` | A note's secret can be replaced by one the holder generates, without changing its value. | `/w/cb` with neither `pr` nor `amount`; the holder supplies `h = sha256(new secret)`. No funding source needed. |
| `split` | One note becomes two, of a chosen amount and the remainder. | `/w/cb` with `amount`; holder supplies `h` and `h2`. No funding source needed. Refused while `SUNSET_MINT` is on, since it grows outstanding liability. |
| `merge` | Several notes become one worth their sum. | `/w/cb` with multiple `k1`. No funding source needed. |
The `mint`/`melt` versus `rotate`/`split`/`merge` division is not cosmetic: it is
exactly the line a missing funding source falls along. Without one the service still
runs and the last three still work, while nothing moves in or out over Lightning. That
is a real, distinct state, and consumers should be able to render it — see
[Availability is not capability](#availability-is-not-capability).
**LNURL sub-specifications** — the wire formats spoken, so a wallet knows what to expect.
| Value | Meaning | Grounded in |
| --- | --- | --- |
| `lud06` | Serves a LUD-06 `payRequest` and its callback. | `router.get_lnaddress` returns `tag: "payRequest"`; `/p/cb` returns `pr`. |
| `lud03` | Serves a LUD-03 `withdrawRequest` per note, informational, never burning. | `router.get_withdraw` (`GET /w?k1=`). Advertised as `callback` on the mint-address response. |
| `lud16` | Payable at a lightning address, `{username}@{host}` and the bare-domain `_@{host}`. | `/.well-known/lnurlp/{username}`, with `text/identifier` in the payRequest metadata. |
| `lud21` | Serves `GET /verify/{payment_hash}` so a wallet with no node can poll settlement. | `router.verify_invoice`, gated on `VERIFY_ENABLED`. **Announcement-only** — see below. |
**Optional extras.**
| Value | Meaning | Grounded in |
| --- | --- | --- |
| `signed-notes` | The mint advertises a `mintPubkey` and returns recoverable signatures over each new note, so a holder can verify issuer and amount offline. | `signing.mint_pubkey` / `signing.sign_note`; `sig`/`sig2` on the `/w/cb` response. Requires a funding source (it signs via the node's `signmessage`). |
| `onion` | The mint also answers on a Tor hidden service. | `ONION_URL`; advertised in the one-pager's "Also via Tor" block, and used as the callback base when a request arrives over it. |
**Not in the vocabulary, deliberately:** anything about fees (they are already disclosed
in the payRequest `metadata` as `Mint fees: <base_msat>,<ppm>`, which is authoritative
and live), anything about the mint's balance or liability, and anything a consumer would
have to take on trust with no way to check.
#### Availability is not capability
`features` says what the mint **implements**. It does not say what is **working right
now**. A mint whose funding node is unreachable still implements `mint` and `melt`; it
just cannot perform them this minute.
Consumers that probe SHOULD present these as two different things. The concrete, checkable
signal, for `lnurl-mint`: if a mint-address response omits `mintPubkey` while still
returning valid `minWithdrawable`/`maxWithdrawable`, the funding source is not reachable,
and `mint`, `melt` and `signed-notes` are unavailable until it is — while `rotate`,
`split` and `merge` continue to work normally.
Consumers MUST NOT rewrite the announcement's `features` from a probe. The announcement
is the operator's statement of what they built; the probe is an observation about a
moment.
#### What a prober may and may not conclude
This matters enough to be normative, because two of these are counter-intuitive and one
is a trap.
| Value | Probe-detectable? | How |
| --- | --- | --- |
| `lud06`, `lud16` | **Yes** | `/.well-known/lnurlp/_` returns `tag: "payRequest"`; `payLink` on the withdraw side names the address. |
| `lud03` | **Yes** | The mint-address response's `callback` points at `/w`. |
| `signed-notes` | **Yes** | `mintPubkey` present on the mint-address response. |
| `onion` | **Yes** | A `*.onion` host in the one-pager at `GET /`. Never in any JSON. |
| `mint`, `melt` | **Partly** | Implementation cannot be probed without minting; *availability* is the `mintPubkey` bit above. |
| `rotate`, `split`, `merge` | **No** | Only `/w/cb` proves them, and calling it mutates or destroys a stranger's note. |
| `lud21` | **No — and this is the trap** | See below. |
**`lud21` cannot be probed.** `VERIFY_ENABLED=false` makes `/verify/{hash}` raise a 404
with detail `"Not found"`. An *enabled* endpoint asked about an unknown payment hash
raises a 404 with detail `"Not found"` too. Both are then converted by the LNURL error
handler into an identical `200 {"status":"ERROR","reason":"Not found"}`. The two states
are byte-identical on the wire — verified against a live instance and a local build of
both configurations. The only genuine advertisement of a `verify` URL is in the response
to `/p/cb`, and calling that **creates a real invoice on the operator's node**, which no
directory should be doing on a timer.
So `lud21` is announcement-only: a prober MUST NOT set it, and MUST NOT clear it either.
### `content`
Optional, and exactly NIP-87's rule: a stringified kind-0-style metadata object
(`name`, `picture`, `about`, …). **If `content` is empty, consumers should fall back to
the `kind:0` of the event's `pubkey`** for the mint's display information.
```json
{"name": "21 Mint", "picture": "https://lnurl.21mint.me/icon.png", "about": "lnurlcash bearer notes, mainnet."}
```
Consumers MUST treat `content` as untrusted text written by anyone: unparseable JSON,
wrong types and hostile strings degrade to "no metadata", never to an error and never to
unescaped output.
---
## Reviews
There is **no new review kind**. A review of an LNURL mint is a plain NIP-87
`kind:38000` whose `k` tag names `38174`:
```json
{
"kind": 38000,
"pubkey": "<reviewer-pubkey>",
"content": "[5/5] Rotations are instant and melts have never failed me.",
"tags": [
["k", "38174"],
["d", "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555"],
["u", "https://lnurl.21mint.me", "lnurl"],
["a", "38174:<announcer-pubkey>:021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555", "wss://relay.cashumints.space"]
]
}
```
Reusing `38000` is the whole point of doing it this way. An existing NIP-87 client that
knows nothing about `38174` still sees a well-formed recommendation event from an author
it follows, still reads the rating and the text, and still knows it is *not* about Cashu
or Fedimint because the `k` tag says a kind it does not recognise. It half-understands
these reviews for free, and degrades to ignoring them rather than to misfiling them.
- **`k`** — REQUIRED, `"38174"`. This is what separates an LNURL review from a Cashu one,
and it is load-bearing: a Cashu mint's pubkey and an LNURL mint's `mintPubkey` are both
66-hex-character `d` values, so a resolver keying on `d` alone could file one as the
other.
- **`d`** — the announcement's identifier, per the `d` rules above. As NIP-87 says, this
can be computed from the mint's own pubkey even when no announcement event exists.
- **`u`** — OPTIONAL, the mint's https base URL. A third element MAY carry the free-form
marker `lnurl`, matching how NIP-87's own example marks `cashu` and `fedimint`.
- **`a`** — OPTIONAL, `38174:<announcer-pubkey>:<d>` plus a relay hint, pointing at the
announcement event itself.
### Rating
Ratings follow this site's existing convention (NOTES.md), unchanged across all three
ecosystems, because a reader comparing a Cashu mint to an LNURL one must be comparing
the same scale:
1. a `["rating", "<n>"]` tag with `n` in `1..5`, else
2. a `["rating", "<f>"]` tag with `f` in `0..1`, scaled to `1..5` (NIP-87 suggests this
form), else
3. an `[N/5]` prefix on `content`, which is how the great majority of real reviews on
the network encode it.
Unparseable means **no rating**, and the review is excluded from averages. It is never
defaulted to 5.
---
## Query patterns
Recommendations for LNURL mints from people a user follows:
```json
["REQ", "<id>", {"kinds": [38000], "authors": ["<user>", "<contacts…>"], "#k": ["38174"]}]
```
Every review of one specific mint:
```json
["REQ", "<id>", {"kinds": [38000], "#k": ["38174"], "#d": ["021ab8…3555"]}]
```
Announcements, directly:
```json
["REQ", "<id>", {"kinds": [38174]}]
["REQ", "<id>", {"kinds": [38174], "#d": ["021ab8…3555"]}]
```
All three ecosystems in one subscription, which is what an aggregator actually opens:
```json
["REQ", "<id>", {"kinds": [38172, 38173, 38174]}]
["REQ", "<id>", {"kinds": [38000], "#k": ["38172", "38173", "38174"]}]
```
As NIP-87 already warns for `38172`/`38173`: querying announcements directly, with no
web of trust between the reader and the publisher, will surface whatever anyone chose to
publish. Clients doing that SHOULD apply spam prevention or restrict to relays they
trust. Nothing about an announcement is evidence that the mint behind it is honest, or
even that it exists.
---
## Reference implementation
- **Constant and parse rules** — `shared/src/nostr.ts` (`KIND_LNURL_ANNOUNCEMENT`,
`ANNOUNCEMENT_KINDS`), `shared/src/lnurl.ts` (vocabulary, `d` rules, announcement
parser).
- **Discovery and review resolution** — `api/src/discovery.ts`.
- **Probing** — `api/src/lnurl-probe.ts`. What it found on a live instance, and every
ambiguity it had to resolve, is recorded in `NOTES-LNURL.md`.
- **Publishing 38174** — `api/src/announce.ts`. Off by default; see the README section
"Publishing LNURL mint announcements" before turning it on, because it writes to
public relays.
The implementation and this document are meant to be checked against each other:
`api/src/check-lnurl.ts` asserts the `d` rules, the vocabulary, the tag shapes and the
round trip described here, so a change to one that is not a change to the other fails
the test suite.
+361
View File
@@ -0,0 +1,361 @@
# Dynamic mint data: analysis
**Status:** analysis only. No code or config was changed.
**Goal:** new and updated mints, stats, and reviews should appear on the list (and related list surfaces) immediately, without waiting for the nightly static rebuild.
**Current architecture (important correction):** nginx does **not** serve `web/dist` as files. Production is:
```
browser → nginx :443 → cashumints-site (web/server.mjs :8789) → published copy of dist
→ cashumints_api (Hono :8788) → /api/*, /icons/*
```
`cashumints-web.timer` rebuilds at **03:30** daily, then `rsync`s `web/dist/` to `WEB_ROOT` (`/var/lib/cashumints/web`). Markup is a snapshot of whatever the API returned at that build.
---
## 1. Data flow audit
There are **no Astro content collections**. Every dynamic page either fetches the Hono API at **build time** (`web/src/lib/api.ts`, `API_URL`, default `http://127.0.0.1:8787` / prod `8788`) or hydrates in the browser from `/api/…` and/or Nostr.
Astro “islands” here are **not** React/Vue/Svelte with `client:load`. There is **zero** `client:*` usage. Runtime behaviour is vanilla `<script>` modules in `.astro` files, bundled by Vite, re-run on view transitions via `onReady()`.
### 1.1 Every route under `web/src/pages`
| Route | `getStaticPaths` | Build-time data | Client-side at runtime |
|---|---|---|---|
| `[...locale]/index.astro` (`/`, `/es`, …) | `localePaths` (one HTML page per locale) | `fetchMints`, `fetchFedimints`, `fetchLnurlMints`, `fetchStats`, `fetchHealth`; N+1 `fetchMint` / `fetchFedimint` / `fetchLnurlMint` for the top 6 of each; `fetchLatestReviews()` from relays via `nostr-build.ts` | **PulseTicker** refreshes `/api/stats` + `/api/health`. Search form is GET to `/mints`. Review carousel is **not** re-queried. ColorBends is WebGL only. |
| `[...locale]/mints.astro` | `localePaths` | `fetchMints()` then **one `fetchMint(host)` per mint** for NUT chips | Search / sort / hide-offline only rearrange **already-rendered** cards. **No live refetch.** |
| `[...locale]/fedimints.astro` | `localePaths` | `fetchFedimints()` (no N+1; no NUT chips) | Same client filter/sort as `/mints`. **No live refetch.** |
| `[...locale]/lnurl-mints.astro` | `localePaths` | `fetchLnurlMints()` + N+1 `fetchLnurlMint` for chips | Same. **No live refetch.** |
| `[...locale]/mint/[host].astro` | `localePathsFor` over **every cashu mint known at build** | `fetchMints()` + `fetchMint(host)` per mint, shared across locales | **MintLive** → `GET /api/mints/:host`. **Reviews** → Nostr via `nostr-tools` `SimplePool`. |
| `[...locale]/fedimint/[id].astro` | same pattern, `fetchFedimints` | federation detail | Same two islands. |
| `[...locale]/lnurl-mint/[host].astro` | same pattern, `fetchLnurlMints` | LNURL detail | Same two islands. |
| `[...locale]/reviews.astro` | `localePaths` | `fetchAllListings()`, `fetchStats()`, `fetchReviewFeed(…, 30)` from relays | **Re-queries relays** (`feed-client.ts` `loadFeed`), paginates, filters. This is the working “live list” pattern. |
| `[...locale]/wallets.astro` | `localePaths` | Hardcoded wallet array in the page | None |
| `[...locale]/about.astro` | `localePaths` | `fetchStats()` for counts in copy | None |
| `[...locale]/privacy.astro`, `terms.astro`, `disclaimer.astro` | `localePaths` | i18n catalogs only | None |
| `[...locale]/404.astro` + `pages/404.astro` | prefixed locales / English `/404` | None (static 404 shell) | **NotFound resolver:** `GET /api/mints/:host`, on miss `POST /api/index`, then reviews panel |
| `sitemap.xml.ts` | n/a (endpoint at build) | `fetchMints` / `fetchFedimints` / `fetchLnurlMints`; `isIndexableMint` filter | n/a |
| `robots.txt.ts` | n/a | `SITE_URL` only; `Disallow: /api/`, `/icons/` | n/a |
24 locales (`en` plus 23 prefixes). List pages are ~24 HTML files each. Mint detail pages are `mints × locales` (README ballpark: ~55 cashu mints × 24 ≈ 1,300+ pages, plus federations and LNURL).
### 1.2 Why `/mints` shows only a subset, with stale stats and reviews
**Not pagination.** `fetchMints()` calls `GET /api/mints?type=cashu` with **no `limit`**. `listMints()` in the API returns every matching row, sorted by score. The optional `?limit=` query is unused by the site.
**Not an `isIndexableMint` filter on the grid.** Unreachable, unreviewed mints still render as cards. That predicate only drops them from JSON-LD `ItemList` and the sitemap.
**The list is a frozen HTML snapshot.** `mints.astro` does:
```ts
const mints = await fetchMints();
const capabilities = await Promise.all(
mints.map((mint) => fetchMint(mint.host).then(…).catch(() => null)),
);
```
then maps every item to `<MintCard>`. After publish, those cards do not change until the next `cashumints-web.timer` run.
What that freezes:
| Card field | Source at build | Live counterpart |
|---|---|---|
| Presence of the mint | API `mints` table at 03:30 | Discovery + `POST /api/index` write new rows immediately |
| `review_count`, `rating_avg`, `score`, `last_review_at` | SQL aggregates of **ingested** Nostr reviews | Relays (and the API, once discovery has ingested) |
| `status`, `last_online` | last probe stored in the DB | `GET /api/mints/:host` (what MintLive already uses) |
| Melt-only / frozen chip | N+1 detail fetch of cached `/v1/info` | same detail payload |
**Why a mint can exist “on the site” but not on the list:** the 404 resolver (`NotFound.astro`) looks up `/mint/{host}` against the **live** API and, if missing, calls `POST /api/index`. That mint becomes reviewable at once. It does **not** get a card on `/mints` until rebuild. Same for Review-by-URL. The README states this as intended:
> Mint pages are prerendered, so new mints and new review counts appear at the next build. … an unbuilt mint still resolves through the client-side fallback on the 404 page.
**Why reviews look wrong on the list and right on the detail page:** `/mints` never talks to Nostr. It prints `MintListItem.review_count` / `rating_avg` from the API snapshot. The detail page’s `Reviews.astro` calls `loadReviews(subject)` against relays and replaces the panel. A review written today is on the detail page as soon as relays answer; the list card still shows yesterday’s count.
**Home page extra subset:** `mints.slice(0, 6)` (and the same for federations / LNURL). “Latest reviews” is a **build-time** relay read (`fetchLatestReviews(…, 10)`), unlike `/reviews`, which re-queries on load.
### 1.3 How the mint detail page fetches Nostr (the pattern that works)
**Component:** `web/src/components/Reviews.astro`
Root: `[data-reviews-panel]` with `data-subject={JSON.stringify(subject)}`.
Script imports `loadReviews` / `loadProfiles` from `web/src/lib/reviews-client.ts`.
**Library:** [`nostr-tools`](https://github.com/nbd-wtf/nostr-tools) **v2.10.4** — `SimplePool` from `nostr-tools/pool`, plus `nip19`. **Not NDK.**
**Relays** (`DEFAULT_RELAYS` in `shared/src/nostr.ts`, overridable with `PUBLIC_REVIEW_RELAYS`):
- `wss://relay.cashumints.space`
- `wss://nos.lol`
- `wss://relay.azzamo.net`
- `wss://relay.snort.social`
- `wss://relay.primal.net`
Profiles add `wss://purplepag.es` and `wss://relay.nostr.net`.
**Query:** kind `38000` (addressable reviews), two OR’d filters, `limit: 500`, `maxWait: 8000` ms:
1. `#d` = mint pubkey (plus LNURL alternate ids) and `#k` = announcement kind
2. `#u` = URL spellings (legacy Cashu reviews; federations skip this)
Newest event per author wins. In-tab cache 5 minutes. Empty result is treated as a relay error if the API had counted reviews.
**Live status (API, not Nostr):** `MintLive.astro` is a hidden `[data-mint-live={host}]` marker. Its script `fetch(`${apiBase}/api/mints/${host}`)` and patches the status chip, banner, limits, and NUT/module/feature rows. `apiBase` is `import.meta.env.PUBLIC_API_URL`, empty in production → same-origin `/api/…`.
**Unbuilt mint URLs:** `server.mjs` serves the locale 404. `NotFound.astro` derives the address from the path, `GET /api/mints/:host`, then `POST /api/index` on miss, then dispatches `cashumints:subject` so the same Reviews island starts.
---
## 2. API coverage
All routes are in `api/src/server.ts`. CORS is enabled on `/api/*` and `/icons/*` (`hono/cors`, default allow-all). Production uses **same origin** (`PUBLIC_API_URL=` empty; nginx proxies `/api/` and `/icons/`), so browsers never hit a cross-origin API unless that env is set.
### 2.1 Existing endpoints
#### `GET /api/mints`
- Query: `?type=cashu|fedimint|lnurl` (unknown type → empty array); optional `?limit=N`
- No type filter → **entire index**, each item tagged `type`
- **No default limit.** Full list.
- Response: `MintListItem[]`
```ts
{
url: string;
host: string;
name: string | null;
icon: string | null; // "/icons/…" or null
type: 'cashu' | 'fedimint' | 'lnurl' | string;
status: 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
last_online: number | null; // unix
review_count: number; // ingested, one-per-author
rating_avg: number | null; // 1 decimal
score: number; // Bayesian, from those aggregates
last_review_at: number | null;
version: string | null;
}
```
**This is enough to rebuild the `/mints` grid** (name, icon, rating, count, status, sort keys). It is **not** enough for melt-only / frozen chips (`nuts` / capabilities) or for true sentiment bars (`rating_distribution`). Those currently require N+1 `GET /api/mints/:host`.
#### `GET /api/mints/:host`
- 404 `{ error, message }` if unknown
- Response: `MintDetail` = list item plus:
```ts
{
description, pubkey, info /* NUT-06 */, nuts: string[],
first_seen, last_probe, updated_at,
rating_distribution: { '1'..'5': number },
reviews_90d, uptime_30d, probes_recent: { ts, ok, latency_ms }[],
// fedimint extras when type=fedimint: federation_id, invite_codes, modules, …
// lnurl extras when type=lnurl: features, funding_available, withdraw bounds, …
}
```
Used by MintLive and the 404 resolver. **No review bodies.**
#### `GET /api/stats`
In-process memo **60s**. Cashu `mints_*` counts are cashu-only.
```ts
{
mints_total, mints_online, mints_offline, mints_degraded,
reviews_total, last_review_at, updated_at,
cashu_total, // same as mints_total
fedimint_total, fedimint_online, fedimint_offline, fedimint_announced, fedimint_reviews,
lnurl_total, lnurl_online, lnurl_offline, lnurl_degraded_funding, lnurl_reviews
}
```
PulseTicker already consumes this live.
#### `GET /api/health`
`Cache-Control: no-store`. `{ status, uptime_s, last_probe_at, last_discovery_at, mints_tracked, updated_at }`. 503 when degraded.
#### `POST /api/index`
The only write. Body `{ type, input }`. Rate limit **10/hour/IP** (`INDEX_RATE_LIMIT`). Success 200/201: `MintDetail & { existing, indexed_from? }`. Failures: `bad_input`, `blocked_host`, `wrong_type`, `unverifiable`, `rate_limited`, etc.
#### `GET /icons/:file`
Static files, `Cache-Control: public, max-age=86400`.
### 2.2 Gaps
| Need | Exists? | Notes |
|---|---|---|
| Full mint / federation / LNURL lists | **Yes** | `/api/mints?type=…` |
| Per-mint stats (status, uptime, distribution, probes) | **Yes** | `/api/mints/:host` |
| Review **bodies** / recent review text | **No HTTP endpoint** | Live only via Nostr (`reviews-client.ts`, `feed-client.ts`). API stores ratings for aggregates, not content for the UI. |
| List payload with NUT chips / LNURL chips | **No** | List item has no `nuts`, no `rating_distribution`. Today: N+1 at **build**. Client-side N+1 per visitor would be wasteful. |
| Global review feed JSON | **No** | `/reviews` talks to relays, not the API. |
| Snapshot JSON in `dist` | **None** | No checked-in mint dump. Stale data **is** the prerendered HTML. |
Discovery itself is not the list cap (`QUERY_LIMIT` 500 × `MAX_PAGES` 20). The visible cap is **whatever was in the DB when `astro build` ran**.
---
## 3. Option analysis
### 3a. Client-side hydration (keep static build)
Keep `output: 'static'`, `server.mjs`, nginx, and the timer. On `/mints` (and twins), fetch the live API after load and rebuild the grid — the same idea as Reviews / PulseTicker / MintLive.
**What already exists to copy**
- PulseTicker: prerender numbers, then `fetch(`${apiBase}/api/stats`)`.
- `/reviews`: prerender ~30 cards, then `loadFeed()` replaces them.
- MintLive: patch in place from `/api/mints/:host`.
- `/mints` script already sorts/filters on `data-*` (`data-score`, `data-rating`, `data-reviews`, `data-last-review`, `data-status`, …). New cards only need those attributes.
**There are no `client:load` islands to add.** Work is extra `<script>` in the list pages (or a shared module) plus an HTML builder for a card, because `MintCard.astro` cannot run in the browser.
Concrete changes:
| File | Change |
|---|---|
| `web/src/pages/[...locale]/mints.astro` | After paint, `fetch('/api/mints?type=cashu')`, rebuild `[data-mint-grid]`, update `[data-result-count]`. Keep prerendered cards as noscript / first-paint fallback. |
| `web/src/pages/[...locale]/fedimints.astro` | Same, `?type=fedimint`. |
| `web/src/pages/[...locale]/lnurl-mints.astro` | Same, `?type=lnurl`. |
| `web/src/lib/mint-cards.ts` (**new**) | Browser HTML for one card, mirroring `MintCard.astro` (same pattern as `review-cards.ts`). |
| `web/src/lib/api.ts` | Optional: a tiny browser `fetch` helper using `apiBase` from `client.ts` (build-time `API_URL` must **not** ship to the browser). |
| `web/src/pages/[...locale]/index.astro` | Optional but needed for “immediately”: refresh top-6 grids from the same list endpoint; optionally reuse `/reviews`’s `loadFeed` for the carousel (today that strip is build-only). |
| `web/src/components/MintCard.astro` | Untouched if the JS builder is a parallel renderer; or extract shared markup helpers. |
**Chips:** either (i) leave prerendered chips stale, (ii) add `nuts` / chip flags to `MintListItem` + `toListItem()` in `api/src/queries.ts` (small, one-time API change), or (iii) N+1 detail fetches from every browser (do not do this).
**New mint click path:** hydrated list → `/mint/{newhost}` → 404 HTML → NotFound resolver → live page. Already designed. No SSR required for that URL to work.
**Dependencies:** none new (`nostr-tools` already in `web`). No adapter. No nginx / systemd change for the site server.
**Config:** none if `PUBLIC_API_URL` stays empty.
### 3b. Hybrid SSR (`@astrojs/node`, `prerender = false`)
Astro 5 dropped `output: 'hybrid'`. Pattern: install an adapter, keep `output: 'static'`, set `export const prerender = false` on routes that must run per request.
Concrete changes:
| File / unit | Change |
|---|---|
| `web/package.json` | Add `@astrojs/node`. `start` would become something like `node dist/server/entry.mjs` instead of `node server.mjs`. |
| `web/astro.config.mjs` | `import node from '@astrojs/node'`; `adapter: node({ mode: 'standalone' })`. `output` can stay `'static'`. |
| `web/src/pages/[...locale]/mints.astro` (and fedimints / lnurl-mints; maybe `index.astro`) | `export const prerender = false`. Drop `getStaticPaths` **or** keep it only if those routes stay static. SSR pages fetch `fetchMints()` **per request**. |
| `web/server.mjs` | **Cannot stay as the only server.** Node adapter emits `dist/server/entry.mjs` plus `dist/client/` for assets. Locale 404s, ETag/cache policy, path jail, and “refuse empty root” all live in `server.mjs` today and would need reimplementation or a wrapper. |
| `cashumints-site.service` | `ExecStart` → adapter entry; `WEB_ROOT` layout changes (`client/` vs flat dist). |
| `cashumints-web.service` | `rsync` of `web/dist/` is wrong for `client/` + `server/`. Publish + restart model changes. A failed SSR process takes down **HTML**, not only islands. |
| nginx | Still reverse-proxy; upstream may stay `:8789` if the adapter listens there. `proxy_read_timeout` 30s is fine for API-backed SSR. |
SSR **does not** by itself refresh review **bodies** on the list (there are none). It **does** make list HTML match the API on every request, including for crawlers, and can emit real 200s for new `/mint/{host}` if that route is also `prerender = false` (today new hosts are **404** until rebuild).
Per-request cost: `/mints` already does N+1 detail fetches for chips × 24 locales if you SSR all locales naively. Needs a cache or list-payload chips.
### 3c. Caching layers
**In-repo**
| Layer | TTL | Affects |
|---|---|---|
| `getStats()` memo (`api/src/queries.ts`) | 60s | `/api/stats` |
| Review / feed `SimplePool` caches in the tab | 5 min | detail reviews, `/reviews` |
| Icons | 1 day (`max-age=86400`) | `/icons/*` |
| `GET /api/mints`, `/api/stats`, `/api/mints/:host` | **no** `Cache-Control` from Hono (except health `no-store`) | nginx fills the gap |
**nginx** (`README.md` sample; not in this repo)
```nginx
proxy_cache_path … keys_zone=cashumints:10m max_size=256m inactive=10m;
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
}
location /icons/ { proxy_cache …; proxy_cache_valid 200 1d; }
location = /api/health { proxy_cache off; }
```
**HTML** from `server.mjs`: `Cache-Control: public, max-age=0, must-revalidate` + ETag. Browsers revalidate; they do not keep a 24h stale `/mints`.
**Implications**
- Client-side list fetch: **30s** nginx micro-cache is desirable (thundering herd). Not the 24h bug.
- After `POST /api/index`, a new mint can be missing from `GET /api/mints` for up to 30s (404 cache 10s on unknown host). Acceptable; optional `Cache-Control: s-maxage=30` on the API to make it explicit.
- SSR `/mints` would still sit behind that 30s API cache unless the SSR process talks to the API on loopback **bypassing nginx** (`API_URL=http://127.0.0.1:8788`), which the **build** already does. An SSR Node process on the same machine should keep using loopback, not `https://cashumints.space/api`.
- No purge/invalidation API exists. Not needed for 30s TTL.
**Rate limits:** only `POST /api/index`. `GET /api/mints` is unbounded. A list page per visitor is one small JSON payload (~tens of mints), not a risk.
---
## 4. Recommendation
**Do 3a (client-side hydration of the list from `GET /api/mints`).** Least infrastructure change that fixes: full live mint list, live card stats, live review **counts** on the list.
Do **not** move `/mints` to SSR for this goal. That replaces `server.mjs`, the publish path, and systemd, to solve a problem the API + existing island style already solve. Use SSR later only if you need **200 + HTML** for mint URLs that do not exist at build time (SEO for brand-new hosts). Those URLs already **work** for humans via the 404 resolver.
### Why 3a is enough
1. The API already returns the full cashu list with scores and review aggregates.
2. Same-origin `/api` is already proxied; PulseTicker proves browser → API works in production.
3. New mints already resolve on click (404 → `POST /api/index`). Hydrating the list is the missing half.
4. Review **text** on `/mints` was never a list feature; card counts come from the API. Once the list refetches, counts match what MintLive/stats use (discovery-ingested). Bodies stay on the detail page via Nostr, as now.
5. `/reviews` already has live bodies.
### Effort
**Small–medium, ~1–2 days** for `/mints` + fedimints + lnurl-mints + a shared card renderer. Another half day if the home top grids and “latest reviews” strip should match.
Optional extra (half day): add chip fields to `GET /api/mints` so the client does not N+1.
### Files that would be touched (3a)
**Required for the list pages**
- `web/src/pages/[...locale]/mints.astro`
- `web/src/pages/[...locale]/fedimints.astro`
- `web/src/pages/[...locale]/lnurl-mints.astro`
- `web/src/lib/mint-cards.ts` (new; browser card HTML + `fetch` against `apiBase`)
**Likely**
- `web/src/pages/[...locale]/index.astro` (top-6 + “all N mints” count; otherwise home still lies)
- `web/src/components/MintCard.astro` only if extracting shared helpers rather than duplicating markup
**Optional API (chips)**
- `shared/src/types.ts` (`MintListItem`)
- `api/src/queries.ts` (`toListItem`)
**Not required:** `astro.config.mjs`, `web/package.json` (unless you add a test), `web/server.mjs`, nginx, systemd units, `@astrojs/node`.
### Risks
| Risk | Severity | Mitigation |
|---|---|---|
| **SEO:** crawlers see the prerendered subset | Medium if you **replace** HTML with an empty grid. Low if you **keep** build-time cards and enhance. Google does not run the same JS as users. | Keep prerendered cards. Hydration only adds/updates. `ItemList` JSON-LD stays a snapshot; acceptable. |
| **New mint URLs return HTTP 404** until rebuild | Low for UX (resolver fills in). Medium for SEO of a mint indexed today. | Out of scope for list freshness. SSR on `mint/[host].astro` is the fix if needed. |
| **Nostr relay latency** | Does not apply to the **list** if you use the API. Applies to `/reviews` and detail panels already (8–9s timeout, bones, retry). | Do not query relays on `/mints`. |
| **API rate limits** | None on GET. | — |
| **CORS** | None while `PUBLIC_API_URL` is empty (same origin). Hono already sends CORS if the API is split later. | Do not bake `API_URL` (`127.0.0.1`) into browser code. |
| **nginx 30s cache** | Cards can lag indexing by 30s | Fine. Loopback SSR would skip it; client fetch will not. |
| **Card markup drift** | JS builder vs `MintCard.astro` | One HTML helper, or accept a short duplication like `review-cards.ts` vs the Astro card. |
| **View transitions** | Cards use `data-vt-*` names applied on click | Preserve those attributes in the builder. |
| **i18n** | Card strings must use `useI18n()` in the island, not build-time `t` | Same as Reviews / PulseTicker. |
| **Home “latest reviews”** | Still nightly unless you reuse `loadFeed` | Separate follow-up; `/reviews` is already live. |
### Suggested sequence (when implementing)
1. Hydrate `/mints` from `GET /api/mints?type=cashu`; keep prerendered fallback.
2. Repeat for `/fedimints` and `/lnurl-mints`.
3. Hydrate the home top grids (and the “all N” link) from the same endpoints.
4. Only if chips go stale in a way users notice: extend the list payload.
5. Revisit SSR only for mint-detail **status codes** / OG / sitemap freshness — not for the list.
+3 -2
View File
@@ -4,7 +4,7 @@
"version": "2.0.0",
"type": "module",
"engines": {
"node": ">=20"
"node": ">=20.18"
},
"scripts": {
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
@@ -12,7 +12,8 @@
"dev:web": "pnpm --filter ./web dev",
"seed": "pnpm --filter ./api seed",
"bones": "pnpm --filter ./web bones",
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
"build": "pnpm --filter ./shared build && pnpm --filter ./api build && pnpm --filter ./web build",
"start": "pnpm --filter ./web start",
"check:links": "pnpm --filter ./web check:links",
"typecheck": "pnpm -r typecheck",
"check:i18n": "pnpm --filter ./web check:i18n",
+403
View File
@@ -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
+500
View File
@@ -0,0 +1,500 @@
/**
* 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)}`;
}
/* ---------- decoding an invite code ---------- */
/**
* Decoding one, which until now nothing in this codebase did.
*
* `isInviteCode` above is a shape check, and a shape check is all the *display* side
* has ever needed: a code arrives inside an announcement that already carries the
* federation id in its `d` tag, and the code itself is handed to a wallet verbatim.
*
* On-demand indexing changes that. A reader pasting an invite code into the review
* dialog hands over the only thing they have, and there is no announcement beside it
* to read an id off — so the id has to come out of the code, or the federation cannot
* be keyed, deduped against what is already indexed, or given a page.
*
* Two layers, both small and both self-contained (a bech32 dependency for one function
* would be the tail wagging the dog):
*
* 1. **bech32m**, per BIP-350: the same alphabet and checksum as bech32 with a
* different constant. Fedimint uses bech32m, so a code that verifies under the
* *bech32* constant is rejected rather than accepted — it would mean the code was
* produced by something else.
* 2. **fedimint's consensus encoding** of `Vec<InviteCodePart>`: a BigSize count, then
* per part a BigSize tag, a BigSize length and that many bytes. Tag 1 is the
* federation id, 32 bytes. Everything else — guardian API URLs (tag 0), an API
* secret (tag 2), anything a later fedimint adds — is skipped by its length
* without being understood, which is what the tag/length framing is for.
*
* Written against real codes off the relay pool, not against a reading of the Rust, and
* `check-index.ts` asserts it still decodes them.
*/
/** bech32's alphabet, and its two checksum constants. `1` is deliberately not in it. */
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BECH32M_CONST = 0x2bc830a3;
const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
function bech32Polymod(values: readonly number[]): number {
let chk = 1;
for (const value of values) {
const top = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GENERATOR[i]!;
}
return chk >>> 0;
}
function hrpExpand(hrp: string): number[] {
const out: number[] = [];
for (const char of hrp) out.push(char.charCodeAt(0) >> 5);
out.push(0);
for (const char of hrp) out.push(char.charCodeAt(0) & 31);
return out;
}
/**
* The payload bytes of a bech32m string, or null if it is not a valid one.
*
* Length capped well above any real invite code: the checksum is only meaningful over
* a string somebody could plausibly have produced, and an unbounded input here would
* be an unbounded loop below.
*/
function decodeBech32m(input: string): { hrp: string; bytes: Uint8Array } | null {
if (input.length < 8 || input.length > 4000) return null;
// Mixed case is invalid in bech32; one case throughout is not.
if (input !== input.toLowerCase() && input !== input.toUpperCase()) return null;
const value = input.toLowerCase();
const split = value.lastIndexOf('1');
if (split < 1 || split + 7 > value.length) return null;
const hrp = value.slice(0, split);
for (const char of hrp) {
const code = char.charCodeAt(0);
if (code < 33 || code > 126) return null;
}
const data: number[] = [];
for (const char of value.slice(split + 1)) {
const index = BECH32_CHARSET.indexOf(char);
if (index === -1) return null;
data.push(index);
}
if (bech32Polymod([...hrpExpand(hrp), ...data]) !== BECH32M_CONST) return null;
// Five-bit groups to eight, dropping the checksum and the final partial group.
const payload = data.slice(0, -6);
const bytes: number[] = [];
let acc = 0;
let bits = 0;
for (const group of payload) {
acc = (acc << 5) | group;
bits += 5;
while (bits >= 8) {
bits -= 8;
bytes.push((acc >> bits) & 0xff);
}
}
// Leftover bits must be zero padding, and there must be fewer than five of them.
if (bits >= 5 || ((acc << (8 - bits)) & 0xff) !== 0) return null;
return { hrp, bytes: Uint8Array.from(bytes) };
}
/** A cursor over the decoded bytes, reading fedimint's BigSize integers and blobs. */
class ByteReader {
private at = 0;
constructor(private readonly bytes: Uint8Array) {}
get done(): boolean {
return this.at >= this.bytes.length;
}
/**
* Lightning's BigSize, which is what fedimint encodes a `u64` as: one byte under
* 0xfd, otherwise a marker and 2, 4 or 8 big-endian bytes.
*
* Returns null rather than throwing when the buffer runs out, so a truncated code is
* a rejected code and not an exception a caller has to catch.
*/
bigSize(): number | null {
const first = this.byte();
if (first === null) return null;
if (first < 0xfd) return first;
const width = first === 0xfd ? 2 : first === 0xfe ? 4 : 8;
let value = 0;
for (let i = 0; i < width; i++) {
const next = this.byte();
if (next === null) return null;
// Above 2^53 nothing here is a real length or tag anyway, and the arithmetic
// stops being exact, so an absurd value is refused rather than rounded.
value = value * 256 + next;
if (value > Number.MAX_SAFE_INTEGER) return null;
}
return value;
}
bytesOf(length: number): Uint8Array | null {
if (length < 0 || this.at + length > this.bytes.length) return null;
const slice = this.bytes.subarray(this.at, this.at + length);
this.at += length;
return slice;
}
private byte(): number | null {
return this.at < this.bytes.length ? this.bytes[this.at++]! : null;
}
}
/** The tag fedimint gives the federation id inside an invite code. */
const INVITE_PART_FEDERATION_ID = 1;
/** A federation id is a 32 byte hash. A part of any other length is not one. */
const FEDERATION_ID_BYTES = 32;
/** Real codes carry two or three parts. This only has to stop a hostile count. */
const MAX_INVITE_PARTS = 64;
function toHex(bytes: Uint8Array): string {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}
/**
* The federation id inside an invite code, or null if the code is not one.
*
* Null covers every way a pasted string can fail — wrong prefix, a typo the checksum
* catches, valid bech32m that is not an invite code, an invite code with no federation
* id part — because a caller has exactly one thing to say about all of them ("that is
* not an invite code") and telling them apart would be telling a stranger which of
* their guesses was closest.
*/
export function federationIdFromInviteCode(code: string): string | null {
const raw = code?.trim();
if (!raw || !/^fed1[a-z0-9]+$/i.test(raw)) return null;
const decoded = decodeBech32m(raw);
// `fed1` is the human-readable part; the `1` after it is bech32's separator.
if (!decoded || decoded.hrp !== 'fed1') return null;
const reader = new ByteReader(decoded.bytes);
const parts = reader.bigSize();
if (parts === null || parts === 0 || parts > MAX_INVITE_PARTS) return null;
for (let i = 0; i < parts; i++) {
const tag = reader.bigSize();
const length = tag === null ? null : reader.bigSize();
const value = length === null ? null : reader.bytesOf(length);
if (value === null) return null;
if (tag === INVITE_PART_FEDERATION_ID && value.length === FEDERATION_ID_BYTES) {
return toHex(value);
}
}
return null;
}
+3
View File
@@ -4,3 +4,6 @@ export * from './normalize.js';
export * from './score.js';
export * from './nuts.js';
export * from './warnings.js';
export * from './fedimint.js';
export * from './lnurl.js';
export * from './indexing.js';
+208
View File
@@ -0,0 +1,208 @@
/**
* On-demand indexing: the contract `POST /api/index` speaks.
*
* Both ends of that request are in this repository — the API answers it, the 404
* resolver and the review-by-URL dialog send it — so the shapes live here rather than
* being written out twice and drifting. The reason codes in particular are the whole
* point of this file: the API decides *what happened*, the browser decides *what to
* say about it*, and a typo in a string literal must not be the thing that silently
* turns a precise sentence into a generic one.
*
* The endpoint itself is documented in BACKEND.md. What is here is only what both
* sides need to agree on: which types are indexable, what a valid submission looks
* like before any network call, and what comes back.
*/
import { federationIdFromInviteCode, isInviteCode } from './fedimint.js';
import { normalizeMintUrl } from './normalize.js';
import type { MintDetail, MintInfo } from './types.js';
/** The three ecosystems a reader can hand this site an identifier for. */
export const INDEXABLE_TYPES = ['cashu', 'fedimint', 'lnurl'] as const;
export type IndexType = (typeof INDEXABLE_TYPES)[number];
export function isIndexType(value: unknown): value is IndexType {
return typeof value === 'string' && (INDEXABLE_TYPES as readonly string[]).includes(value);
}
/**
* Why a submission did not become a row.
*
* Every one of these is a different sentence to a reader, which is why they are not
* collapsed into a generic failure:
*
* `bad_type` the `type` field was not one of the three
* `bad_input` the input is not a URL (or an invite code) at all
* `blocked_host` it resolves somewhere this server will not fetch from
* `wrong_type` the host answered, as a *different* ecosystem's mint
* `invalid_response` the host answered, as nothing this site recognises
* `invalid_invite` the invite code does not decode
* `unverifiable` nothing answered, and Nostr has never heard of it either
* `rate_limited` too many submissions from one address this hour
*/
export type IndexFailureReason =
| 'bad_type'
| 'bad_input'
| 'blocked_host'
| 'wrong_type'
| 'invalid_response'
| 'invalid_invite'
| 'unverifiable'
| 'rate_limited';
/** How a row that did not already exist came to be written. */
export type IndexSource = 'probe' | 'announcement' | 'invite';
export interface IndexFailure {
error: IndexFailureReason;
/** English, for a log or a `curl`. The browser renders its own translated copy. */
message: string;
/**
* The ecosystem this address *does* look like, when the answer said so.
*
* Only ever set beside `wrong_type`, and it is what lets the dialog offer "this looks
* like a Cashu mint, review it there instead" with a button rather than making the
* reader work out which page they wanted.
*/
detected_type?: IndexType;
/** Seconds until the next submission is accepted. Only beside `rate_limited`. */
retry_after?: number;
}
/** 200 or 201: the same payload `GET /api/mints/:host` returns, plus how it got there. */
export type IndexSuccess = MintDetail & {
/** True when the identifier was already indexed and nothing was probed. */
existing: boolean;
/** Absent on an `existing` hit: nothing was written, so nothing wrote it. */
indexed_from?: IndexSource;
};
export type IndexResponse = IndexSuccess | IndexFailure;
export function isIndexFailure(body: IndexResponse): body is IndexFailure {
return typeof (body as IndexFailure).error === 'string';
}
/**
* The client-side pre-check, run before any network call.
*
* Deliberately shallow: it answers "could this possibly be an address of this kind?"
* and nothing more. Whether the mint exists, answers, or is what it claims is the
* server's question, and asking the browser to guess would only produce a second
* opinion to disagree with. What it does catch is the common typo — an empty box, a
* sentence, a `fed1…` pasted into the Cashu field — before a request goes out.
*
* Returns the value to submit, which is the input with the scheme the normalizer would
* add, so the dialog can show the reader what it is about to check.
*/
export function checkIndexInput(
type: IndexType,
input: string,
): { ok: true; value: string } | { ok: false; reason: 'empty' | 'bad_url' | 'bad_invite' } {
const raw = input.trim();
if (!raw) return { ok: false, reason: 'empty' };
if (type === 'fedimint') {
return isInviteCode(raw) && federationIdFromInviteCode(raw) !== null
? { ok: true, value: raw.toLowerCase() }
: { ok: false, reason: 'bad_invite' };
}
// A pasted invite code in a URL field is a wrong-field mistake, not a malformed URL,
// and saying "that is an invite code" is more use than "that is not a URL".
if (isInviteCode(raw)) return { ok: false, reason: 'bad_invite' };
const normalized = normalizeMintUrl(raw);
return normalized ? { ok: true, value: normalized.url } : { ok: false, reason: 'bad_url' };
}
/**
* Is this body a NUT-06 mint info document?
*
* The probe needs a test that a Cashu mint passes and an arbitrary JSON endpoint fails,
* because `/v1/info` on a host that is not a mint is very often a 200 with *something*
* on it — an API index, a health check, a framework's error object. NUT-06 makes every
* field optional, so the test is "does it carry any of the things only a mint has":
* a `nuts` object, or a mint pubkey, or the name/version pair a mint's info always has.
*
* Kept here rather than in the prober because the wrong-type detection on the LNURL
* path runs the same test against the same document, and two spellings of "is this a
* Cashu mint" is exactly how a submission ends up filed under both ecosystems.
*/
export function isNut06Info(body: unknown): body is MintInfo {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
const info = body as Record<string, unknown>;
const nuts = info['nuts'];
if (nuts && typeof nuts === 'object' && !Array.isArray(nuts) && Object.keys(nuts).length > 0) {
return true;
}
// A mint pubkey is 33 compressed bytes, exactly as an LNURL mint's is.
if (typeof info['pubkey'] === 'string' && /^0[23][0-9a-f]{64}$/i.test(info['pubkey'])) {
return true;
}
return typeof info['name'] === 'string' && typeof info['version'] === 'string';
}
/**
* The route a page for this listing lives at, given its type and routing slug.
*
* One table, because three pages, the 404 resolver, the review dialog and the sitemap
* all have to agree on it, and the failure mode of disagreeing is a link to a page that
* does not exist. Locale prefixing is the caller's job (`localePath`).
*/
export const MINT_ROUTES: Record<IndexType, string> = {
cashu: '/mint',
fedimint: '/fedimint',
lnurl: '/lnurl-mint',
};
/** `/mint/mint.example.com`, unprefixed. */
export function mintPath(type: string, host: string): string {
const base = MINT_ROUTES[type as IndexType] ?? MINT_ROUTES.cashu;
return `${base}/${host}`;
}
/**
* Which ecosystem a page path belongs to, or null when it is not a listing page.
*
* The 404 resolver's first question: the reader asked for *something*, and whether
* this build has any business indexing it on their behalf is decided entirely by the
* shape of the path they used.
*/
export function typeForPath(path: string): { type: IndexType; host: string } | null {
const match = /^\/(mint|fedimint|lnurl-mint)\/([^/]+)\/?$/.exec(path);
if (!match?.[1] || !match[2]) return null;
const type: IndexType =
match[1] === 'fedimint' ? 'fedimint' : match[1] === 'lnurl-mint' ? 'lnurl' : 'cashu';
return { type, host: decodeURIComponent(match[2]) };
}
/**
* The address a `/mint/{slug}` or `/lnurl-mint/{slug}` deep link implies, or null when
* the slug cannot be turned back into one.
*
* Routing slugs are deterministic but not reversible: `mint.example.com/Bitcoin` becomes
* `mint.example.com-bitcoin`, and so would a mint at `mint.example.com-bitcoin` if one
* existed. Looking a row up by slug is unaffected — that is what the column is for — but
* *indexing* from a slug means fetching an address, and guessing which of two readings a
* hyphen had would mean probing the wrong host and possibly indexing it.
*
* So only the unambiguous shape is derived: a plain hostname, optionally with the port
* suffix the slug spells `-3338`. Everything else returns null, and the caller says it
* cannot look this one up from the link alone and offers the dialog, where the reader
* can paste the address including its path.
*
* The `lnurl-` collision prefix is deliberately *not* stripped. A slug only takes that
* prefix at insert time, so a link carrying one describes a row that exists and never
* reaches this function; a slug that merely starts with those characters is far more
* likely to be a mint whose hostname begins `lnurl-`, and turning it into a different
* host would mean probing — and possibly indexing — somebody else's server.
*/
export function addressFromSlug(slug: string): string | null {
const value = slug.trim().toLowerCase();
const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?:-(\d{2,5}))?$/.exec(value);
if (!match?.[1]) return null;
return `https://${match[1]}${match[2] ? `:${match[2]}` : ''}`;
}
+714
View File
@@ -0,0 +1,714 @@
/**
* LNURL mints: what a `kind:38174` announcement contains, what the mint's own endpoints
* return, and the handful of derived values the API, the pages and the islands all have
* to agree on.
*
* The kind is this site's own proposed NIP-87 extension, specified in
* `docs/KIND-LNURL-MINT.md`. That document and this file are meant to be read together:
* every rule below is stated there normatively, and `api/src/check-lnurl.ts` asserts the
* two have not drifted apart.
*
* The endpoint parsing was written against the live reference instance and against a
* locally run build of the mint software, not against a reading of the README — the same
* rule NOTES.md sets for the Cashu side. `NOTES-LNURL.md` records what was actually on
* the wire. Four places where the wire and the prose disagreed, all resolved for the wire:
*
* - There is no bare `/p`. The payRequest lives only at `/.well-known/lnurlp/{username}`,
* and `/p` is a hard 404. The mint advertisement — limits, description, pubkey, node
* identity — is on the *withdraw* side, `/.well-known/lnurlw/{username}`.
* - Every registered route answers its errors with **HTTP 200** and an LNURL
* `{"status":"ERROR"}` body. A status code proves nothing; only the parsed body does.
* - `None` fields are dropped from responses entirely, so "no funding source" shows up
* as `mintPubkey` and the node fields being *absent*, not null.
* - LUD-21 verify cannot be distinguished from an unknown payment hash. It is
* announcement-only; nothing here infers it.
*
* Every amount on the wire is millisatoshi. Nothing in this file rounds; `msatToSat`
* is where that decision is made once.
*/
import {
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
} from './nostr.js';
import { displayDomain, normalizeMintUrl } from './normalize.js';
/** The scheme on the synthetic `mints.url` an LNURL row is keyed by. */
export const LNURL_KEY_SCHEME = 'lnurl:';
/**
* Routing-slug prefix, used **only** on collision.
*
* `mints.host` is globally unique across every ecosystem, so an LNURL mint and a Cashu
* mint on the same hostname would fight over one slug and the loser would silently not
* be tracked. Almost always they do not collide, and the LNURL mint keeps the clean
* `lnurl.21mint.me` slug; when one does, the row takes `lnurl-` in front rather than
* vanishing. See `insertLnurlMint` for why that is decided at insert time and then never
* revisited.
*/
export const LNURL_SLUG_PREFIX = 'lnurl-';
/**
* The primary key an LNURL row uses.
*
* Namespaced rather than storing the bare URL, for the same reason `fedimint:` is: the
* column is the table's primary key across all three ecosystems, and one host serving
* both a Cashu mint and an LNURL mint must produce two rows, not a collision. The base
* URL is recoverable in full, so nothing is lost by the prefix.
*/
export function lnurlKey(baseUrl: string): string {
return LNURL_KEY_SCHEME + baseUrl;
}
/** The base URL inside an `lnurl:` key, or null if that is not what this is. */
export function baseUrlFromKey(key: string): string | null {
if (!key.startsWith(LNURL_KEY_SCHEME)) return null;
const url = key.slice(LNURL_KEY_SCHEME.length);
return url.startsWith('https://') ? url : null;
}
/* ---------- identity ---------- */
/**
* A mint pubkey: the funding node's identity key, 33 bytes compressed, 66 hex characters
* beginning `02` or `03`.
*
* Strict about the length and the prefix on purpose. This value becomes the `d` tag, and
* the whole reason the two `d` forms are unambiguous is that one of them is exactly this
* shape and the other never is.
*/
export function isMintPubkey(value: unknown): value is string {
return typeof value === 'string' && /^0[23][0-9a-f]{64}$/i.test(value);
}
/**
* The `d` fallback: a mint's normalized host, with no scheme and no trailing slash.
*
* `https://mint.example.com/lnurl/` becomes `mint.example.com/lnurl`. Always contains a
* dot and never matches `isMintPubkey`, which is what keeps the two forms apart.
*/
export function hostIdentifier(baseUrl: string): string {
return displayDomain(baseUrl).toLowerCase();
}
/**
* The canonical `d` for a mint, given whatever is known about it.
*
* Pubkey when there is one, host otherwise — and the pubkey is *sticky*: a caller
* passing a previously known pubkey keeps it even when the current probe found none,
* because a node being unreachable for one request is not a change of identity. See
* "When a mint gains a pubkey after being announced by host" in the kind document.
*/
export function lnurlIdentifier(baseUrl: string, mintPubkey: string | null | undefined): string {
return isMintPubkey(mintPubkey) ? mintPubkey.toLowerCase() : hostIdentifier(baseUrl);
}
/**
* Both identifiers a review of this mint could carry, for a `#d` relay filter and for
* resolution.
*
* A mint announced by host before it had a funding source, and by pubkey afterwards, has
* reviews pointing at both. Asking for only the current one strands the older half.
*/
export function lnurlIdentifiers(
baseUrl: string,
mintPubkey: string | null | undefined,
): string[] {
const host = hostIdentifier(baseUrl);
return isMintPubkey(mintPubkey) ? [mintPubkey.toLowerCase(), host] : [host];
}
/* ---------- features ---------- */
/**
* Note operations: the four branches of LUD-25's `/w/cb`, plus minting.
*
* Split into two groups because that is where a missing funding source cuts. `mint` and
* `melt` both need the node — one issues an invoice, the other pays one — while
* `rotate`, `split` and `merge` only rewrite this mint's own book and keep working with
* no node at all. That distinction is the whole reason the degraded state is worth
* rendering rather than collapsing into "offline".
*/
export const FUNDED_FEATURES = ['mint', 'melt'] as const;
export const NOTE_FEATURES = ['rotate', 'split', 'merge'] as const;
/** The LNURL sub-specifications a mint can speak. */
export const SPEC_FEATURES = ['lud06', 'lud03', 'lud16', 'lud21'] as const;
/** Optional extras, both of which are genuinely visible from outside. */
export const EXTRA_FEATURES = ['signed-notes', 'onion'] as const;
/**
* The whole vocabulary, and the only values this build has an opinion about.
*
* Anything else in a `features` tag is kept and shown as its own chip rather than
* dropped: the kind document requires consumers to ignore tokens they do not recognise,
* and rendering an unknown capability under its published name says exactly as much as
* it should.
*/
export const FEATURE_VOCABULARY: readonly string[] = [
...FUNDED_FEATURES, ...NOTE_FEATURES, ...SPEC_FEATURES, ...EXTRA_FEATURES,
];
/**
* The named rows on the mint page's Features panel, in display order.
*
* `notes` is a row and not a feature: the three note-rewriting operations are one thing
* to a reader ("can I reshape what I hold?") and always ship together, so one row
* satisfied by any of them beats three rows that are always identical. Every other row
* is one vocabulary value.
*/
export const HIGHLIGHT_FEATURES = [
'mint', 'melt', 'notes', 'lud16', 'lud21', 'signed-notes', 'onion',
] as const;
export type HighlightFeature = (typeof HIGHLIGHT_FEATURES)[number];
/** Which vocabulary values satisfy each named row. */
export const FEATURE_ALIASES: Record<string, readonly string[]> = {
mint: ['mint'],
melt: ['melt'],
notes: ['rotate', 'split', 'merge'],
lud16: ['lud16'],
lud21: ['lud21'],
'signed-notes': ['signed-notes'],
onion: ['onion'],
};
/**
* Rows whose row is only as good as the funding source behind it.
*
* A mint that implements minting, melting and note signing still cannot do any of them
* while its node is unreachable, and the panel says so rather than showing a tick that
* is not true today. `rotate`/`split`/`merge` are deliberately absent from this set:
* they are exactly what still works.
*/
export const FUNDING_DEPENDENT: readonly HighlightFeature[] = ['mint', 'melt', 'signed-notes'];
/**
* Plain-language names, and the English source of truth for them.
*
* The catalogs carry a translation per key under `lnurl.feature.`; anything neither
* knows renders as its own published token, which is still a true label.
*/
export const FEATURE_NAMES_EN: Record<string, string> = {
mint: 'Mint via Lightning (LUD-06)',
melt: 'Melt to Lightning',
notes: 'Rotate / split / merge notes',
lud16: 'Lightning address',
lud21: 'Payment verification (LUD-21)',
'signed-notes': 'Signed notes (offline verification)',
onion: 'Tor address',
rotate: 'Rotate a note',
split: 'Split a note',
merge: 'Merge notes',
lud06: 'LUD-06 payRequest',
lud03: 'LUD-03 withdrawRequest',
};
/**
* Split a `features` tag: `"mint,melt,rotate,lud06"`.
*
* Comma separated, but whitespace is tolerated because a publisher writing
* `"mint, melt"` meant the same thing — the same latitude `parseModules` gives a
* `modules` tag. Lowercased, deduped, order preserved, and bounded so a hostile tag
* cannot become a thousand chips on a page.
*/
export function parseFeatures(value: string | null | undefined): string[] {
if (!value) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const part of value.split(/[,\s]+/)) {
const feature = part.trim().toLowerCase();
if (!feature || feature.length > 32 || seen.has(feature)) continue;
if (!/^[a-z0-9_-]+$/.test(feature)) continue;
seen.add(feature);
out.push(feature);
if (out.length >= 40) break;
}
return out;
}
/** Which named row a vocabulary value belongs to, or null for the chip list. */
export function highlightFeatureFor(feature: string): HighlightFeature | null {
const name = feature.toLowerCase();
for (const key of HIGHLIGHT_FEATURES) {
if (FEATURE_ALIASES[key]?.includes(name)) return key;
}
return null;
}
/** True when this mint claims anything satisfying one of the named rows. */
export function hasFeature(features: readonly string[], key: HighlightFeature): boolean {
const aliases = FEATURE_ALIASES[key] ?? [];
return features.some((feature) => aliases.includes(feature.toLowerCase()));
}
/** Features with no named row of their own, for the chip list under the seven. */
export function otherFeatures(features: readonly string[]): string[] {
return features.filter((feature) => highlightFeatureFor(feature) === null);
}
/**
* What one row of the Features panel says.
*
* Three states rather than two, which is the one place this panel is richer than the
* Fedimint Modules panel it is modelled on: a capability can be published, and still be
* unavailable this minute because the node behind it is unreachable. Collapsing that
* into "supported" would show a tick beside something that would fail if tried.
*/
export type FeatureState = 'ok' | 'unavailable' | 'none';
/**
* The state of every named row, given what was announced and what the probe saw.
*
* The announcement decides whether a capability exists at all; the probe can only take
* one away, and only the three that depend on a funding source. That asymmetry is
* normative in the kind document: a prober never rewrites an operator's `features`.
*
* `fundingAvailable` is null when nothing has probed yet, which reads as "no reason to
* doubt it" rather than as a failure.
*/
export function featureStates(
features: readonly string[],
fundingAvailable: boolean | null,
): Record<HighlightFeature, FeatureState> {
const out = {} as Record<HighlightFeature, FeatureState>;
for (const key of HIGHLIGHT_FEATURES) {
if (!hasFeature(features, key)) {
out[key] = 'none';
continue;
}
out[key] =
fundingAvailable === false && FUNDING_DEPENDENT.includes(key) ? 'unavailable' : 'ok';
}
return out;
}
/**
* The features a probe can honestly claim, from what it observed.
*
* Every entry has an observation behind it, and the four that are missing are the
* point. `rotate`, `split` and `merge` are only provable by calling `/w/cb`, which
* mutates or destroys a stranger's note; `lud21` is genuinely undetectable, because a
* disabled verify endpoint and an unknown payment hash return byte-identical responses
* (NOTES-LNURL.md §5). None of the four is inferred from a version string.
*
* Two callers, one definition, deliberately: the page renders this beside an operator's
* announced list, and the publisher signs it into a `kind:38174`. Those must not be
* able to disagree about what this site claims to have seen.
*
* The order is the vocabulary's own, so two runs over one mint produce identical
* output and the publisher's change detector does not fire on a reordering.
*/
export function observedFeatures(probe: {
fundingAvailable: boolean | null;
maxWithdrawableMsat: number | null;
maxSendableMsat: number | null;
lightningAddress: string | null;
mintPubkey: string | null;
onionUrl: string | null;
}): string[] {
const features: string[] = [];
/*
* `mint` and `melt` each need two things: the mint advertising the relevant side,
* and a funding source that can actually perform it. A mint whose node is unreachable
* implements both and can do neither, and this site only ever saw it in the state
* where it could not — so it does not say otherwise.
*/
const funded = probe.fundingAvailable === true;
if (funded && probe.maxSendableMsat !== null) features.push('mint');
if (funded && (probe.maxWithdrawableMsat ?? 0) > 0) features.push('melt');
if (probe.maxSendableMsat !== null) features.push('lud06');
// The advertisement's `callback` is `/w`, the LUD-03 withdrawRequest, and parsing the
// advertisement at all is what proves it was served.
if (probe.maxWithdrawableMsat !== null) features.push('lud03');
if (probe.lightningAddress) features.push('lud16');
if (probe.mintPubkey && funded) features.push('signed-notes');
if (probe.onionUrl) features.push('onion');
return features;
}
/**
* What the Features panel renders: what the operator announced, plus what was observed.
*
* A union, and it has to be one. The announcement is the operator's claim about what
* they built and is the richer list — only they can tell you that notes rotate. The
* probe is this site's own observation and is the *only* list for a mint nobody has
* announced yet, which today is every LNURL mint on the network.
*
* Announced values come first so an operator's own ordering survives. Note that this is
* a display concern and nothing else: `LnurlFields.features` still holds the
* announcement verbatim, and the publisher still signs only `observedFeatures`. The
* kind document's rule is that a prober never rewrites an operator's list, and nothing
* here does — it renders two lists side by side.
*/
export function displayFeatures(
announced: readonly string[] | null | undefined,
observed: readonly string[] | null | undefined,
): string[] {
return [...new Set([...(announced ?? []), ...(observed ?? [])])];
}
/* ---------- amounts ---------- */
/**
* Millisatoshi to satoshi, floored.
*
* Floored rather than rounded because both of the numbers this converts are *bounds*: a
* `maxWithdrawable` rounded up advertises a note larger than the mint will ever issue,
* and a `minWithdrawable` rounded down advertises one it will refuse. Flooring keeps the
* displayed range inside the real one at both ends, which is the safe direction to be
* wrong in. Sub-sat amounts floor to 0, which is true and is what the "withdrawals
* disabled" warning keys on.
*/
export function msatToSat(msat: number | null | undefined): number | null {
if (typeof msat !== 'number' || !Number.isFinite(msat) || msat < 0) return null;
return Math.floor(msat / 1000);
}
/* ---------- the mint's own endpoints ---------- */
/** The path the mint advertisement lives on. `_` is LUD-16's reserved bare-domain name. */
export const WITHDRAW_INFO_PATH = '/.well-known/lnurlw/_';
/** The LUD-06 payRequest, used as a fallback when the withdraw side does not answer. */
export const PAY_INFO_PATH = '/.well-known/lnurlp/_';
/**
* What a mint advertisement yields once parsed.
*
* Everything is optional except the two limits and the tag, because that is genuinely
* what varies: a mint with no funding source omits its whole node section, and the
* response is still valid and still worth rendering.
*/
export interface LnurlAdvertisement {
minWithdrawableMsat: number;
maxWithdrawableMsat: number;
defaultDescription: string | null;
mintPubkey: string | null;
payLink: string | null;
nodeAlias: string | null;
nodeUri: string | null;
nodeCapacityMsat: number | null;
nodeChannels: number | null;
nodePeers: number | null;
/**
* Whether the mint's funding source answered.
*
* Derived, not read: `mintPubkey` is populated only when a funding source is both
* configured and reachable, and is dropped from the response otherwise. One bit, and
* it is the only HTTP-visible signal there is — see NOTES-LNURL.md for why it is not
* possible to separate "never configured" from "unreachable right now", and why the
* site does not try.
*/
fundingAvailable: boolean;
}
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function str(value: unknown, max = 400): string | null {
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text || text.length > max) return null;
return text;
}
/**
* Parse `/.well-known/lnurlw/_`.
*
* Returns null for anything that is not a withdrawRequest carrying both limits — which
* includes the mint's own `{"status":"ERROR"}` bodies, since those arrive with HTTP 200
* and would otherwise read as a successful probe. The caller turns null into the
* "responding but invalid" state, which is neither online nor offline.
*/
export function parseAdvertisement(body: unknown): LnurlAdvertisement | null {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const raw = body as Record<string, unknown>;
if (raw['tag'] !== 'withdrawRequest') return null;
const min = num(raw['minWithdrawable']);
const max = num(raw['maxWithdrawable']);
// Both bounds are required, and inverted bounds are not a mint advertisement.
if (min === null || max === null || min < 0 || max < 0 || min > max) return null;
const mintPubkey = isMintPubkey(raw['mintPubkey']) ? String(raw['mintPubkey']).toLowerCase() : null;
return {
minWithdrawableMsat: min,
maxWithdrawableMsat: max,
defaultDescription: str(raw['defaultDescription']),
mintPubkey,
payLink: str(raw['payLink']),
nodeAlias: sanitizeDisplayText(raw['nodeAlias'], 64) ?? null,
nodeUri: str(raw['nodeUri'], 200),
nodeCapacityMsat: num(raw['nodeCapacity']),
nodeChannels: num(raw['nodeNumChannels']),
nodePeers: num(raw['nodeNumPeers']),
fundingAvailable: mintPubkey !== null,
};
}
/** What the LUD-06 payRequest yields. Fetched for the fee, the address and the limits. */
export interface LnurlPayInfo {
minSendableMsat: number;
maxSendableMsat: number;
/** The `text/plain` entry: the closest thing to an operator-written description. */
description: string | null;
/** The `text/identifier` entry, a LUD-16 lightning address. */
identifier: string | null;
/** `Mint fees: <base>,<ppm>`, absent when the mint is fee-free. */
feeBaseMsat: number | null;
feePpm: number | null;
/** The `withdrawLink` extension: lnurlcash's pointer back to the withdraw side. */
withdrawLink: string | null;
}
/**
* Parse `/.well-known/lnurlp/_`.
*
* `metadata` is a JSON *string* holding a JSON array of `[mime, value]` pairs, so it is
* parsed twice. A metadata blob that will not parse costs the description and the
* address and nothing else: the limits above it are still good.
*/
export function parsePayInfo(body: unknown): LnurlPayInfo | null {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const raw = body as Record<string, unknown>;
if (raw['tag'] !== 'payRequest') return null;
const min = num(raw['minSendable']);
const max = num(raw['maxSendable']);
if (min === null || max === null || min < 0 || max < 0 || min > max) return null;
let description: string | null = null;
let identifier: string | null = null;
let feeBaseMsat: number | null = null;
let feePpm: number | null = null;
try {
const entries: unknown = JSON.parse(typeof raw['metadata'] === 'string' ? raw['metadata'] : '[]');
if (Array.isArray(entries)) {
for (const entry of entries) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
continue;
}
const [mime, value] = entry as [string, string];
// `Mint fees: <base_msat>,<ppm>` shares the `text/plain` mime with the
// description, so it is recognised by its prefix and taken out of the running
// for one — otherwise a fee-charging mint's description would be its fee line.
const fees = /^Mint fees:\s*(\d+)\s*,\s*(\d+)\s*$/i.exec(value);
if (mime === 'text/plain' && fees) {
feeBaseMsat = Number.parseInt(fees[1]!, 10);
feePpm = Number.parseInt(fees[2]!, 10);
continue;
}
if (mime === 'text/plain' && description === null) {
description = sanitizeDisplayText(value, 400) ?? null;
}
if (mime === 'text/identifier' && identifier === null) {
identifier = isLightningAddress(value) ? value.trim().toLowerCase() : null;
}
}
}
} catch {
// Unparseable metadata. The limits are still real, so this is not a failed probe.
}
return {
minSendableMsat: min,
maxSendableMsat: max,
description,
identifier,
feeBaseMsat,
feePpm,
withdrawLink: str(raw['withdrawLink']),
};
}
/** `name@domain`, and only that. Same shape rule the review cards apply to a NIP-05. */
export function isLightningAddress(value: unknown): value is string {
if (typeof value !== 'string') return false;
const text = value.trim();
return text.length <= 128 && /^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text);
}
/**
* The mint's lightning address, derived from `payLink`.
*
* Not read off the payRequest's own `text/identifier`, which echoes back whatever
* username was queried — probing `_` gets `_@host`, which is LUD-16's bare-domain form
* and not something to render at a reader. `payLink` is built from the operator's
* configured username unconditionally, so it is the one place the real name appears.
*/
export function addressFromPayLink(payLink: string | null | undefined): string | null {
if (!payLink) return null;
let url: URL;
try {
url = new URL(payLink);
} catch {
return null;
}
const username = /\/\.well-known\/lnurlp\/([^/?#]+)$/.exec(url.pathname)?.[1];
if (!username || username === '_') return null;
const address = `${decodeURIComponent(username)}@${url.hostname.toLowerCase()}`;
return isLightningAddress(address) ? address : null;
}
/**
* A `*.onion` host in the one-pager, or null.
*
* Deliberately looser than base32's alphabet. A v3 address is 56 characters of `a-z2-7`,
* but the mint software's own test fixture is not valid base32, and a real address that
* does not fit the expected shape is still the operator's address. Nothing is ever
* fetched over Tor by this site, so the cost of a wrong match is one displayed string.
*/
export function onionFromHtml(html: string): string | null {
const match = /\b([a-z0-9]{16,60}\.onion)\b/i.exec(html);
return match?.[1]?.toLowerCase() ?? null;
}
/**
* The software version, from `GET /openapi.json`.
*
* `0.0.0+unknown` is the package's own "I could not find my metadata" sentinel, not a
* release, so it is treated as no version at all rather than printed at a reader. The
* title is checked too: an arbitrary FastAPI app on the same host would otherwise
* contribute its version to a mint's page.
*/
export function parseSoftware(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const info = (body as Record<string, unknown>)['info'];
if (!info || typeof info !== 'object') return null;
const record = info as Record<string, unknown>;
const title = str(record['title'], 64);
const version = str(record['version'], 64);
if (!title || !version) return null;
if (!/^[\w.+-]+$/.test(version) || version.startsWith('0.0.0+unknown')) return null;
if (!/^[\w.@/ -]+$/.test(title)) return null;
return `${title}/${version}`;
}
/* ---------- the announcement ---------- */
export interface LnurlAnnouncement {
/** The `d` tag: a mint pubkey, or a normalized host. */
identifier: string;
/** The `d` tag when it was a pubkey, else null. Feeds the sticky identity rule. */
mintPubkey: string | null;
/** The canonical `u` tag, normalized. This is what rows are deduped by. */
baseUrl: string;
/** The routing slug the normalizer derived from that URL. */
slug: string;
/** The `features` tag, split. */
features: string[];
/** The `n` tag, normalized. */
network: string | null;
/** From `content`, which is kind-0-shaped metadata. */
name: string | null;
picture: string | null;
about: string | null;
announcerPubkey: string;
announcedAt: number;
}
/**
* Read a kind 38174 event, or return null if it is not one this site can use.
*
* `u` is required here where NIP-87 makes it a SHOULD for 38172, and the kind document
* says why: an LNURL mint's `d` may be a bare host with no scheme, which is not
* something to fetch, so an announcement with no usable `u` carries no address at all
* and there is nothing to probe, key a row by, or link to.
*
* The `d` tag is *not* required to match the `u` tag's host. A mint that has a pubkey
* announces under it, and checking the two against each other would reject exactly the
* events the identity rule exists to allow.
*/
export function parseLnurlAnnouncement(event: NostrEventLike): LnurlAnnouncement | null {
const d = tagValue(event.tags, 'd')?.trim().toLowerCase();
if (!d || d.length > 200) return null;
// A `d` that is neither a pubkey nor host-shaped is not an identifier this build can
// resolve a review against, so the announcement is not usable even if `u` is fine.
if (!isMintPubkey(d) && !/^[a-z0-9.-]+\.[a-z]{2,}(:\d+)?(\/[\w./~-]*)?$/.test(d)) return null;
let normalized: ReturnType<typeof normalizeMintUrl> = null;
for (const raw of tagValues(event.tags, 'u')) {
normalized = normalizeMintUrl(raw);
if (normalized) break;
}
if (!normalized) return null;
const meta = parseLnurlMetadata(event.content);
return {
identifier: d,
mintPubkey: isMintPubkey(d) ? d : null,
baseUrl: normalized.url,
slug: normalized.host,
features: parseFeatures(tagValue(event.tags, 'features')),
network: normalizeLnurlNetwork(tagValue(event.tags, 'n')),
name: meta.name,
picture: meta.picture,
about: meta.about,
announcerPubkey: event.pubkey,
announcedAt: event.created_at,
};
}
/**
* The `content` of a 38174, which the kind document defines as kind-0-style metadata.
*
* Same defensive posture as the Fedimint parser: this is arbitrary text written by
* anyone with a relay connection, so unparseable JSON, wrong types and junk fields all
* degrade to "no metadata" rather than throwing.
*/
export function parseLnurlMetadata(content: string): {
name: string | null;
picture: string | null;
about: string | null;
} {
const empty = { 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', 'display_name', 'mint_name'), 64) ?? null,
picture: sanitizePictureUrl(first('picture', 'icon_url', 'image')) ?? null,
about: sanitizeDisplayText(first('about', 'description'), 400) ?? null,
};
}
/**
* Normalize an `n` tag.
*
* The same mapping the Fedimint side applies, and for the same reason: NIP-87 names the
* value `mainnet`, publishers copy each other, and `bitcoin` is what they write. Kept
* separate from `normalizeNetwork` only so the two ecosystems' rules can diverge later
* without one quietly changing the other; today they agree, and `check-lnurl.ts` asserts
* that they still do.
*/
export function normalizeLnurlNetwork(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;
}
+102 -1
View File
@@ -26,13 +26,114 @@ 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;
// Anything that parses as an IP literal is judged by the resolved-address rule too,
// so the two checks cannot disagree about, say, 100.64.0.1 or 224.0.0.1.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && isPrivateIpAddress(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;
}
/**
* Is this literal IP address one the indexer must never connect to?
*
* Written against a *resolved* address rather than a hostname, which is the difference
* between this and `isDisallowedHost` above: `mint.example.com` looks like an ordinary
* public name and can resolve to `127.0.0.1`, and only the answer DNS gave can tell you
* so. `POST /api/index` fetches URLs a stranger typed, so it resolves first and checks
* every address here before a socket is opened.
*
* Broader than the hostname rule on purpose. Beyond loopback, link-local and the three
* RFC-1918 ranges it also refuses carrier-grade NAT (100.64/10), `0.0.0.0/8`, the
* benchmarking and documentation ranges, multicast and the broadcast address: none of
* them is a public mint, and each of them is somewhere on a network this server can see
* and a stranger should not be able to point it at.
*/
export function isPrivateIpAddress(value: string): boolean {
const ip = value.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!ip) return true;
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
if (a === undefined || b === undefined || a > 255 || b > 255) return true;
if (a === 0 || a === 10 || a === 127) return true; // this-network, RFC1918, loopback
if (a === 169 && b === 254) return true; // link-local
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
if (a === 192 && b === 168) return true; // RFC1918
if (a === 192 && b === 0) return true; // IETF protocol assignments / 192.0.2.0 docs
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
if (a === 198 && b === 51) return true; // documentation
if (a === 203 && b === 0) return true; // documentation
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
if (a >= 224) return true; // multicast, reserved, broadcast
return false;
}
if (!ip.includes(':')) return true; // Not an address this function understands.
// An IPv4-mapped or IPv4-compatible address is judged on its IPv4 half.
const mapped = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(ip);
if (mapped?.[1]) return isPrivateIpAddress(mapped[1]);
if (ip === '::' || ip === '::1') return true;
if (/^f[cd]/.test(ip)) return true; // unique local, fc00::/7
if (/^fe[89ab]/.test(ip)) return true; // link-local, fe80::/10
if (/^ff/.test(ip)) return true; // multicast
return false;
}
/**
* Is this hostname one the indexer must never fetch, before DNS is consulted at all?
*
* The cheap half of the check: `localhost`, `.onion`, `.local`, a bare label with no
* dot, and an IP literal that is already disqualified by `isPrivateIpAddress`. Exported
* so the on-demand indexer can refuse the obvious cases without paying for a lookup,
* and so a redirect hop can be judged by the same rule its origin was.
*/
export function isBlockedHostname(hostname: string): boolean {
return isDisallowedHost(hostname.toLowerCase());
}
/**
* 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.
+104 -7
View File
@@ -11,9 +11,58 @@ 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;
/**
* LNURL mint announcement.
*
* Not in NIP-87: this site's own proposed extension to it, specified in
* `docs/KIND-LNURL-MINT.md` and intended for a PR to nostr-protocol/nips. 38174 is the
* next free slot in the family — checked against the kind index in the nips README and
* against every issue and PR in that repository before it was claimed.
*/
export const KIND_LNURL_ANNOUNCEMENT = 38174;
/** 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,
lnurl: KIND_LNURL_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 +229,50 @@ 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);
}
/** True when the event's `k` tag marks it as being about an LNURL mint. */
export function isLnurlReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_LNURL_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 +303,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 +335,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 +346,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 +375,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
View File
@@ -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 ?? '');
}
+267 -4
View File
@@ -1,6 +1,26 @@
/** Shapes returned by the API. The web app builds against these. */
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
import type { MintCapabilities } from './warnings.js';
/**
* 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' | 'lnurl' | (string & {});
/** One item of `GET /api/mints`. */
export interface MintListItem {
@@ -8,6 +28,8 @@ export interface MintListItem {
host: string;
name: string | null;
icon: string | null;
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed read as 'cashu'. */
type: MintType;
status: MintStatus;
last_online: number | null;
review_count: number;
@@ -15,6 +37,40 @@ export interface MintListItem {
score: number;
last_review_at: number | null;
version: string | null;
/* ---- card chips ----
*
* The three fields below exist so a card can be drawn from the list payload alone.
* Before them, /mints fetched `GET /api/mints/:host` once per mint at build time just
* to read two booleans off each one, which is fine for fifty-five mints on one build
* machine and is not fine for every visitor's browser once the list hydrates. They are
* facts, never rendered strings: the label a chip prints is decided by `mintChip` in
* the reader's own language, on whichever side is drawing the card.
*
* A federation has no counterpart and needs none — it publishes no capability list, so
* `mintChip` returns null for one and always will. See the Fedimint branch of
* `getMintWarnings`.
*/
/**
* NUT numbers this mint publishes, as strings: `["4", "5", "17"]`. Cashu only; empty
* for the other ecosystems and for a mint whose `/v1/info` has never been read.
*/
nuts: string[];
/**
* NUT-04 and NUT-05 switches, the Cashu chip's only input. null means nothing is
* cached for this mint, which is a different fact from "both are on" — see
* `readCapabilities`.
*/
capabilities: MintCapabilities | null;
/**
* LNURL: the advertised withdraw ceiling, millisatoshi. Optional rather than
* `| null`, so this stays exactly what `Partial<LnurlFields>` declares on `MintDetail`
* and the two do not have to be kept identical by hand.
*/
max_withdrawable_msat?: number | null;
/** LNURL: whether the last probe reached the mint's funding node. */
funding_available?: boolean | null;
}
export interface ProbeSample {
@@ -25,8 +81,17 @@ 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 and LNURL keys are optional and absent on a Cashu mint, which is what
* keeps the Cashu payload unchanged. Read them through `FedimintDetail` or
* `LnurlDetail` after checking `type`.
*/
export interface MintDetail
extends MintListItem,
Partial<FedimintFields>,
Partial<LnurlFields> {
description: string | null;
pubkey: string | null;
info: MintInfo | null;
@@ -41,7 +106,140 @@ 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;
/**
* What `ecosystem_json` holds for an LNURL row.
*
* Two sources, never mixed: `features` is what the operator *announced* on Nostr, and
* everything under "probed" is what the mint's own endpoints said when they were last
* reached. The kind document makes that separation normative — a prober never rewrites
* an operator's capability list — and keeping the two in different fields is what makes
* it impossible to do by accident.
*
* Every millisatoshi field is stored exactly as the wire gave it. Conversion to sats
* happens once, at render, through `msatToSat`.
*/
export interface LnurlFields {
/** The `d` tag: the mint pubkey when it has one, else the normalized host. */
lnurl_id: string;
/** The https base URL. `host` is the routing slug derived from it. */
base_url: string;
/** The `features` tag, split. The operator's claim, never edited by a probe. */
features: string[];
/**
* What the last probe actually observed this mint serving.
*
* Kept apart from `features` above rather than merged into it, because the two are
* different kinds of statement — a claim and an observation — and the kind document
* makes it normative that a prober never rewrites the first. The page renders their
* union through `displayFeatures`; the publisher signs only this one.
*/
observed_features: string[];
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
network: string | null;
/** `created_at` of the newest announcement seen. null for a seeded row. */
announced_at: number | null;
/** Who published that announcement. The `a` tag of a review points back at them. */
announcer_pubkey: string | null;
/* ---- probed: from the mint's own endpoints ---- */
/**
* The funding node's identity key, from the mint advertisement.
*
* Sticky once learned: a probe that finds none does not clear it, because a node
* being unreachable for one request is not a change of identity. Its *absence from
* the latest probe* is recorded separately, in `funding_available`.
*/
mint_pubkey: string | null;
/**
* Whether the last probe found a reachable funding source.
*
* null before anything has probed. false is the degraded-but-online state: the mint
* answers, its limits are real, and `rotate`/`split`/`merge` still work, but nothing
* moves in or out over Lightning. See NOTES-LNURL.md for why this one bit cannot
* distinguish "never configured" from "unreachable right now", and why that is fine.
*/
funding_available: boolean | null;
/** Which endpoint answered: the withdraw advertisement, or the payRequest fallback. */
probe_endpoint: string | null;
/**
* Set when the host answered but with something that is not a mint advertisement.
*
* A distinct outcome from both online and offline, and it has to be: these endpoints
* return HTTP 200 for their errors, so "responding" and "working" are different
* questions. Carries the short reason, for the banner.
*/
invalid_reason: string | null;
/** Withdraw bounds, millisatoshi: what a note's value can actually be. */
min_withdrawable_msat: number | null;
max_withdrawable_msat: number | null;
/** Pay bounds, millisatoshi: what a minter can actually send. Not the same numbers. */
min_sendable_msat: number | null;
max_sendable_msat: number | null;
/** `Mint fees: <base>,<ppm>` from the payRequest metadata. Absent means fee-free. */
fee_base_msat: number | null;
fee_ppm: number | null;
/** The LUD-16 address, derived from `payLink` rather than the echoed identifier. */
lightning_address: string | null;
/** The Tor address from the one-pager, when one is advertised. */
onion_url: string | null;
/** The funding node, as the mint chooses to describe it. All optional, all msat. */
node_alias: string | null;
node_uri: string | null;
node_capacity_msat: number | null;
node_channels: number | null;
node_peers: number | null;
}
/** `GET /api/mints/:host` for an LNURL mint: the detail plus its own fields. */
export type LnurlDetail = MintDetail & LnurlFields;
/**
* `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 +248,51 @@ 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;
lnurl_total: number;
/** LNURL mints a probe reached. Online includes the degraded-funding ones. */
lnurl_online: number;
lnurl_offline: number;
/**
* Online mints whose funding source was unreachable at the last probe: up and
* serving, but nothing moves in or out over Lightning. A subset of `lnurl_online`,
* never added to it.
*/
lnurl_degraded_funding: number;
/** Reviews of LNURL mints (`k` = 38174), included in `reviews_total`. */
lnurl_reviews: number;
}
/**
* How one configured relay answered during the last discovery cycle.
*
* The three facts are deliberately separate, because the failure this exists to catch
* had all three looking different from each other: the relays in `RELAYS` connected
* fine, reached EOSE fine, and simply did not carry the archive, so `events` was the
* only field that would have said anything. A relay that is down and a relay that is
* up and empty are different problems with different fixes.
*/
export interface RelayHealth {
url: string;
/** A socket was opened to it. false means the address is wrong or the relay is down. */
connected: boolean;
/**
* Events it sent, counted before cross-relay deduplication — so this is what *this*
* relay contributed, not what was new because of it. Zero on a connected relay is
* the interesting number.
*/
events: number;
/** Every query it was asked ended in a real EOSE rather than in a timeout. */
eose: boolean;
}
/** `GET /api/health`. */
@@ -60,6 +303,26 @@ export interface Health {
last_discovery_at: number | null;
mints_tracked: number;
updated_at: number;
/**
* Per-relay outcome of the last discovery cycle. Empty until one has run — including
* on a fresh database, which is why a brand new deployment reports degraded until its
* first backfill finishes.
*/
discovery_relays: RelayHealth[];
/** Unique events the last discovery cycle received. null before the first one. */
last_discovery_events: number | null;
/** Which kind of cycle those numbers describe. */
last_discovery_mode: 'backfill' | 'incremental' | null;
/**
* The last backfill came back under `backfill_min_events`, or none has run yet.
*
* This is the flag that would have caught a year of ~31-event backfills against a
* relay list missing the archive. It forces `status` to degraded, and /api/health to
* 503, which is what the build gate and the site's own health checks read.
*/
discovery_starved: boolean;
/** `BACKFILL_MIN_EVENTS`, echoed so a reader of this payload can see the threshold. */
backfill_min_events: number;
}
/**
+372 -9
View File
@@ -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,23 @@ 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
// LNURL. The offline tiers above are shared verbatim rather than twinned: `gone`,
// `offline-long` and `offline` are about reachability, which means exactly the same
// thing for an LNURL mint as for a Cashu one, and their copy already reads correctly
// for both. Only the states with no Cashu equivalent are new.
| 'lnurl-withdrawals-disabled' // maxWithdrawable is zero: nothing can be redeemed
| 'lnurl-invalid' // the host answers, but not with a mint advertisement
| 'lnurl-no-funding'; // up and serving, but its Lightning node is unreachable
export interface MintWarning {
kind: MintWarningKind;
@@ -108,8 +125,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;
/**
@@ -119,6 +146,19 @@ export interface MintWarningInput {
capabilities?: Pick<MintCapabilities, 'mintDisabled' | 'meltDisabled'> | null;
/** Only used to date "never answered a single check". */
first_seen?: number;
/* ---- LNURL ---- */
/**
* The advertised withdraw ceiling, millisatoshi. Zero is the disabling value, and it
* is checked as `=== 0` rather than as falsy: `null` means nothing has probed, which
* is not the same claim at all.
*/
max_withdrawable_msat?: number | null;
/** false when the last probe found the mint's Lightning node unreachable. */
funding_available?: boolean | null;
/** Set when the host answered with something that is not a mint advertisement. */
invalid_reason?: string | null;
}
export interface MintWarningOptions {
@@ -183,9 +223,43 @@ 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',
'lnurlWithdrawalsDisabled.lead': 'Withdrawals disabled.',
'lnurlWithdrawalsDisabled.body.online':
'This mint advertises a maximum withdrawal of zero, so no note it issues can be redeemed for anything. Do not mint here until that changes.',
'lnurlWithdrawalsDisabled.body.offline':
'This mint advertised a maximum withdrawal of zero when it was last reached, so no note it issued could be redeemed for anything. Do not mint here until that changes.',
'lnurlWithdrawalsDisabled.meta.online': 'Withdrawals currently disabled',
'lnurlWithdrawalsDisabled.meta.offline': 'Withdrawals were disabled when last seen',
'lnurlInvalid.lead': 'Endpoint responding but invalid.',
'lnurlInvalid.body':
'The host answers, but not with a mint advertisement this site can read, so withdrawals may not work. Anything below is the last state that did parse, and reviews still work.',
'lnurlInvalid.meta': 'Responding, but not with a valid mint advertisement',
'lnurlNoFunding.lead': 'Minting and melting unavailable.',
'lnurlNoFunding.body':
'Existing notes can still be rotated, split, or merged, but nothing moves in or out via Lightning right now. The mint itself is up and answering; the Lightning node behind it is not reachable from it.',
'lnurlNoFunding.meta': 'Minting and melting unavailable',
'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}).',
'also.noFunding': 'Its Lightning node was also unreachable, so nothing could be minted or melted.',
};
/**
@@ -212,8 +286,35 @@ 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',
'gone',
'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled',
'offline-long',
'melt-only', 'lnurl-invalid', 'lnurl-no-funding',
'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',
];
/*
* The LNURL kinds *are* interleaved, unlike the Fedimint ones, and they have to be:
* that branch reuses `gone`, `offline-long` and `offline`, so its warnings genuinely
* share a list with those ranks and appending would put a critical
* "withdrawals disabled" below a mild "offline since yesterday". The relative order of
* everything that existed before is unchanged, which is what `check-warnings.ts`
* asserts.
*/
/**
* 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 +333,89 @@ const defaultMonth = (unix: number): string =>
export function getMintWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
if (mint.type === 'fedimint') return fedimintWarnings(mint, options);
if (mint.type === 'lnurl') return lnurlWarnings(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;
@@ -241,13 +425,7 @@ export function getMintWarnings(
const caps = mint.capabilities ?? readCapabilities(mint.info?.nuts);
const { mintDisabled, meltDisabled } = caps;
const offline = mint.status === 'offline';
const since = mint.last_online;
const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null;
// Offline with no last_online means it has never once answered, which is at least
// as bad as a month of silence, so it lands in the top tier rather than the bottom.
const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1;
const { offline, since, days, tier } = offlineTier(mint, now);
/*
* Cached flags describe the last configuration seen, not the current one, and the
@@ -336,6 +514,183 @@ export function getMintWarnings(
return out;
}
/**
* How long a listing has been unreachable, in the four bands the banners key on.
*
* Shared by the Cashu and LNURL branches, which apply exactly the same thresholds —
* being unreachable means the same thing whether the endpoint that stopped answering
* was `/v1/info` or a mint advertisement, and two copies of "is 7 days long?" is two
* places for it to become 8 in one of them.
*
* Tier 3 covers both a month of silence and never having answered at all: offline with
* no `last_online` means it has never once answered, which is at least as bad as a
* month of it, so it lands in the top tier rather than the bottom.
*/
function offlineTier(
mint: Pick<MintWarningInput, 'status' | 'last_online'>,
now: number,
): { offline: boolean; since: number | null; days: number | null; tier: 0 | 1 | 2 | 3 } {
const offline = mint.status === 'offline';
const since = mint.last_online;
const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null;
const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1;
return { offline, since, days, tier };
}
/**
* What can be said about an LNURL mint.
*
* Between the two extremes of the other ecosystems. A federation publishes no switches
* at all, so its page can only talk about reachability; a Cashu mint publishes NUT-04
* and NUT-05 flags, so its page can be specific about which direction is broken. An
* LNURL mint sits in between: two things about it are genuinely checkable over HTTP,
* and both get a banner.
*
* - `maxWithdrawable` of zero. A mint advertising that no note can be redeemed for
* anything is the closest analogue this ecosystem has to "withdrawals disabled",
* and it is read from the advertisement rather than inferred, so it ranks with the
* Cashu capability banners.
* - A funding source the mint cannot reach. Distinctive to lnurlcash and worth its
* own sentence, because it is *partial*: the mint is up, its notes still rotate,
* split and merge, and only the two operations that need a Lightning node are
* unavailable. Calling that "offline" would be wrong in both directions.
*
* The third, `lnurl-invalid`, is about this site's own reading rather than the mint's
* configuration: these endpoints answer their errors with HTTP 200, so a host that
* responds with something unparseable is a state that has to be named rather than
* silently counted as either up or down.
*
* Note what is deliberately missing: nothing here reads `features`. That tag is the
* operator's claim about what they built, and a banner derived from a claim rather than
* from an observation would be the same invention the Fedimint branch refuses to make.
*/
function lnurlWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;
const month = options.formatMonth ?? defaultMonth;
const s = options.strings ?? englishStrings;
const { offline, since, days, tier } = offlineTier(mint, now);
const tense = offline ? 'offline' : 'online';
const dayCount = s('dayCount', { n: days ?? 0 });
// `=== 0`, never falsy: null means nothing has probed this mint yet, and "we have not
// looked" must not render as "withdrawals are disabled".
const withdrawalsDisabled = mint.max_withdrawable_msat === 0;
const invalid = Boolean(mint.invalid_reason);
const noFunding = mint.funding_available === false;
const out: MintWarning[] = [];
// Pushed in ORDER, so the first one added is the one that wins.
if (tier === 3) {
out.push({
kind: 'gone',
severity: 'critical',
lead: s('gone.lead'),
body:
since !== null
? s('gone.body.since', { date: date(since), days: dayCount })
: mint.first_seen
? s('gone.body.neverDated', { month: month(mint.first_seen) })
: s('gone.body.never'),
meta: since !== null ? s('gone.meta.since', { date: date(since) }) : s('gone.meta.never'),
});
}
if (withdrawalsDisabled) {
out.push({
kind: 'lnurl-withdrawals-disabled',
severity: 'critical',
lead: s('lnurlWithdrawalsDisabled.lead'),
body: s(`lnurlWithdrawalsDisabled.body.${tense}`),
meta: s(`lnurlWithdrawalsDisabled.meta.${tense}`),
});
}
if (tier === 2 && since !== null) {
out.push({
kind: 'offline-long',
severity: 'critical',
lead: s('offlineLong.lead', { days: dayCount, date: date(since) }),
body: s('offlineLong.body'),
meta: s('offlineLong.meta', { date: date(since) }),
});
}
if (invalid) {
out.push({
kind: 'lnurl-invalid',
severity: 'warning',
lead: s('lnurlInvalid.lead'),
body: s('lnurlInvalid.body'),
meta: s('lnurlInvalid.meta'),
});
}
if (noFunding) {
out.push({
kind: 'lnurl-no-funding',
severity: 'warning',
lead: s('lnurlNoFunding.lead'),
body: s('lnurlNoFunding.body'),
meta: s('lnurlNoFunding.meta'),
});
}
if (tier === 1 && since !== null) {
out.push({
kind: 'offline',
severity: 'warning',
lead: s('offline.lead', { date: date(since) }),
body: s('offline.body'),
meta: s('offline.meta', { date: date(since) }),
});
}
const primary = out[0];
if (primary) {
const extra = lnurlCollapsed(primary.kind, { noFunding, offline, since, dayCount, date, s });
if (extra) primary.body += ` ${extra}`;
}
return out;
}
/**
* The one sentence a losing LNURL condition earns inside the winner's text.
*
* Same contract as `collapsed`, and separate from it because the conditions being
* folded in are different ones: there is no NUT-04 or NUT-05 here, and the fact worth
* rescuing from an offline banner is that the mint's Lightning node was down too.
*/
function lnurlCollapsed(
kind: MintWarningKind,
ctx: {
noFunding: boolean;
offline: boolean;
since: number | null;
dayCount: string;
date: (unix: number) => string;
s: WarningStrings;
},
): string | null {
if (kind === 'gone' || kind === 'offline-long' || kind === 'offline') {
return ctx.noFunding ? ctx.s('also.noFunding') : null;
}
// A capability banner outranked an offline one: say the mint is also unreachable,
// otherwise the page reads as if it were up and merely misconfigured.
if (kind === 'lnurl-withdrawals-disabled' && ctx.offline && ctx.since !== null) {
return ctx.s('also.offline', { date: ctx.date(ctx.since), days: ctx.dayCount });
}
return null;
}
/**
* The one sentence a losing condition earns inside the winner's text. Banners never
* stack, but a mint that is both gone and had stopped paying out is a worse story
@@ -401,6 +756,8 @@ export const CHIP_COPY_EN: Record<string, string> = {
frozen: 'Frozen',
noWithdrawals: 'No withdrawals',
meltOnly: 'Melt only',
/** LNURL: up and serving, but nothing moves in or out over Lightning. */
noFunding: 'No mint / melt',
};
export function mintChip(warnings: MintWarning[], strings?: WarningStrings): MintChip | null {
@@ -409,7 +766,13 @@ export function mintChip(warnings: MintWarning[], strings?: WarningStrings): Min
if (kinds.has('frozen')) return { label: s('frozen'), severity: 'critical' };
// "Melt only" is the other direction, so it cannot double as the label here.
if (kinds.has('melt-disabled')) return { label: s('noWithdrawals'), severity: 'critical' };
// An LNURL mint advertising a zero withdraw ceiling is the same statement to a
// reader as a Cashu mint with melting off, so it earns the same two words.
if (kinds.has('lnurl-withdrawals-disabled')) {
return { label: s('noWithdrawals'), severity: 'critical' };
}
if (kinds.has('melt-only')) return { label: s('meltOnly'), severity: 'warning' };
if (kinds.has('lnurl-no-funding')) return { label: s('noFunding'), severity: 'warning' };
return null;
}
+8
View File
@@ -28,6 +28,12 @@ const apiUrl = process.env.API_URL ?? 'http://127.0.0.1:8787';
// https://api.cashumints.space.
const publicApiUrl = process.env.PUBLIC_API_URL ?? '';
// Plausible-compatible analytics endpoint and site identifier. Empty values disable the
// script, which is useful for local builds that should not report page views.
const plausibleUrl =
process.env.PLAUSIBLE_URL ?? 'https://analytics.azzamo.net/js/script.js';
const plausibleDomain = process.env.PLAUSIBLE_DOMAIN ?? 'cashumints.space';
// Dev server and preview port. Set WEB_PORT in .env; a --port flag still overrides it.
const port = Number.parseInt(process.env.WEB_PORT ?? '', 10) || 4321;
@@ -73,6 +79,8 @@ export default defineConfig({
vite: {
define: {
'import.meta.env.PUBLIC_API_URL': JSON.stringify(publicApiUrl),
'import.meta.env.PLAUSIBLE_URL': JSON.stringify(plausibleUrl),
'import.meta.env.PLAUSIBLE_DOMAIN': JSON.stringify(plausibleDomain),
},
server: { proxy },
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 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

+7 -1
View File
@@ -5,12 +5,16 @@
"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",
"start": "node server.mjs",
"preview": "astro preview",
"typecheck": "astro check",
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
"check:links": "node scripts/check-links.mjs dist",
"check:i18n": "node scripts/check-i18n.mjs",
"test": "node --test test/*.test.mjs",
"check:hreflang": "node scripts/check-hreflang.mjs dist"
},
"dependencies": {
@@ -21,7 +25,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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 B

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+21 -1
View File
@@ -238,6 +238,12 @@ 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
'reviews.dialog.lede.', // the dialog's invitation, one per ecosystem
'reviews.rate.', // the reviews panel's inline "Rate this mint" row
];
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
@@ -262,6 +268,20 @@ for (const file of files) {
// A .ts file under lib/ or scripts/ is island code wholesale.
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
/*
* A page can inline one extra namespace for its own islands, through Base.astro's
* `clientNamespaces` prop. The home page does: its grids hydrate from the API and
* rewrite their own "All 56 mints" links, whose strings live under `home.` — a
* namespace not worth inlining on 1,300 mint pages that never read it.
*
* Read out of the page rather than listed here, so the prop and this check cannot
* disagree. A namespace a page does not actually pass is still a leak.
*/
const extraNamespaces = new Set(
[...(/clientNamespaces=\{\[([^\]]*)\]\}/.exec(source)?.[1] ?? '').matchAll(/'([\w-]+)'/g)]
.map((m) => m[1]),
);
for (const match of source.matchAll(T_CALL)) {
const key = match[2];
used.add(key);
@@ -274,7 +294,7 @@ for (const file of files) {
for (const match of clientSource.matchAll(T_CALL)) {
const key = match[2];
const namespace = key.split('.')[0];
if (!clientNamespaces.has(namespace)) {
if (!clientNamespaces.has(namespace) && !extraNamespaces.has(namespace)) {
clientLeaks.push({ key, file: relative, namespace });
}
}
+57 -42
View File
@@ -6,6 +6,7 @@
* files change roughly never.
*
* node scripts/make-assets.mjs
* node scripts/make-assets.mjs --no-card # icon set only, needs no browser
*
* Playwright and sharp are not dependencies of this workspace. Both arrive transitively
* under `astro` (which ships sharp for its image pipeline) and are resolved through it,
@@ -14,10 +15,12 @@
*
* The card is drawn in a real browser rather than assembled by hand because it uses the
* site's own fonts and tokens: a social card that does not look like the site is worth
* less than no card. The moai is the same rect geometry as `components/Moai.astro`.
* less than no card. The moai on the card is the same rect geometry as
* `components/Moai.astro`. The icon set is a different mark: the pixel cashu nut carried
* over from the old site (`src/assets/cashu-logo.png`), composed with sharp alone.
*/
import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -100,14 +103,8 @@ p { margin-top: 28px; font-size: 27px; line-height: 1.45; color: #9A927E; max-wi
<div class="host">cashumints.space</div>
</body></html>`;
/* The icon master: the moai on the site's background, generous padding. */
const iconHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin: 0; padding: 0; }
body {
width: 512px; height: 512px; background: #0E0D0B;
display: flex; align-items: center; justify-content: center;
}
</style></head><body>${moaiSvg(320)}</body></html>`;
/* The icon source: the old site's pixel cashu nut, 1000x1000 with transparent margins. */
const logoPath = path.join(root, 'src', 'assets', 'cashu-logo.png');
/**
* A 32x32 ICO wrapping a PNG payload.
@@ -138,8 +135,54 @@ function icoFromPng(png) {
await mkdir(publicDir, { recursive: true });
const browser = await chromium.launch();
try {
/*
* The icon master: the nut trimmed and centred on the brand ground, at the footprint the
* moai used to occupy (glyph ≈ 380px tall in the 512 tile). Content this size also stays
* inside the circle a maskable icon is cropped to.
*/
const glyph = await sharp(logoPath).trim().resize(384, 384, { fit: 'inside' }).png().toBuffer();
const master = await sharp({
create: { width: 512, height: 512, channels: 4, background: '#0E0D0B' },
})
.composite([{ input: glyph, gravity: 'centre' }])
.png()
.toBuffer();
for (const [name, size] of [
['icon-512.png', 512],
['icon-192.png', 192],
['favicon-32.png', 32],
]) {
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
console.log(name);
}
// iOS ignores transparency and composites on white; the master's ground is already opaque.
await sharp(master)
.resize(180, 180)
.png({ compressionLevel: 9 })
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
console.log('apple-touch-icon.png');
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
console.log('favicon.ico');
// The standalone SVG favicon: the nut itself, transparent like the old site served it.
// The PNG rides inside the SVG so one source file covers every size; `pixelated` keeps
// the pixel art crisp if anything scales it up.
const logoB64 = (await readFile(logoPath)).toString('base64');
await writeFile(
path.join(publicDir, 'favicon.svg'),
`<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000">` +
`<image width="1000" height="1000" style="image-rendering:pixelated" ` +
`href="data:image/png;base64,${logoB64}"/></svg>`,
);
console.log('favicon.svg');
if (!process.argv.includes('--no-card')) {
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
@@ -149,35 +192,7 @@ try {
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: path.join(publicDir, 'og.png') });
console.log('og.png');
await page.setViewportSize({ width: 512, height: 512 });
await page.setContent(iconHtml, { waitUntil: 'load' });
const master = await page.screenshot({ type: 'png' });
for (const [name, size] of [
['icon-512.png', 512],
['icon-192.png', 192],
['favicon-32.png', 32],
]) {
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
console.log(name);
}
// iOS ignores transparency and composites on white, so flatten onto the brand ground.
await sharp(master)
.resize(180, 180)
.flatten({ background: '#0E0D0B' })
.png({ compressionLevel: 9 })
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
console.log('apple-touch-icon.png');
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
console.log('favicon.ico');
// The standalone SVG favicon: the same mark, at any size, for browsers that take it.
await writeFile(path.join(publicDir, 'favicon.svg'), moaiSvg(11));
console.log('favicon.svg');
} finally {
} finally {
await browser.close();
}
}
+223
View File
@@ -0,0 +1,223 @@
/**
* 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, lnurlModel } 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 })
: raw.type === 'lnurl'
? lnurlModel(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, lnurl, stats] = await Promise.all([
getJson('/api/mints?type=cashu'),
getJson('/api/mints?type=fedimint'),
getJson('/api/mints?type=lnurl'),
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' })),
...lnurl.map((m) => ({ listing: m, kind: 'lnurl' })),
];
// 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 })
: kind === 'lnurl'
? lnurlModel(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();
}
+712
View File
@@ -0,0 +1,712 @@
/**
* 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';
/** The LNURL ecosystem label. The only cool blue here — see DESIGN.md. */
const LNURL = '#4EA8DE';
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 non-Cashu card unmistakable at thumbnail size.
*
* Three things separate the three ecosystems at 300px wide, and the badge is only one
* of them: the word itself, the badge's colour, and the glow behind the whole card all
* change together. Cashu gets no badge at all, which is its own signal — it is the
* default and the majority of the index.
*/
function typeBadge(label, color, borderColor) {
return h(
'div',
{
display: 'flex',
padding: '9px 14px',
borderRadius: '9px',
border: `1.5px solid ${borderColor}`,
color,
fontFamily: MONO,
fontWeight: 500,
fontSize: '18px',
letterSpacing: '3px',
},
label,
);
}
/** 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,
),
),
);
}
/**
* Third cell, LNURL: the withdraw range — never NUT limits, never a module list.
*
* This is the number that answers an LNURL reader's actual question: how much can one
* of this mint's notes be worth? It is shown in sats, converted from millisatoshi in
* the model, and it is a *range* rather than a pair of independent bounds, which is
* why the two rows read "min"/"max" the way the Cashu limits cell does.
*
* The lightning address takes the space the Fedimint cell gives its network badge: it
* is the one string on the whole card someone might copy out of a screenshot.
*/
function thirdCellLnurl(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: '34px', 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.withdraw) {
if (model.withdraw.min !== null) lines.push(['min', `${compact(model.withdraw.min)} sat`]);
if (model.withdraw.max !== null) lines.push(['max', `${compact(model.withdraw.max)} sat`]);
}
return h(
'div',
base,
cellLabel('WITHDRAW'),
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),
),
),
),
model.address &&
h(
'div',
{ fontFamily: MONO, fontWeight: 400, fontSize: '18px', color: FAINT, marginTop: '16px', maxWidth: '300px' },
fitMono(model.address, 30),
),
);
}
/** The background glow: amber healthy, red for trouble, violet Fedimint, blue LNURL. */
function glow(model) {
const danger =
model.status === 'offline' ||
(model.type !== 'fedimint' && model.warning && model.warning.severity === 'critical');
const color = danger
? 'rgba(224,100,80,.13)'
: model.type === 'fedimint'
? 'rgba(167,139,250,.11)'
: model.type === 'lnurl'
// Deliberately the strongest of the three. Amber and violet only have to
// separate a card from the site's own background; this one has to separate an
// LNURL card from a Fedimint card at 300px, where the badge text is a smudge
// and the tint is the first thing a reader actually resolves.
? 'rgba(78,168,222,.20)'
: '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 isLnurl = model.type === 'lnurl';
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
// Room beside the icon: canvas minus padding, icon, gaps and the chips. A badge is
// ~180px for FEDIMINT and ~120px for the shorter LNURL.
const chipRoom = 240 + (isFed ? 180 : isLnurl ? 120 : 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' : isLnurl ? 'LNURL mint 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 && typeBadge('FEDIMINT', VIOLET, 'rgba(167,139,250,.5)'),
isLnurl && typeBadge('LNURL', LNURL, 'rgba(78,168,222,.5)'),
),
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) : isLnurl ? thirdCellLnurl(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, Fedimint and LNURL mint reviews, signed on Nostr',
),
h(
'div',
// Four stats where there were three, so the gap tightens rather than the row
// running past the card's own margins.
{ display: 'flex', gap: '64px', marginTop: '64px' },
stat(model.mints, 'Cashu mints'),
stat(model.federations, 'federations'),
stat(model.lnurl, 'LNURL mints'),
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,
});
}
+175
View File
@@ -0,0 +1,175 @@
/**
* The edge cases the template must survive, as synthetic API payloads.
*
* Run through the same cashuModel/fedimintModel/lnurlModel 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 },
},
},
};
/**
* The shared shape of an LNURL row's detail payload, as `GET /api/mints/:host` returns
* it: the ecosystem fields flattened across the top level, millisatoshi on the wire.
*/
const lnurlBase = {
type: 'lnurl',
status: 'online',
last_online: NOW - 300,
last_probe: NOW - 300,
first_seen: NOW - 200 * 86400,
review_count: 9,
rating_avg: 4.4,
rating_distribution: dist(6, 2, 1),
last_review_at: NOW - 20 * 86400,
features: ['mint', 'melt', 'rotate', 'split', 'merge', 'lud06', 'lud03', 'lud16', 'signed-notes'],
network: 'mainnet',
mint_pubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
funding_available: true,
invalid_reason: null,
min_withdrawable_msat: 5_000,
max_withdrawable_msat: 999_899_000,
lightning_address: 'mint@lnurl.example',
};
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,
},
{
// The healthy LNURL card: blue glow, LNURL badge, a withdraw range in the third
// cell, and the lightning address under it.
...lnurlBase,
host: 'fixture-lnurl-online',
url: 'lnurl:https://lnurl.example',
base_url: 'https://lnurl.example',
name: null,
icon: null,
},
{
// Offline 20 days: red glow beats the blue one, warning cell replaces the range.
// The blue badge stays, because which ecosystem it is has not changed.
...lnurlBase,
host: 'fixture-lnurl-offline',
url: 'lnurl:https://lnurl.gone.example',
base_url: 'https://lnurl.gone.example',
name: 'Departed Notes',
icon: null,
status: 'offline',
last_online: NOW - 20 * 86400,
rating_avg: 2.1,
rating_distribution: dist(0, 1, 1, 3, 5),
review_count: 10,
},
{
// A withdraw ceiling of zero, while ONLINE: green status chip stays, and the
// critical warning cell appears anyway. The one card that must never print
// "max 0 sat" as though it were a limit.
...lnurlBase,
host: 'fixture-lnurl-no-withdrawals',
url: 'lnurl:https://lnurl.locked.example',
base_url: 'https://lnurl.locked.example',
name: 'Locked Notes Mint',
icon: null,
min_withdrawable_msat: 0,
max_withdrawable_msat: 0,
},
];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+313
View File
@@ -0,0 +1,313 @@
/**
* 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, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
/**
* Bump when the template's layout or copy changes, to regenerate every image.
*
* 2: the LNURL variant. Its badge, glow and third cell are new, and the shared
* `warningCell` learned three LNURL warning kinds, so every card's hash input moved.
*/
export const TEMPLATE_VERSION = 2;
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',
// LNURL. `lnurl-withdrawals-disabled` gets the same two words as the Cashu
// melt-disabled cell, because it is the same statement to a reader.
'lnurl-withdrawals-disabled': 'No withdrawals',
'lnurl-no-funding': 'No mint / melt',
'lnurl-invalid': 'Not responding properly',
}[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;
case 'lnurl-withdrawals-disabled':
sub = 'Notes cannot be redeemed';
break;
case 'lnurl-no-funding':
// The precise half of the story, and the half that stops a reader writing the
// mint off: its notes still work, only Lightning is shut.
sub = 'Notes still rotate and split';
break;
case 'lnurl-invalid':
sub = 'Endpoint answered with junk';
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', 'lnurl-withdrawals-disabled'].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),
};
}
/**
* One LNURL mint's card model.
*
* The same skeleton as the other two, with the third cell answering this ecosystem's
* own question: how much can one of this mint's bearer notes be worth? That is the
* withdraw range, in sats, converted here once from the millisatoshi the wire carries.
*
* `withdraw` is null when the ceiling is zero, and deliberately: a card printing
* "max 0 sat" reads as a number rather than as a refusal, and the warning cell that
* outranks it says the true thing instead.
*/
export function lnurlModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
const domain = displayDomain(baseUrl);
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 min = msatToSat(mint.min_withdrawable_msat ?? null);
const max = msatToSat(mint.max_withdrawable_msat ?? null);
return {
v: TEMPLATE_VERSION,
type: 'lnurl',
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,
withdraw: max !== null && max > 0 ? { min, max } : null,
/** The LUD-16 address, which is the one string a reader might actually copy. */
address: mint.lightning_address ?? null,
warning: warningCell(warning, mint, now),
iconHash,
gradient: iconHash ? null : iconGradient(domain),
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,
lnurl: stats.lnurl_total,
reviews: stats.reviews_total,
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* A contact sheet of the three ecosystems' cards at thumbnail size.
*
* Not part of the build: run it by hand (`node scripts/og/thumbs.mjs`) to check the
* one thing a full-size render cannot show you — whether a reader scrolling a timeline
* can tell a Cashu card from a Fedimint one from an LNURL one before any text is
* legible. Writes og-fixtures/_thumbs.png.
*/
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
const here = path.dirname(fileURLToPath(import.meta.url));
const dir = path.resolve(here, '../../og-fixtures');
/** 300px wide: roughly how a link preview renders in a feed. */
const W = 300;
const H = Math.round((630 / 1200) * W);
const GAP = 16;
const LABEL = 22;
const cards = [
['Cashu', 'fixture-no-icon.png'],
['Fedimint', 'fixture-fed-announced.png'],
['LNURL', 'fixture-lnurl-online.png'],
];
const parts = await Promise.all(
cards.map(async ([label, file], i) => {
const data = await readFile(path.join(dir, file));
const x = i * (W + GAP);
return (
`<image x="${x}" y="${LABEL}" width="${W}" height="${H}" ` +
`href="data:image/png;base64,${data.toString('base64')}"/>` +
`<text x="${x}" y="15" font-family="monospace" font-size="13" fill="#EDE8DC">${label}</text>`
);
}),
);
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${cards.length * (W + GAP) - GAP}" ` +
`height="${H + LABEL}"><rect width="100%" height="100%" fill="#0E0D0B"/>${parts.join('')}</svg>`;
await writeFile(path.join(dir, '_thumbs.png'), new Resvg(svg).render().asPng());
console.log(`og: og-fixtures/_thumbs.png (${cards.length} cards at ${W}px)`);
+386
View File
@@ -0,0 +1,386 @@
/**
* The production web server.
*
* The site is `output: 'static'`: `pnpm build` prerenders every page and this process
* only hands the result out over HTTP. nginx sits in front of it and proxies, rather
* than pointing a `root` at the built tree, so that nothing outside this file decides
* what is readable. That is not a stylistic preference — it removes two whole classes
* of failure the file-serving arrangement kept producing:
*
* traversal permissions nginx runs as www-data and the build runs as cashumints,
* so every directory from / down to dist had to be traversable
* by a user with no other business in it. One 0700 home
* directory anywhere in the chain took the site down, and
* `try_files` reports a permission error as a plain miss, so
* the symptom was a blanket 404 or an internal-redirect loop
* ending in 500 — never the actual cause.
*
* duplicated routing the locale 404 rule lived in nginx as a hand-maintained
* alternation of 23 codes. Adding a language meant editing a
* file that is not in this repository, and forgetting to was
* silent. Locale 404s are resolved by looking in the built
* tree now, so the list cannot drift.
*
* Reading files is all it does. There is no template, no database handle and no route
* table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards
* them there directly.
*
* Env:
* SITE_PORT 8789 port to listen on
* SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS
* WEB_ROOT ./dist the tree to serve
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function int(raw, fallback) {
const n = Number.parseInt(raw ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/**
* The tree to serve, absolute.
*
* Defaults to the build output next to this file, which is what `pnpm start` and the
* tests use. In production it points at a copy outside the checkout: `pnpm build`
* empties dist before it writes, so serving dist directly means a rebuild takes the
* whole site down for the length of the build. See cashumints-web.service.
*/
export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist'));
const PORT = int(process.env.SITE_PORT, 8789);
const HOST = process.env.SITE_HOST ?? '127.0.0.1';
/** One line per event, key=value after the message. Matches the API's log format. */
function log(level, msg, fields = {}) {
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
}
const line = parts.join(' ');
if (level === 'error') console.error(line);
else console.log(line);
}
const TYPES = new Map(Object.entries({
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.wasm': 'application/wasm',
}));
const IMMUTABLE = 'public, max-age=31536000, immutable';
const WEEK = 'public, max-age=604800';
const HOUR = 'public, max-age=3600';
/** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */
const REVALIDATE = 'public, max-age=0, must-revalidate';
/**
* How long a response may be reused.
*
* Everything Vite and the card builder emit carries a content hash in its filename, so
* those are immutable for a year — a changed file is a changed URL. Markup is the
* opposite: the URLs are permanent and the bytes change on every rebuild, so it
* revalidates every time and the ETag below turns that into a 304 in the usual case.
*
* `/og/default.png` is the one unhashed card, served for pages that have no mint of
* their own, so it gets an hour rather than a year.
*/
export function cacheControl(urlPath, ext) {
if (ext === '.html') return REVALIDATE;
if (urlPath === '/og/default.png') return HOUR;
if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE;
if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR;
return WEEK;
}
/**
* Turn a request path into a path inside ROOT, or null if it escapes or is malformed.
*
* Returns the *relative* path so the caller can join it against ROOT; every segment is
* checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are
* refused outright: a static build emits none, so a request for one is either a probe
* or a mistake, and neither should be answered with bytes.
*/
export function safePath(urlPath) {
if (urlPath.includes('\0')) return null;
const normalized = path.posix.normalize(urlPath);
if (!normalized.startsWith('/')) return null;
const segments = normalized.split('/').filter(Boolean);
for (const segment of segments) {
if (segment === '..' || segment.startsWith('.')) return null;
}
return segments;
}
async function statFile(file) {
try {
const stats = await fsp.stat(file);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
/**
* The candidates for one request path, in order.
*
* Astro emits directory-style routes — /mints is dist/mints/index.html — and
* `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the
* literal path first keeps assets a single stat; the `.html` candidate covers the flat
* files at the root, /404.html among them.
*/
export function candidates(segments) {
const rel = segments.join('/');
if (rel === '') return ['index.html'];
return [rel, `${rel}/index.html`, `${rel}.html`];
}
/**
* The 404 body for a path, and it is not always the English one.
*
* A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into
* English, which is the same reason `redirectToDefaultLocale` is false in the Astro
* config. Which prefixes count is decided by what the build actually emitted: if
* <prefix>/404/index.html exists, the prefix is a locale. Nothing to keep in sync.
*/
async function notFoundBody(segments) {
const first = segments[0];
if (first) {
const localized = path.join(ROOT, first, '404', 'index.html');
const stats = await statFile(localized);
if (stats) return { file: localized, stats };
}
const fallback = path.join(ROOT, '404.html');
const stats = await statFile(fallback);
return stats ? { file: fallback, stats } : null;
}
/** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */
function etagFor(stats) {
return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`;
}
/** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */
function etagMatches(header, etag) {
if (!header) return false;
if (header.trim() === '*') return true;
const bare = etag.replace(/^W\//, '');
return header
.split(',')
.map((candidate) => candidate.trim().replace(/^W\//, ''))
.includes(bare);
}
function notModified(req, etag, lastModified) {
if (etagMatches(req.headers['if-none-match'], etag)) return true;
// Only consulted when the client sent no ETag, per RFC 9110 §13.1.3.
if (req.headers['if-none-match']) return false;
const since = Date.parse(req.headers['if-modified-since'] ?? '');
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
}
/**
* Headers every response carries.
*
* No CSP here on purpose. The site loads an analytics script from another origin, the
* review islands open websockets to whatever relays are configured and the status
* islands fetch whatever mint URLs the index holds, so a policy tight enough to be
* worth having has to be derived from those lists rather than guessed at — and a wrong
* one fails as a silently broken island. HSTS belongs to nginx, which is what actually
* terminates TLS.
*/
function baseHeaders() {
return {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
};
}
function send(res, status, headers, body) {
res.writeHead(status, { ...baseHeaders(), ...headers });
res.end(body);
}
/** Stream a file, or just its headers for HEAD. */
function sendFile(req, res, status, file, stats, urlPath) {
const ext = path.extname(file).toLowerCase();
const etag = etagFor(stats);
const headers = {
'Content-Type': TYPES.get(ext) ?? 'application/octet-stream',
'Content-Length': stats.size,
'Last-Modified': new Date(stats.mtimeMs).toUTCString(),
ETag: etag,
'Cache-Control': cacheControl(urlPath, ext),
...baseHeaders(),
};
// A 304 must not carry a body or a Content-Length describing one.
if (status === 200 && notModified(req, etag, stats.mtimeMs)) {
delete headers['Content-Length'];
delete headers['Content-Type'];
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(status, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = fs.createReadStream(file);
stream.on('error', (err) => {
log('error', 'read failed', { file, err: err.message });
res.destroy();
});
// Kill the read when the client hangs up mid-transfer rather than draining the file.
res.on('close', () => stream.destroy());
stream.pipe(res);
}
async function handle(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n');
return;
}
let urlPath;
try {
urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
const segments = safePath(urlPath);
if (!segments) {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
for (const candidate of candidates(segments)) {
const file = path.join(ROOT, candidate);
// Belt and braces: safePath already refused `..`, this refuses anything that still
// resolved outside the tree, a symlink in the build output included.
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break;
const stats = await statFile(file);
if (stats) {
sendFile(req, res, 200, file, stats, urlPath);
return;
}
}
const miss = await notFoundBody(segments);
if (!miss) {
send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n');
return;
}
// Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd
// URL indexed as a real page.
sendFile(req, res, 404, miss.file, miss.stats, urlPath);
}
export function createServer() {
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
log('error', 'request failed', { path: req.url, err: err.message });
if (!res.headersSent) {
send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n');
} else {
res.destroy();
}
});
});
/*
* Longer than nginx's upstream keepalive, and headersTimeout longer still.
*
* If this end closes an idle connection at the same moment nginx reuses it, nginx has
* nothing to retry and reports 502. Outlasting the proxy makes the proxy always the
* one to close, which is the race-free direction.
*/
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
// A malformed request line should not take the process with it.
server.on('clientError', (err, socket) => {
if (err.code === 'ECONNRESET' || !socket.writable) return;
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
return server;
}
/**
* Refuse to start on an empty or unreadable root.
*
* The failure this prevents is the one that is hardest to see: a process that starts
* cleanly, answers every request with a 404 and looks healthy to anything watching the
* port. Exiting non-zero puts the reason in `systemctl status` instead.
*/
async function checkRoot() {
const index = path.join(ROOT, 'index.html');
if (await statFile(index)) return;
log('error', 'web root has no index.html', {
root: ROOT,
hint: 'a manual `pnpm build` only writes web/dist; `systemctl start cashumints-web` builds and publishes to WEB_ROOT',
});
process.exit(1);
}
/** Only when run directly, so the tests can import the pieces above. */
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await checkRoot();
const server = createServer();
server.listen(PORT, HOST, () => {
log('info', 'site listening', { host: HOST, port: PORT, root: ROOT });
});
let shuttingDown = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (shuttingDown) return;
shuttingDown = true;
log('info', 'shutting down', { signal });
server.close(() => process.exit(0));
// Idle keep-alive connections would otherwise hold the close open for a minute.
server.closeIdleConnections();
setTimeout(() => {
server.closeAllConnections();
process.exit(0);
}, 10_000).unref();
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+36 -15
View File
@@ -4,7 +4,7 @@
"name": "mint-page",
"viewportWidth": 324,
"width": 324,
"height": 327,
"height": 407,
"bones": [
[
0,
@@ -16,14 +16,14 @@
[
20.9877,
0,
59.2593,
54.4078,
43,
8
],
[
20.9877,
43,
59.2593,
54.4078,
22,
8
],
@@ -102,8 +102,15 @@
0,
265,
100,
63,
84,
8
],
[
0,
366,
42.284,
41,
10
]
]
},
@@ -111,7 +118,7 @@
"name": "mint-page",
"viewportWidth": 712,
"width": 712,
"height": 193,
"height": 252,
"bones": [
[
0,
@@ -123,19 +130,19 @@
[
9.5506,
0,
26.9663,
24.7586,
43,
8
],
[
9.5506,
43,
26.9663,
24.7586,
22,
8
],
[
38.764,
36.5564,
18,
10.8146,
29,
@@ -143,7 +150,7 @@
true
],
[
40.4494,
38.2417,
29,
0.9831,
7,
@@ -211,6 +218,13 @@
100,
42,
8
],
[
0,
211,
19.2416,
41,
10
]
]
},
@@ -218,7 +232,7 @@
"name": "mint-page",
"viewportWidth": 1184,
"width": 1184,
"height": 172,
"height": 231,
"bones": [
[
0,
@@ -230,19 +244,19 @@
[
5.7432,
0,
16.2162,
14.8886,
43,
8
],
[
5.7432,
43,
16.2162,
14.8886,
22,
8
],
[
23.3108,
21.9832,
18,
6.5034,
29,
@@ -250,7 +264,7 @@
true
],
[
24.3243,
22.9967,
29,
0.5912,
7,
@@ -318,9 +332,16 @@
100,
21,
8
],
[
0,
190,
11.5709,
41,
10
]
]
}
},
"_hash": "f3f7f7e86a10ecca631b74af17fb0335"
"_hash": "e76f6212eab4b1517002712803fae94e"
}
+235 -102
View File
@@ -4,132 +4,181 @@
"name": "reviews-panel",
"viewportWidth": 322,
"width": 322,
"height": 727,
"height": 876,
"bones": [
[
6.2112,
20,
18,
11.8012,
38,
"50%"
],
[
22.9814,
19,
17,
32.6087,
22,
8
],
[
22.9814,
41,
39,
70.8075,
16,
17,
5
],
[
22.9814,
70,
68,
20.429,
16,
8
],
[
46.5159,
68,
45.8948,
66,
0.9317,
20,
8
],
[
49.3109,
66,
11.8012,
19,
20,
8
],
[
22.9814,
98,
96,
70.8075,
116,
144,
8
],
[
22.9814,
246,
70.8075,
27,
8
],
[
6.2112,
255,
310,
11.8012,
38,
"50%"
],
[
22.9814,
254,
309,
23.6025,
22,
8
],
[
22.9814,
277,
331,
70.8075,
16,
17,
5
],
[
22.9814,
305,
360,
20.429,
16,
8
],
[
46.5159,
303,
45.8948,
358,
0.9317,
20,
8
],
[
49.3109,
358,
12.1118,
19,
20,
8
],
[
22.9814,
333,
388,
70.8075,
116,
120,
8
],
[
22.9814,
514,
70.8075,
27,
8
],
[
6.2112,
490,
578,
11.8012,
38,
"50%"
],
[
22.9814,
489,
577,
31.0559,
22,
8
],
[
22.9814,
512,
599,
70.8075,
16,
17,
5
],
[
22.9814,
540,
628,
20.429,
16,
8
],
[
46.5159,
538,
45.8948,
626,
0.9317,
20,
8
],
[
49.3109,
626,
12.7329,
19,
20,
8
],
[
22.9814,
568,
656,
70.8075,
139,
144,
8
],
[
22.9814,
806,
20.8075,
17,
8
],
[
22.9814,
831,
70.8075,
27,
8
]
]
@@ -138,132 +187,174 @@
"name": "reviews-panel",
"viewportWidth": 710,
"width": 710,
"height": 455,
"height": 533,
"bones": [
[
2.8169,
20,
18,
5.3521,
38,
"50%"
],
[
10.4225,
21,
17,
14.7887,
22,
8
],
[
10.4225,
43,
75.2421,
16,
39,
67.7773,
17,
5
],
[
87.9181,
22,
80.4533,
21,
9.265,
16,
8
],
[
91.831,
42,
5.3521,
90.5634,
19,
0.4225,
20,
8
],
[
91.831,
19,
5.3521,
20,
8
],
[
10.4225,
77,
68,
82.3944,
48,
8
],
[
10.4225,
124,
86.7606,
46,
27,
8
],
[
2.8169,
164,
188,
5.3521,
38,
"50%"
],
[
10.4225,
165,
187,
10.7042,
22,
8
],
[
10.4225,
187,
75.2421,
16,
209,
67.6364,
17,
5
],
[
87.9181,
166,
80.3125,
191,
9.265,
16,
8
],
[
90.4225,
189,
0.4225,
20,
8
],
[
91.6901,
186,
189,
5.493,
19,
20,
8
],
[
10.4225,
221,
238,
82.3944,
48,
8
],
[
10.4225,
294,
86.7606,
46,
27,
8
],
[
2.8169,
308,
358,
5.3521,
38,
"50%"
],
[
10.4225,
309,
357,
14.0845,
22,
8
],
[
10.4225,
331,
75.2421,
16,
379,
67.3548,
17,
5
],
[
87.9181,
310,
80.0308,
361,
9.265,
16,
8
],
[
90.1408,
359,
0.4225,
20,
8
],
[
91.4085,
330,
359,
5.7746,
19,
20,
8
],
[
10.4225,
365,
408,
82.3944,
72,
8
],
[
10.4225,
488,
86.7606,
70,
27,
8
]
]
@@ -272,136 +363,178 @@
"name": "reviews-panel",
"viewportWidth": 784,
"width": 784,
"height": 431,
"height": 533,
"bones": [
[
2.551,
20,
18,
4.8469,
38,
"50%"
],
[
9.4388,
21,
17,
13.3929,
22,
8
],
[
9.4388,
43,
77.5789,
16,
39,
70.8187,
17,
5
],
[
89.0585,
22,
82.2983,
21,
8.3905,
16,
8
],
[
92.602,
42,
4.8469,
91.4541,
19,
0.3827,
20,
8
],
[
92.602,
19,
4.8469,
20,
8
],
[
9.4388,
77,
68,
74.6173,
48,
8
],
[
9.4388,
124,
88.0102,
46,
27,
8
],
[
2.551,
164,
188,
4.8469,
38,
"50%"
],
[
9.4388,
165,
187,
9.6939,
22,
8
],
[
9.4388,
187,
77.5789,
16,
209,
70.6912,
17,
5
],
[
89.0585,
166,
82.1708,
191,
8.3905,
16,
8
],
[
91.3265,
189,
0.3827,
20,
8
],
[
92.4745,
186,
189,
4.9745,
19,
20,
8
],
[
9.4388,
221,
238,
74.6173,
48,
8
],
[
9.4388,
294,
88.0102,
46,
27,
8
],
[
2.551,
308,
358,
4.8469,
38,
"50%"
],
[
9.4388,
309,
357,
12.7551,
22,
8
],
[
9.4388,
331,
77.5789,
16,
379,
70.4361,
17,
5
],
[
89.0585,
310,
81.9157,
361,
8.3905,
16,
8
],
[
91.0714,
359,
0.3827,
20,
8
],
[
92.2194,
330,
359,
5.2296,
19,
20,
8
],
[
9.4388,
365,
408,
74.6173,
72,
8
],
[
9.4388,
488,
88.0102,
46,
27,
8
]
]
}
},
"_hash": "788093c50463a4ad673ece50c6db52da"
"_hash": "d9fb0ac370be8e706ff4dfea00b0081b"
}
+3 -1
View File
@@ -32,12 +32,14 @@ const t = useI18n(locale);
const { value, label, kind = t('mint.kind.identifier'), class: className } = Astro.props;
---
{/* The accessible name repeats the visible text: a voice-control user says what
they see, and a name that omits it cannot be spoken at. */}
<button
type="button"
class:list={['cid', className]}
data-copy-id={value}
title={value}
aria-label={t('common.copyFull', { kind })}
aria-label={`${label ?? value}: ${t('common.copyFull', { kind })}`}
>
<span class="cid-text">{label ?? value}</span>
</button>
+97 -38
View File
@@ -1,5 +1,23 @@
---
/**
* The site footer: one component, every page, three zones.
*
* Zone one is four columns — who this is, then the two link groups people actually
* navigate to, then the language control. Zone two is a thin bar with the copyright
* and the credit, and nothing else: the moment anything else moves into that row it
* stops being a signature and starts being a fourth navigation surface.
*
* What used to be here was four flat rows, the last of which was ~20 raw language
* links wrapped over two lines. That wall is now the same switcher the header carries
* (see LanguageSwitcher.astro, `placement="footer"`), so there is one language control
* on the site in two places rather than two controls that happen to agree.
*
* Everything below is static per locale and prerenders. No hex, no npub, no counts,
* nothing fetched: the footer is the one region of every page that must be identical
* on the home page and on a mint page that failed to load.
*/
import LanguageSwitcher from './LanguageSwitcher.astro';
import Moai from './Moai.astro';
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
@@ -9,22 +27,29 @@ const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
/**
* Two rows: the pages people navigate to, then the ones they go looking for. The
* disclaimer sits in the second row here and again at the bottom of every mint page,
* which is where the decision it is about actually gets made.
/*
* Route slugs stay English in every language and `localePath` adds the prefix, exactly
* as the header does. See src/i18n/routing.ts for why that trade was made.
*/
const nav = [
{ path: '/mints', label: t('footer.allMints') },
{ path: '/reviews', label: t('footer.allReviews') },
{ path: '/wallets', label: t('footer.wallets') },
{ path: '/about', label: t('footer.about') },
const explore = [
{ href: localePath('/mints', locale), label: t('footer.allMints') },
{ href: localePath('/fedimints', locale), label: t('footer.allFedimints') },
{ href: localePath('/lnurl-mints', locale), label: t('footer.allLnurlMints') },
{ href: localePath('/reviews', locale), label: t('footer.allReviews') },
{ href: localePath('/wallets', locale), label: t('footer.wallets') },
];
const legal = [
{ path: '/disclaimer', label: t('footer.disclaimer') },
{ path: '/terms', label: t('footer.terms') },
{ path: '/privacy', label: t('footer.privacy') },
/*
* GitHub sits in this column rather than beside the credit line because it is a place
* to go, not a signature. It is the only external link in the columns, so it carries
* `rel="noopener"` and nothing else needs to.
*/
const site = [
{ href: localePath('/about', locale), label: t('footer.about'), external: false },
{ href: GITHUB, label: 'GitHub', external: true },
{ href: localePath('/terms', locale), label: t('footer.terms'), external: false },
{ href: localePath('/privacy', locale), label: t('footer.privacy'), external: false },
{ href: localePath('/disclaimer', locale), label: t('footer.disclaimer'), external: false },
];
/*
@@ -38,31 +63,65 @@ const madeBy = t('footer.madeBy', {
});
---
<footer>
<div class="foot-inner">
<footer class="site-footer" role="contentinfo">
<div class="foot-cols">
{/*
The brand column. The custodial warning lives here, under the description,
because it is the most honest sentence on the site and it was previously an
orphan floating at the bottom right of the last row. It is set in `--muted`
rather than `--faint`: quiet, but a step louder than the link columns, which is
the only typographic claim this footer makes about relative importance.
*/}
<div class="foot-brand">
<a class="foot-mark" href={localePath('/', locale)}>
<Moai size={20} />
<span>Cashumints.space</span>
</a>
<p class="foot-desc">{t('footer.tagline')}</p>
<p class="foot-warn">{t('footer.warning')}</p>
</div>
{/*
One nav landmark over both link columns, not one per column: a screen reader
listing landmarks should find "Footer", not "Footer" twice. The language control
carries its own nav, because it is a different kind of thing — it does not take
you to another page, it takes you to this page again.
*/}
<nav class="foot-nav" aria-label={t('footer.nav')}>
<div class="foot-col">
<h2 class="foot-head">{t('footer.col.explore')}</h2>
<ul>
{explore.map((link) => (
<li><a href={link.href}>{link.label}</a></li>
))}
</ul>
</div>
<div class="foot-col">
<h2 class="foot-head">{t('footer.col.site')}</h2>
<ul>
{site.map((link) => (
<li>
<a href={link.href} rel={link.external ? 'noopener' : undefined}>{link.label}</a>
</li>
))}
</ul>
</div>
</nav>
<div class="foot-col foot-lang">
<h2 class="foot-head">{t('lang.label')}</h2>
<LanguageSwitcher placement="footer" />
</div>
</div>
{/*
The bottom bar. Two items, and the discipline is that it stays two items: the
copyright and who made it. Everything else that was ever tempted into this row now
has a column above it.
*/}
<div class="foot-bar">
<span>{t('footer.copyright', { year: String(year) })}</span>
<div class="links">
<a href={GITHUB} rel="noopener">GitHub</a>
{nav.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
</div>
<span class="foot-right" set:html={madeBy} />
</div>
<div class="foot-inner foot-legal">
<div class="links">
{legal.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
</div>
<LanguageSwitcher variant="inline" />
<span class="foot-right foot-warn">{t('footer.warning')}</span>
<span class="foot-credit" set:html={madeBy} />
</div>
</footer>
<style>
/* The second row is quieter than the first and has no top border of its own: it
reads as a continuation of the footer, not a second footer. */
.foot-legal { padding-top: 0; border-top: none; font-size: 12.5px; }
.foot-warn { color: var(--faint); }
@media (max-width: 640px) {
.foot-legal { padding-top: 4px; }
}
</style>
+187
View File
@@ -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>
+167 -72
View File
@@ -1,12 +1,17 @@
---
/**
* The language switcher, in two shapes.
* The language switcher. One control, two placements.
*
* `menu` is the header control: a compact button showing the current language's code,
* opening a list of the three. `inline` is the footer's plain row of three links. Both
* are built from the same list and the same hrefs, because they are the same control
* and a reader who finds one and then the other should not have to work out whether
* they do the same thing.
* `bar` is the header's: a compact button showing the current language's code. `footer`
* is the Language column's: the same button, showing the language's full name, opening
* upward so the panel is never clipped off the bottom of the document. Same list, same
* hrefs, same markup, same script — the only differences are which label the trigger
* shows and which way the panel opens, because a reader who finds one and then the
* other should not have to work out whether they do the same thing.
*
* The footer used to carry a second, different control: every locale as a flat link,
* ~20 of them wrapped over two rows. That was not a switcher, it was a wall, and it
* grew by one row every time a language was added.
*
* Text, not flags, in both. A flag is a country: Spanish is not Spain, Dutch is not the
* Netherlands, and English belongs to nobody in particular. Each language is written in
@@ -15,22 +20,24 @@
* Every link points at this same page in that language, built from the unprefixed path,
* so this works identically on a mint page: /mint/kashu.me and /es/mint/kashu.me are
* the same mint. There is no "switch to Spanish and land on the home page" case, which
* is the usual failure of a switcher that only knows about the site root.
* is the usual failure of a switcher that only knows about the site root. They are real
* `<a href>` elements, so a crawler follows them and a reader with no JavaScript uses
* them.
*
* The header shape is a `<details>` rather than a scripted dropdown. It opens, closes
* and takes the keyboard with no JavaScript at all, which matters because the whole
* point of this control is being able to leave a page you cannot read. The script below
* only adds what `<details>` does not do by itself: closing on Escape and on a click
* elsewhere.
* The control is a `<details>` rather than a scripted dropdown. It opens, closes and
* takes the keyboard with no JavaScript at all, which matters because the whole point
* of this control is being able to leave a page you cannot read. The script below only
* adds what `<details>` does not do by itself: closing on Escape and on a click
* elsewhere, and filtering the list as you type.
*/
import { LOCALES, localePath, useI18n } from '../i18n';
import { LOCALES, localePath, localeInfo, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
/** `menu` is the header control; `inline` is the footer row. */
variant?: 'menu' | 'inline';
/** `bar` is the header control; `footer` is the one in the Language column. */
placement?: 'bar' | 'footer';
}
const { variant = 'inline' } = Astro.props;
const { placement = 'bar' } = Astro.props;
const { locale, path } = pageLocale(Astro);
const t = useI18n(locale);
@@ -43,40 +50,71 @@ const links = LOCALES.map((other) => ({
}));
/*
* Two letters in the header, because the full name of the current language is a word
* the reader already knows they are reading. The code is the locale's own, uppercased,
* so a fourth language needs nothing added here.
* The header shows two letters, because the full name of the current language is a word
* the reader already knows they are reading, and the header row is full. The footer
* column has the width for the name and no adjacent nav to crowd, and a column headed
* "Language" reads better with "Nederlands" under it than with "NL".
*/
const currentCode = locale.toUpperCase();
const trigger = placement === 'footer' ? localeInfo(locale).label : locale.toUpperCase();
/*
* The search field earns its place at 20-odd languages and is noise at three. It is
* the one part of this control that needs JavaScript, so it is also the one part that
* is allowed to be absent: every link is in the panel either way.
*/
const searchable = LOCALES.length > 8;
---
{
variant === 'menu' ? (
<details class="lang-menu" data-lang-menu>
<details class={`lang-menu lang-${placement}`} data-lang-menu>
<summary aria-haspopup="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" />
</svg>
{/* The code alone does not say what it is, so the label is in the tree for a
screen reader and out of the way for everyone else. */}
{/* The trigger label does not say what it is on its own, so the word is in the
accessibility tree and out of the way for everyone else. */}
<span class="sr-only">{t('lang.label')}</span>
<span class="lang-code">{currentCode}</span>
<span class="lang-current">{trigger}</span>
<svg class="lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div class="lang-panel">
{searchable && (
<div class="lang-search">
<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>
<input
type="search"
placeholder={`${t('lang.label')}…`}
aria-label={t('lang.switcher')}
autocomplete="off"
spellcheck="false"
data-lang-search
/>
</div>
)}
<nav class="lang-list" aria-label={t('lang.switcher')}>
{
/*
* The current language is still a link, and it goes where it says it goes.
* `aria-current` is what tells a screen reader it is where you already are; the
* weight and the tick are what tell everyone else.
*/
links.map((other) => (
<a
href={other.href}
hreflang={other.code}
lang={other.code}
aria-current={other.current ? 'true' : undefined}
data-lang-option
data-search={`${other.label} ${other.code}`}
>
<span>{other.label}</span>
<span class="lang-option-code">{other.code.toUpperCase()}</span>
{other.current && (
<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" />
@@ -86,33 +124,10 @@ const currentCode = locale.toUpperCase();
))
}
</nav>
</details>
) : (
<nav class="lang-switch" aria-label={t('lang.switcher')}>
{
/*
* The current language is still a link, and it goes where it says it goes.
* `aria-current` is what tells a screen reader it is where you already are, and
* the weight is what tells everyone else.
*/
links.map((other) => (
<a
href={other.href}
hreflang={other.code}
lang={other.code}
aria-current={other.current ? 'true' : undefined}
>
{other.label}
</a>
))
}
</nav>
)
}
</div>
</details>
<style>
/* ---------- the header control ---------- */
.lang-menu { position: relative; flex-shrink: 0; }
.lang-menu summary {
@@ -129,28 +144,62 @@ const currentCode = locale.toUpperCase();
.lang-menu summary:hover { color: var(--text); background: var(--card2); }
.lang-menu[open] summary { color: var(--text); background: var(--card2); }
.lang-code { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
.lang-caret { color: var(--faint); transition: transform var(--dur-quick) var(--ease-out); }
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
/* The same panel the account menu uses, so the two controls beside each other in the
header open into the same shape. */
.lang-list {
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
min-width: 168px;
.lang-panel {
position: absolute; z-index: 60;
width: min(260px, calc(100vw - 24px));
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 6px; display: flex; flex-direction: column; gap: 2px;
padding: 6px;
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
}
.lang-search {
display: flex; align-items: center; gap: 8px;
min-height: 40px; padding: 0 10px;
color: var(--faint); background: var(--card2);
border: 1px solid var(--line); border-radius: 8px;
}
.lang-search:focus-within {
color: var(--text);
border-color: var(--amber);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 18%, transparent);
}
.lang-search input {
width: 100%; min-width: 0; padding: 9px 0;
border: 0; outline: 0; background: transparent;
color: var(--text); font: inherit; font-size: 13.5px;
}
.lang-search input::placeholder { color: var(--faint); opacity: 1; }
.lang-search input::-webkit-search-cancel-button { cursor: pointer; }
.lang-list {
max-height: min(360px, calc(100dvh - 150px));
display: flex; flex-direction: column; gap: 2px;
overflow-y: auto; overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-color: var(--line) transparent;
}
.lang-search + .lang-list { margin-top: 6px; padding-right: 2px; }
.lang-list a {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 8px;
font-size: 13.5px; color: var(--muted);
transition: color .15s, background .15s;
}
.lang-list a[hidden] { display: none; }
.lang-list a:hover { color: var(--text); background: var(--card2); }
.lang-list a[aria-current] { color: var(--text); font-weight: 600; }
.lang-list a svg { margin-left: auto; color: var(--amber); flex-shrink: 0; }
.lang-option-code {
margin-left: auto; color: var(--faint);
font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
}
.lang-list a svg { color: var(--amber); flex-shrink: 0; }
/* ---------- the header placement ---------- */
.lang-bar .lang-current { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
.lang-bar .lang-panel { top: calc(100% + 8px); right: 0; }
/*
* Narrow: the globe alone.
@@ -162,40 +211,63 @@ const currentCode = locale.toUpperCase();
* accessible name stays either way, because it was never the visible text.
*/
@media (max-width: 760px) {
.lang-menu summary { padding: 7px 8px; gap: 0; }
.lang-code,
.lang-caret { display: none; }
.lang-bar summary { padding: 7px 8px; gap: 0; }
.lang-bar .lang-current,
.lang-bar .lang-caret { display: none; }
}
@media (max-width: 420px) {
.lang-menu summary { padding: 6px 6px; }
.lang-bar summary { padding: 6px 6px; }
}
/* ---------- the footer row ---------- */
/* ---------- the footer placement ---------- */
.lang-switch { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.lang-switch a {
font-size: 12.5px; color: var(--faint);
transition: color var(--dur-quick);
}
.lang-switch a:hover { color: var(--text); }
.lang-switch a[aria-current] { color: var(--text); font-weight: 600; }
/*
* Aligned with the column's other text rather than inset by the trigger's padding,
* so the globe sits on the same vertical line as the "Language" heading above it and
* the links in the columns beside it.
*/
.lang-footer { display: inline-block; margin-inline-start: -10px; }
.lang-footer summary { font-size: 13.5px; }
.lang-footer .lang-current { font-weight: 500; }
/*
* Opens upward. The control is a few hundred pixels from the bottom of the document,
* so a panel hanging below it would be a scroll into empty space at best and clipped
* at worst. `inset-inline-start: 0` rather than `left`, because two of the shipped
* locales are right-to-left and the panel should hang from the trigger's leading
* edge in both directions.
*/
.lang-footer .lang-panel { bottom: calc(100% + 8px); inset-inline-start: 0; }
.lang-footer .lang-list { max-height: min(300px, calc(100dvh - 200px)); }
</style>
<script>
import { onReady } from '../scripts/reveal';
/**
* The two things `<details>` does not do on its own.
* The three things `<details>` does not do on its own.
*
* Everything else about this control (open, close, keyboard, focus) is the element's
* own behaviour and works with this script blocked, which is the point of using one.
*
* Attached to the document once rather than to the element, because the view
* transition router replaces the header on every navigation and a per-element
* listener would be re-bound, and leak, once per page visited.
* transition router replaces the header and the footer on every navigation and a
* per-element listener would be re-bound, and leak, once per page visited. One
* listener set serves both placements.
*/
let wired = false;
const normalise = (value: string): string =>
value.normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLocaleLowerCase().trim();
const resetSearch = (menu: HTMLDetailsElement): void => {
const input = menu.querySelector<HTMLInputElement>('[data-lang-search]');
if (input) input.value = '';
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
option.hidden = false;
}
};
const closeAll = (except?: Element | null): void => {
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
if (menu !== except) menu.open = false;
@@ -221,5 +293,28 @@ const currentCode = locale.toUpperCase();
// Back to the control that opened it, not to the top of the document.
open.querySelector('summary')?.focus();
});
document.addEventListener('input', (event) => {
const input = (event.target as Element | null)?.closest<HTMLInputElement>('[data-lang-search]');
if (!input) return;
const menu = input.closest<HTMLDetailsElement>('[data-lang-menu]');
if (!menu) return;
const query = normalise(input.value);
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
option.hidden = !normalise(option.dataset.search ?? '').includes(query);
}
});
// `toggle` does not bubble, so capture it. Starting in the search field makes a
// long language list keyboard-friendly; clearing on close keeps every reopen useful.
document.addEventListener('toggle', (event) => {
const menu = event.target as HTMLDetailsElement;
if (!menu.matches?.('[data-lang-menu]')) return;
if (menu.open) {
requestAnimationFrame(() => menu.querySelector<HTMLInputElement>('[data-lang-search]')?.focus());
} else {
resetSearch(menu);
}
}, true);
});
</script>
+8
View File
@@ -103,6 +103,14 @@ const t = useI18n(locale);
<div class="login-qr" data-connect-qr></div>
{/*
The same pairing link the QR carries, as something to tap. A reader whose
signer app is on the device they are reading on cannot scan their own screen;
this hands the link straight to the app instead. The href is set when the
pairing starts: nostrconnect:// for any signer, primalconnect:// for Primal.
*/}
<a class="btn login-open-app" data-connect-open hidden></a>
<div class="login-uri">
<span class="mono" data-connect-uri></span>
<button type="button" class="copy-btn" data-connect-copy data-copy="" aria-label={t('login.copyLink')} hidden>
+65 -15
View File
@@ -1,6 +1,10 @@
---
import { getMintWarnings, mintChip, type MintCapabilities, type MintListItem } from '@cashumints/shared';
import {
baseUrlFromKey, federationIdFromSlug, getMintWarnings, mintChip,
type LnurlDetail, type MintCapabilities, type MintListItem,
} from '@cashumints/shared';
import { iconUrl } from '../lib/api';
import { targetPath } from '../lib/feed-resolve';
import { displayDomain, iconGradient, initials, transitionName } from '../lib/format';
import { localePath, useI18n } from '../i18n';
import { formatters, starString } from '../i18n/format';
@@ -25,18 +29,53 @@ interface Props {
* from the detail it already has. Left off, the card simply has no chip.
*/
capabilities?: MintCapabilities | null;
/**
* The LNURL states a chip can be drawn from, for a caller that already has the
* detail. The Cashu counterpart is `capabilities` above, and this is separate rather
* than merged with it because the two ecosystems' chips are read from entirely
* different facts: NUT switches there, an advertised withdraw ceiling and a reachable
* Lightning node here. Left off, an LNURL card simply has no chip.
*/
lnurl?: Pick<LnurlDetail, 'max_withdrawable_msat' | 'funding_available'> | null;
}
const { mint, rank, sentiment, revealDelay, capabilities } = Astro.props;
const { mint, rank, sentiment, revealDelay, capabilities, lnurl } = Astro.props;
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, all three 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.
*
* An LNURL mint does have a URL, but its row key carries an `lnurl:` scheme in front of
* it, so the domain comes out of the key rather than off it directly. A reader should
* see `lnurl.21mint.me`, not `lnurl:https://lnurl.21mint.me`.
*
* Everything else on the card is identical, and deliberately: rating, review count,
* sentiment bar and status all mean the same thing for all three, and a reader moving
* between the three indexes should not have to re-learn a card.
*/
const fedimint = mint.type === 'fedimint';
const lnurlBase = baseUrlFromKey(mint.url);
const domain = fedimint
? federationIdFromSlug(mint.host)
: displayDomain(lnurlBase ?? 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';
// One route per ecosystem, from the same table the review feed's links use, so a card
// and a feed row can never disagree about where a listing lives.
const href = localePath(targetPath(mint), locale);
/*
Two words at most. Offline already has its own card styling, so only the states a
@@ -44,14 +83,18 @@ const offline = mint.status === 'offline';
cannot pay back out, or one that has stopped moving sats in either direction. The
mint page carries the explanation.
*/
const chip = capabilities
? mintChip(
getMintWarnings(
{ status: mint.status, last_online: mint.last_online, capabilities },
warningOptions(f),
),
chipStrings(t),
)
const chipSource = capabilities || lnurl
? {
type: mint.type,
status: mint.status,
last_online: mint.last_online,
capabilities: capabilities ?? null,
...(lnurl ?? {}),
}
: null;
const chip = chipSource
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
: null;
const status = statusLabel(mint.status, t);
@@ -87,7 +130,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 +169,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,7 +207,14 @@ const bar =
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
<span class="last">
{
offline
/*
"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')
+78 -7
View File
@@ -10,6 +10,13 @@
* 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.
*
* All three ecosystems' pages mount this, unchanged. It fetches the same endpoint for
* every one of them and updates whichever regions the page actually has: a federation
* has no limits cell and no NUT rows, an LNURL mint has features rather than NUTs and
* withdraw bounds rather than mint limits, and a Cashu mint has neither of the other
* two panels — so each lookup simply misses. `getMintWarnings` branches on the `type` in
* the payload, so a fresh read can never put one ecosystem's banner on another's page.
*/
interface Props {
host: string;
@@ -20,14 +27,18 @@ const { host } = Astro.props;
<span data-mint-live={host} hidden></span>
<script>
import { getMintWarnings, readCapabilities, type MintWarning } from '@cashumints/shared';
import {
displayFeatures, 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 { featureRowsHtml, withdrawLimitsHtml } from '../lib/lnurl-ui';
import { useI18n } from '../i18n/client';
import { formatters, type Formatters } from '../i18n/format';
import { statusLabel, warningOptions } from '../i18n/mint';
import { onReady, prefersReducedMotion } from '../scripts/reveal';
import type { MintDetail } from '@cashumints/shared';
import type { LnurlDetail, MintDetail } from '@cashumints/shared';
function remove(el: Element): void {
el.classList.add('leaving');
@@ -77,8 +88,49 @@ const { host } = Astro.props;
else head.after(banner);
}
/** Keep the two places that repeat the banner's facts in step with it. */
function renderCapabilities(mint: MintDetail, f: Formatters): void {
/** Keep the places that repeat the banner's facts in step with it. */
function renderCapabilities(mint: MintDetail & Partial<LnurlDetail>, 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;
}
/*
* An LNURL mint's features and withdraw bounds, from a fresh probe. Handled here
* for the same reason the modules panel is, and with the same early return: these
* elements only exist on an LNURL page, and falling through to `readCapabilities`
* on a payload with no `info` would be reading NUT switches this ecosystem has none
* of.
*
* The limits cell is rebuilt inside this branch rather than left to the shared code
* below, because the two cells hold different numbers — withdraw bounds here, NUT-04
* mint limits there — and the selector is the same.
*/
const featureRows = document.querySelector<HTMLElement>('[data-live-features]');
if (featureRows) {
featureRows.innerHTML = featureRowsHtml(
displayFeatures(mint.features, mint.observed_features),
mint.funding_available ?? null,
f,
);
const lnurlLimits = document.querySelector<HTMLElement>('[data-live-limits]');
if (lnurlLimits) {
lnurlLimits.innerHTML = withdrawLimitsHtml(
mint.min_withdrawable_msat,
mint.max_withdrawable_msat,
f,
);
}
return;
}
const caps = readCapabilities(mint.info?.nuts);
const limits = document.querySelector<HTMLElement>('[data-live-limits]');
@@ -112,7 +164,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,10 +179,29 @@ 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') {
/*
* The "up" wording differs by whether a version was discoverable, and only
* LNURL routinely has none: its version comes solely from `/openapi.json`,
* which an operator can turn off, so that cell falls back to "last confirmed"
* rather than printing "unknown" above a date. A Cashu mint has `version` set
* from `/v1/info` in every real case and takes the same branch it always did.
*/
const up =
mint.version
? t('mint.checked', { when: f.relative(mint.last_probe) })
: t('lnurl.confirmed.when', { when: f.relative(mint.last_probe) });
checked.textContent =
mint.status !== 'offline' ? t('mint.checked', { when: f.relative(mint.last_probe) })
mint.status !== 'offline' ? up
: mint.last_online ? t('mint.lastSeen', { when: f.relative(mint.last_online) })
: t('mint.neverReached');
}
+455
View File
@@ -0,0 +1,455 @@
---
import Base from '../layouts/Base.astro';
import Moai from './Moai.astro';
import Reviews from './Reviews.astro';
import ReviewByUrl from './ReviewByUrl.astro';
import { localePath, useI18n, type Locale } from '../i18n';
/**
* The 404, as a component rather than a page, because it is reached two ways.
*
* `pages/404.astro` emits the English one at `/404`, which is also the route Astro's
* dev server and a static host look for by name; `pages/[...locale]/404.astro` emits
* the prefixed ones. Neither can import the other's template, so the page itself
* lives here and both are three lines.
*
* Also the resolver for a mint this build has never heard of, which is most of what
* the file now does.
*
* It began as a fallback for a mint discovered *between builds*: a GET against the API
* for the host in the URL, and a summary rendered from whatever came back. That covered
* the gap between discovery and the next rebuild and nothing else — a mint nobody had
* announced on Nostr was not in the index either, so the lookup missed and the reader
* got a 404 for a mint that was live and answering. `/lnurl-mint/mint.600.wtf` was the
* case that made it obvious.
*
* So the lookup became a resolver, for all three route shapes:
*
* 1. the API is asked whether it knows the host (unchanged, and the common case);
* 2. on a miss, the address is derived from the path and `POST /api/index` checks it
* live — one probe, and a Nostr lookup if nothing answers;
* 3. whatever comes back renders the page shell *and* the reviews panel, so a mint
* indexed a second ago is readable and reviewable immediately;
* 4. and every way that can fail says what happened in plain words, with the
* review-by-URL dialog offered so a mistyped address can be corrected on the spot.
*
* A federation is the one shape that cannot be resolved from its own URL: `/fedimint/`
* carries a shortened federation id, and an invite code cannot be derived from one. That
* link goes straight to step 4's not-found state, with the dialog offered for the code.
*/
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
---
<Base
title={t('notfound.title')}
description={t('notfound.description')}
current="mints"
offGraph
>
<div class="page">
<div class="notfound" data-notfound>
{/* 44px, not a hero graphic: DESIGN.md says the moai is never scaled large. */}
<span class="nf-moai"><Moai size={44} /></span>
<h1>{t('notfound.heading')}</h1>
<p class="lede">{t('notfound.lede')}</p>
<form class="search nf-search" action={localePath('/mints', locale)} method="get" role="search">
<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="nf-search">{t('home.search.label')}</label>
<input
id="nf-search"
name="q"
type="text"
autocomplete="off"
placeholder={t('home.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="nf-actions">
<a class="btn primary" href={localePath('/mints', locale)}>{t('notfound.browse')}</a>
<a class="btn" href={localePath('/', locale)}>{t('notfound.home')}</a>
</div>
</div>
{/*
Filled in by the resolver below when the URL looks like a mint page. Nothing
here is prerendered, so this is the one place on the site that gets a page
level skeleton. `data-boneyard` marks the capture target for `pnpm bones`.
*/}
<div class="mint-fallback" data-mint-fallback hidden>
{/*
One line, in words, above the skeleton: this page is doing something unusual —
checking a stranger's mint over the network — and a screenful of bones with no
explanation reads as a page that is merely slow. Outside the capture target on
purpose, so it is not measured into the skeleton it introduces.
*/}
<p class="fb-checking" data-nf-status hidden></p>
<div data-boneyard="mint-page" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'><div data-mint-fallback-body></div></div>
{/*
The real reviews panel, with no subject until one is resolved.
It is the same island every mint page runs, so a mint resolved here gets the
same list, the same filters and the same write dialog rather than a lesser
version of them — which is the difference between "this mint is reachable" and
"this mint is reviewable", and the second is the point of the whole feature.
The island sits out its first run with no subject and starts when the resolver
dispatches `cashumints:subject`.
*/}
<div class="nf-reviews" data-nf-reviews hidden>
<Reviews subject={null} reviewCount={0} distribution={{ '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 }} name="" />
</div>
</div>
</div>
{/*
The same dialog the index pages carry. Here it is the way out of every dead end:
a wrong ecosystem, an address with a typo in it, or a federation link whose invite
code only the reader has. Its trigger names which ecosystem to ask about.
*/}
<ReviewByUrl type="cashu" />
</Base>
<style>
.notfound { padding: 54px 0 20px; max-width: 620px; }
.nf-moai { display: block; margin-bottom: 18px; }
.notfound h1 { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; }
.nf-actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
/* Same search control as the home page hero, so a miss lands somewhere useful. */
.nf-search {
display: flex; align-items: center; gap: 12px; margin-top: 24px;
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 14px 16px;
transition: border-color .15s, box-shadow .15s;
}
.nf-search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
.nf-search > svg { color: var(--faint); flex-shrink: 0; }
.nf-search input {
flex: 1; background: none; border: none; outline: none; min-width: 0;
color: var(--text); font-family: var(--body); font-size: 15.5px;
}
.nf-search input::placeholder { color: var(--faint); }
.nf-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;
}
/* The .fb-* rules live in global.css: this page builds that markup with
innerHTML, and Astro's scoped selectors do not reach injected elements. */
.mint-fallback { padding: 20px 0 40px; }
/* The one line above the skeleton while a mint is being checked. */
.fb-checking {
font-size: 13.5px; color: var(--muted); margin-bottom: 16px;
display: flex; align-items: center; gap: 9px;
}
/* `display: flex` beats the user agent's `[hidden] { display: none }`, so an author
rule has to take it back or the line (and its pulsing dot) never goes away. */
.fb-checking[hidden] { display: none; }
.fb-checking::before {
content: ''; width: 7px; height: 7px; border-radius: 50%;
background: var(--amber); flex-shrink: 0;
animation: soft-pulse 1.4s var(--ease-in-out) infinite;
}
</style>
<script>
import { apiBase, escapeHtml, iconGradient } from '../lib/client';
import { armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../lib/skeleton';
import { useI18n } from '../i18n/client';
import { formatters, starString, type Formatters } from '../i18n/format';
import { statusLabel, warningOptions } from '../i18n/mint';
import { bannerHtml } from '../lib/mint-state';
import { splitLocale } from '../i18n/routing';
import { onReady } from '../scripts/reveal';
import { indexErrorMessage, mintDisplayName, requestIndex, subjectFor } from '../lib/index-client';
import mintPageBones from '../bones/mint-page.bones.json';
import {
addressFromSlug, getMintWarnings, typeForPath,
type IndexFailure, type IndexType, type MintDetail,
} from '@cashumints/shared';
/**
* The summary this page can build from the API alone. `pnpm bones` measures the
* output of this same function, so the bones are the shape of what actually
* arrives rather than of a fuller page that never does.
*/
function fallbackHtml(mint: MintDetail, f: Formatters): string {
const domain = mint.url.replace(/^lnurl:/, '').replace(/^https?:\/\//, '');
const name = mint.name ?? domain.split('/')[0] ?? domain;
/*
* The one warning the mint has earned, drawn by the same function the mint pages
* use. It matters most on exactly the mint this resolver exists for: one indexed
* from a Nostr announcement because nothing answered at its address is, by
* definition, a mint whose page opens with "likely gone" — and a status chip alone
* does not say that.
*/
const warning = bannerHtml(getMintWarnings(mint, warningOptions(f))[0] ?? null);
return `
${warning}
<div class="fb-head">
<span class="mc-icon" style="background:${iconGradient(domain)};width:52px;height:52px;border-radius:13px;font-size:21px">${escapeHtml(
(name[0] ?? '?').toUpperCase(),
)}</span>
<span>
<span class="fb-name">${escapeHtml(name)}</span>
<span class="fb-domain">${escapeHtml(domain)}</span>
</span>
<span class="status-chip ${escapeHtml(mint.status)}"><span class="status-dot"></span>${escapeHtml(
statusLabel(mint.status, f.t),
)}</span>
</div>
<div class="fb-stats">
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.rating'),
)}</span><span class="v">${
mint.rating_avg === null ? f.t('time.none') : f.decimal(mint.rating_avg)
} <span class="stars" style="font-size:14px">${starString(mint.rating_avg)}</span></span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.reviews'),
)}</span><span class="v">${f.number(mint.review_count)}</span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.software'),
)}</span><span class="v" style="font-family:var(--mono);font-size:16px">${escapeHtml(
mint.version ?? f.t('mint.software.unknown'),
)}</span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.lastOnline'),
)}</span><span class="v" style="font-size:16px">${escapeHtml(
f.relative(mint.last_online),
)}</span></span>
</div>
<p class="fb-note">${escapeHtml(f.t('notfound.fb.note'))}</p>
<div class="fb-actions">
<button type="button" class="btn primary" data-write-open>${escapeHtml(
f.t('reviews.byUrl.open'),
)}</button>
</div>`;
}
const setup = (): void => {
const f = formatters(useI18n());
const t = f.t;
/*
* The miss could be at /mint/x or at /es/mint/x, and both resolve to the same mint.
* The prefix comes off with the same helper the build routes with, so the two can
* never disagree about what counts as a locale segment.
*/
const { path } = splitLocale(window.location.pathname);
const target = typeForPath(path);
const box = document.querySelector<HTMLElement>('[data-mint-fallback]');
const body = document.querySelector<HTMLElement>('[data-mint-fallback-body]');
const status = document.querySelector<HTMLElement>('[data-nf-status]');
const notfound = document.querySelector<HTMLElement>('[data-notfound]');
const reviewsBox = document.querySelector<HTMLElement>('[data-nf-reviews]');
const panel = document.querySelector<HTMLElement>('[data-reviews-panel]');
if (!box || !body) return;
if (isBuildMode()) {
// `pnpm bones` is measuring this page. Show a representative mint so the capture
// has a real summary to measure instead of an empty box. Imported dynamically so
// the fixture never reaches a real visitor.
void armBuildSnapshot()
.then(() => import('../lib/skeleton-fixtures'))
.then(({ FIXTURE_MINT }) => {
if (notfound) notfound.hidden = true;
box.hidden = false;
body.innerHTML = fallbackHtml(FIXTURE_MINT, f);
});
return;
}
if (!target) return; // An ordinary 404. The page above is the whole answer.
/**
* Hand the resolved mint to the reviews panel and let it run.
*
* The panel is the same island a mint page uses and it reads its subject off its own
* root, so "starting" it is writing the subject there and telling it to look again.
* Nothing about the panel is special-cased for this page.
*/
function startReviews(mint: MintDetail): void {
const subject = subjectFor(mint);
if (!panel || !subject || !reviewsBox) return;
panel.dataset['subject'] = JSON.stringify(subject);
const list = panel.querySelector<HTMLElement>('[data-review-list]');
if (list) list.dataset['reviewCount'] = String(mint.review_count);
// The write dialog's heading names the mint, and until now there was no mint to
// name. Same sentence the prerendered pages carry.
const title = document.querySelector<HTMLElement>('#write-title');
if (title) title.textContent = t('reviews.dialog.title', { name: mintDisplayName(mint) });
/*
* And the prompt in the box, which is the one line in that dialog that cannot be
* ecosystem-neutral: "how did minting and melting work out" means nothing about a
* federation. The panel picks it at build time from its subject, and this page had
* none, so it defaulted to the Cashu wording. Same key, same fallback rule.
*/
const prompt = document.querySelector<HTMLTextAreaElement>('[data-write-dialog] textarea');
if (prompt) {
const key = `reviews.dialog.bodyPlaceholder.${mint.type}`;
prompt.placeholder = t.has(key) ? t(key) : t('reviews.dialog.bodyPlaceholder.cashu');
}
reviewsBox.hidden = false;
document.dispatchEvent(new CustomEvent('cashumints:subject'));
}
/**
* The tab title for a resolved mint.
*
* One key per ecosystem rather than the Cashu one for all three: a federation's tab
* reading "Cashu mint reviews" is a small, flatly wrong sentence in the one place a
* reader keeps a page open under.
*/
function titleFor(mint: MintDetail): string {
const name = mintDisplayName(mint);
if (mint.type === 'fedimint') return t('mint.titleFedimint', { name });
if (mint.type === 'lnurl') return t('mint.titleLnurl', { name });
return t('mint.title', { name });
}
/** Render the page shell for a mint the site now has. */
function renderMint(mint: MintDetail): void {
if (status) status.hidden = true;
swapBones(body!, (slot) => {
slot.innerHTML = fallbackHtml(mint, f);
});
document.title = titleFor(mint);
startReviews(mint);
}
/**
* The honest dead end.
*
* Not the plain 404: the reader asked about a specific address and this says what
* happened to it, in the words the failure deserves, with the two ways forward that
* actually exist — correct the address, or open it as the ecosystem it turned out to
* belong to.
*/
function renderFailure(message: string, options: { detected?: IndexType; prefill?: string } = {}): void {
if (status) status.hidden = true;
clearBones(body!);
box!.hidden = true;
if (reviewsBox) reviewsBox.hidden = true;
if (!notfound) return;
notfound.hidden = false;
const lede = notfound.querySelector<HTMLElement>('.lede');
if (lede) lede.textContent = message;
const heading = notfound.querySelector<HTMLElement>('h1');
if (heading) {
// A federation is not a mint, and this heading is the one line that names it.
heading.textContent =
target!.type === 'fedimint'
? t('notfound.resolve.failedFedimint')
: t('notfound.resolve.failed');
}
const actions = notfound.querySelector<HTMLElement>('.nf-actions');
if (!actions || actions.querySelector('[data-review-url-open]')) return;
/*
* The way out, as the first action. A reader who typed one character wrong is one
* dialog away from their mint's page; a reader whose address belongs to another
* ecosystem is one button away from the right one.
*/
const kind = options.detected ?? target!.type;
const label = options.detected
? t('notfound.resolve.switch', { kind: t(`feed.ecosystem.${options.detected}`) })
: t('notfound.resolve.retry');
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn primary';
button.dataset['reviewUrlOpen'] = kind;
if (options.prefill) button.dataset['reviewUrlValue'] = options.prefill;
button.textContent = label;
actions.prepend(button);
// The dialog's own island bound its triggers when the page loaded, and this button
// did not exist then.
button.addEventListener('click', () => {
document.querySelector<HTMLElement>('[data-review-url-dialog]')?.dispatchEvent(
new CustomEvent('cashumints:open', { detail: { type: kind, value: options.prefill ?? '' } }),
);
});
}
// The "page not found" copy would be wrong if this mint turns out to exist, so hold
// it back while we find out rather than flashing it and taking it away again.
if (notfound) notfound.hidden = true;
box.hidden = false;
if (status) {
status.textContent = t('notfound.resolve.checking');
status.hidden = false;
}
// The same sentence goes to `showBones` as its screen reader status line, so both
// kinds of reader are told the same thing at the same moment.
showBones(body, mintPageBones, { label: t('notfound.resolve.checking') });
void (async () => {
// 1. Does the index already know it? Much the commonest case: a mint discovered
// since the last build, whose row exists and whose page does not.
try {
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(target.host)}`);
if (res.ok) {
renderMint((await res.json()) as MintDetail);
return;
}
} catch {
// The API is unreachable. Indexing would fail for the same reason; fall through
// to the failure state, which says so.
}
/*
* 2. Two shapes cannot be turned back into an address.
*
* A federation's page address carries a shortened id, and an invite code cannot be
* derived from one. A slug with a path in it (`mint.example.com-bitcoin`) is
* ambiguous by construction — the hyphen may be a path separator or part of a
* hostname — and guessing would mean fetching, and possibly indexing, the wrong
* host. Both say so and offer the dialog, which is where an address the reader
* actually has can go.
*/
const address = target.type === 'fedimint' ? null : addressFromSlug(target.host);
if (!address) {
renderFailure(
target.type === 'fedimint' ? t('notfound.resolve.fedimint') : t('notfound.resolve.ambiguous'),
);
return;
}
// 3. Check it live and index it if it is real.
const result = await requestIndex(target.type, address);
if (result.ok) {
renderMint(result.mint);
return;
}
const failure: IndexFailure = result.failure;
renderFailure(indexErrorMessage(result, t), {
...(failure.detected_type ? { detected: failure.detected_type } : {}),
prefill: address,
});
})();
};
onReady(setup);
</script>
+492
View File
@@ -0,0 +1,492 @@
---
/**
* Island: review a mint by pasting its address.
*
* The index pages list what this site knows. Until now, reviewing something it did not
* know was impossible from here — the only way in was a mint page, and a mint with no
* page could not be reached. This is the way in: one dialog, two steps, on /mints,
* /fedimints and /lnurl-mints alike.
*
* 1. **Identify.** One field, labelled for this page's ecosystem, with a format check
* that runs before any request. Submitting it calls `POST /api/index`, which either
* finds the mint already indexed, checks it live and indexes it, tells us it is a
* *different* ecosystem's mint (with a one-click switch), or explains why not.
* 2. **Review.** The site's ordinary write form — same stars, same identity row, same
* publisher — pointed at whatever step 1 resolved.
*
* One `<dialog>` for both, because it is one intention: the reader came here to write a
* review and the address is the first question, not a separate errand. The steps
* cross-fade in place rather than the dialog closing and reopening.
*
* The trigger lives on the page rather than in here: it belongs in the index head row,
* beside the heading, and the dialog belongs at the end of the document. Anything with
* `data-review-url-open` opens this.
*/
import WriteForm from './WriteForm.astro';
import { useI18n, type Locale } from '../i18n';
import { pageLocale } from '../i18n/paths';
import type { IndexType } from '@cashumints/shared';
interface Props {
/** Which ecosystem this page lists, and therefore what the dialog opens asking for. */
type: IndexType;
}
const { type } = Astro.props;
const { locale } = pageLocale(Astro) as { locale: Locale };
const t = useI18n(locale);
/*
* The label, the invitation and the two review prompts all differ per ecosystem, and
* all of them fall back to the Cashu wording for a type whose own string has not been
* written — the same rule the reviews panel already applies to its placeholder.
*/
const wordsFor = (
kind: IndexType,
): { label: string; placeholder: string; lede: string; body: string } => {
const key = (base: string): string =>
t.has(`${base}.${kind}`) ? `${base}.${kind}` : `${base}.cashu`;
return {
label: t(key('reviews.byUrl.label')),
placeholder: t(key('reviews.byUrl.placeholder')),
lede: t(key('reviews.dialog.lede')),
body: t(key('reviews.dialog.bodyPlaceholder')),
};
};
/*
* All three ecosystems' wording travels with the dialog, not just this page's.
*
* An index page only ever asks about its own, but the 404 page carries one of these for
* whichever deep link missed — and a wrong-type answer can move a submission from one
* ecosystem to another mid-dialog, at which point "Mint URL" is the wrong label for the
* box the reader is looking at. Three short strings in an attribute is a smaller price
* than a dialog per ecosystem or a second request for a label.
*/
const words = {
cashu: wordsFor('cashu'),
fedimint: wordsFor('fedimint'),
lnurl: wordsFor('lnurl'),
};
const initial = words[type] ?? words.cashu;
---
<dialog
class="modal rbu-dialog"
data-review-url-dialog
data-rbu-type={type}
data-rbu-words={JSON.stringify(words)}
aria-labelledby="rbu-title"
>
{/*
Both steps live in the same box and only one is ever in the flow, so the dialog is
the height of whichever step is showing and the backdrop never jumps around a
half-rendered second step.
*/}
<div class="rbu-steps" data-rbu-steps>
<form class="modal-body rbu-step" data-rbu-identify novalidate>
<div class="modal-head">
<h2 id="rbu-title">{t('reviews.byUrl.title')}</h2>
<button type="button" class="copy-btn" data-rbu-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" />
</svg>
</button>
</div>
<p class="modal-note">{t('reviews.byUrl.lede')}</p>
<label class="write-label" for="rbu-input" data-rbu-label>{initial.label}</label>
<input
id="rbu-input"
class="rbu-input"
data-rbu-input
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder={initial.placeholder}
aria-describedby="rbu-error"
/>
<p class="write-error" id="rbu-error" data-rbu-error hidden role="alert"></p>
{/* Filled by the island when the API says "that is the other ecosystem's mint". */}
<p class="rbu-switch" data-rbu-switch hidden></p>
<div class="write-actions">
<button type="button" class="btn" data-rbu-close>{t('common.cancel')}</button>
<button type="submit" class="btn primary" data-rbu-check>
<span class="write-spinner" aria-hidden="true"></span>
<span data-rbu-check-label>{t('reviews.byUrl.check')}</span>
</button>
</div>
</form>
<div class="rbu-step" data-rbu-review hidden>
<WriteForm locale={locale} lede={initial.lede} placeholder={initial.body} idPrefix="rbu">
<div class="modal-head" slot="head">
<h2 data-rbu-review-title>{t('reviews.byUrl.title')}</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" />
</svg>
</button>
</div>
<button type="button" class="rbu-back" slot="head" data-rbu-back>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
{t('reviews.byUrl.back')}
</button>
{/*
The mint the reader is about to review, drawn from the payload the API just
returned. It is the confirmation that the right thing was found — a domain and
a status chip say more than a name alone — and it is why the second step does
not need to repeat the address in its heading.
*/}
<div class="rbu-preview" slot="head" data-rbu-preview></div>
</WriteForm>
</div>
</div>
</dialog>
<style>
/* The two steps share one box; only the one in the flow is measured. */
.rbu-steps { position: relative; }
/*
* `hidden` alone is not enough on the first step: it carries `.modal-body`, whose
* `display: flex` beats the user agent's `[hidden] { display: none }` and left both
* steps stacked in the dialog at once. An author rule has to take it back.
*/
.rbu-step[hidden] { display: none; }
.rbu-step { transition: opacity 140ms var(--ease-out), transform 140ms var(--ease-out); }
/* Leaving to the left, arriving from the right: the reader is moving forwards. */
.rbu-step.is-leaving { opacity: 0; transform: translateX(-10px); }
.rbu-step.is-entering { opacity: 0; transform: translateX(10px); }
/* Going back reverses it, so the motion always matches the direction of travel. */
.rbu-step.is-leaving-back { opacity: 0; transform: translateX(10px); }
.rbu-step.is-entering-back { opacity: 0; transform: translateX(-10px); }
@media (prefers-reduced-motion: reduce) {
.rbu-step { transition: none; }
.rbu-step.is-leaving, .rbu-step.is-entering,
.rbu-step.is-leaving-back, .rbu-step.is-entering-back { opacity: 1; transform: none; }
}
</style>
<script>
import { escapeHtml, iconGradient } from '../lib/client';
import { useI18n } from '../i18n/client';
import { statusLabel } from '../i18n/mint';
import { localePath, splitLocale } from '../i18n/routing';
import { onReady, prefersReducedMotion } from '../scripts/reveal';
import { closeModal, openModal } from '../lib/modal';
import { indexErrorMessage, mintDisplayName, displayAddress, requestIndex, subjectFor } from '../lib/index-client';
import { rememberFreshReview, wireWriteForm } from '../lib/write-review';
import { checkIndexInput, mintPath, type IndexSuccess, type IndexType } from '@cashumints/shared';
import type { ReviewSubject } from '../lib/review-subject';
const setup = (): void => {
const dialog = document.querySelector<HTMLDialogElement>('[data-review-url-dialog]');
if (!dialog) return;
const t = useI18n();
const identify = dialog.querySelector<HTMLFormElement>('[data-rbu-identify]')!;
const reviewStep = dialog.querySelector<HTMLElement>('[data-rbu-review]')!;
const input = dialog.querySelector<HTMLInputElement>('[data-rbu-input]')!;
const errorBox = dialog.querySelector<HTMLElement>('[data-rbu-error]')!;
const switchBox = dialog.querySelector<HTMLElement>('[data-rbu-switch]')!;
const checkButton = dialog.querySelector<HTMLButtonElement>('[data-rbu-check]')!;
const checkLabel = dialog.querySelector<HTMLElement>('[data-rbu-check-label]')!;
const preview = dialog.querySelector<HTMLElement>('[data-rbu-preview]')!;
const reviewTitle = dialog.querySelector<HTMLElement>('[data-rbu-review-title]')!;
const label = dialog.querySelector<HTMLElement>('[data-rbu-label]')!;
const ledeBox = dialog.querySelector<HTMLElement>('[data-write-lede]')!;
const bodyBox = dialog.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
/**
* The wording for each ecosystem, carried in the markup.
*
* Read defensively: a payload that will not parse is a build bug, and a dialog that
* threw here would take the whole review flow down rather than showing a slightly
* generic label.
*/
const words = ((): Record<
string,
{ label: string; placeholder: string; lede: string; body: string }
> => {
try {
return JSON.parse(dialog.dataset['rbuWords'] ?? '{}') as Record<
string,
{ label: string; placeholder: string; lede: string; body: string }
>;
} catch {
return {};
}
})();
/**
* What is being asked for right now.
*
* Starts as the page's own ecosystem, and changes in two places: a trigger that
* names one (the 404 page, whose dialog serves whichever deep link missed), and the
* one-click switch after a wrong-type answer.
*/
let pageType = (dialog.dataset['rbuType'] ?? 'cashu') as IndexType;
function setType(next: IndexType): void {
pageType = next;
const wording = words[next];
if (!wording) return;
label.textContent = wording.label;
input.placeholder = wording.placeholder;
ledeBox.textContent = wording.lede;
bodyBox.placeholder = wording.body;
}
/** What step 1 resolved, and what step 2 publishes against. */
let resolved: { mint: IndexSuccess; subject: ReviewSubject } | null = null;
/** True while a submission is in flight, so nothing runs over it. */
let checking = false;
/* ---------- step 2: the ordinary write form ---------- */
const writer = wireWriteForm({
dialog,
subject: () => resolved?.subject ?? null,
onBackdrop: () => input.value.trim() === '' || window.confirm(t('reviews.byUrl.confirmClose')),
onPublished: (published) => {
const mint = resolved?.mint;
if (!mint) return;
/*
* The review is on the relays; the page that shows it is somewhere else. Hand it
* forward through sessionStorage and go there: the reviews panel picks it up on
* arrival and renders it at the top, exactly as it would have if the review had
* been written on that page. Without this, an author would land on their own
* mint's page and not see what they just wrote until the relays echoed it back.
*/
rememberFreshReview(published);
const { locale } = splitLocale(window.location.pathname);
window.location.href = localePath(mintPath(mint.type, mint.host), locale);
},
});
/* ---------- moving between the steps ---------- */
/**
* Cross-fade to the other step, and hand back the moment it is in the flow.
*
* `onShown` is where anything that focuses belongs: a hidden element cannot take
* focus, so setting up the incoming step before this callback runs would put the
* cursor on the document body and leave the reader pressing Tab to find the form.
* It runs before the pane is painted, so the step is never seen half-arranged.
*/
function showStep(next: 'identify' | 'review', onShown?: () => void): void {
const from = next === 'review' ? identify : reviewStep;
const to = next === 'review' ? reviewStep : identify;
const back = next === 'identify';
if (prefersReducedMotion()) {
from.hidden = true;
to.hidden = false;
onShown?.();
return;
}
from.classList.add(back ? 'is-leaving-back' : 'is-leaving');
window.setTimeout(() => {
from.hidden = true;
from.classList.remove('is-leaving', 'is-leaving-back');
to.hidden = false;
to.classList.add(back ? 'is-entering-back' : 'is-entering');
onShown?.();
// Two frames: one for the browser to lay the pane out hidden-to-shown, one for
// the transition to have a starting value to move from.
requestAnimationFrame(() =>
requestAnimationFrame(() => to.classList.remove('is-entering', 'is-entering-back')),
);
}, 140);
}
/* ---------- the preview card ---------- */
function drawPreview(mint: IndexSuccess): void {
const name = mintDisplayName(mint);
const address = displayAddress(mint);
const note = mint.existing ? t('reviews.byUrl.existing') : t('reviews.byUrl.indexed');
preview.innerHTML =
`<span class="rbu-icon" style="background:${iconGradient(address)}">${escapeHtml(
(name[0] ?? '?').toUpperCase(),
)}</span>` +
`<span class="rbu-who">` +
`<span class="rbu-name">${escapeHtml(name)}</span>` +
// A federation submitted as a bare invite code has no name but its own id, and
// printing that id twice says nothing the first line did not.
(address === name ? '' : `<span class="rbu-domain">${escapeHtml(address)}</span>`) +
`</span>` +
`<span class="rbu-chips">` +
`<span class="status-chip ${escapeHtml(mint.status)}"><span class="status-dot"></span>${escapeHtml(
statusLabel(mint.status, t),
)}</span>` +
`<span class="rbu-note">${escapeHtml(note)}</span></span>`;
reviewTitle.textContent = t('reviews.dialog.title', { name });
}
/* ---------- step 1: identify ---------- */
function showError(message: string): void {
errorBox.textContent = message;
errorBox.hidden = message === '';
}
function setChecking(busy: boolean): void {
checking = busy;
input.disabled = busy;
checkButton.disabled = busy;
checkButton.classList.toggle('is-busy', busy);
checkLabel.textContent = busy ? t('reviews.byUrl.checking') : t('reviews.byUrl.check');
}
/** Offer the other ecosystem, with a button that re-submits under it. */
function offerSwitch(detected: IndexType): void {
switchBox.hidden = false;
switchBox.innerHTML =
`${escapeHtml(t('reviews.byUrl.detected', { kind: t(`feed.ecosystem.${detected}`) }))} ` +
`<button type="button" class="rev-retry" data-rbu-switch-to="${escapeHtml(detected)}">${escapeHtml(
t('reviews.byUrl.switchTo', { kind: t(`feed.ecosystem.${detected}`) }),
)}</button>`;
}
async function submitAddress(type: IndexType, raw: string): Promise<void> {
if (checking) return;
showError('');
switchBox.hidden = true;
// The format check runs first and costs nothing: an empty box, a sentence, or an
// invite code in a URL field are all answerable without asking the server.
const precheck = checkIndexInput(type, raw);
if (!precheck.ok) {
showError(
precheck.reason === 'empty' ? t('reviews.byUrl.error.empty')
: precheck.reason === 'bad_invite' ? t('reviews.byUrl.error.badInvite')
: t('reviews.byUrl.error.badUrl'),
);
input.focus();
return;
}
setChecking(true);
const result = await requestIndex(type, precheck.value);
setChecking(false);
if (!result.ok) {
const detected = result.failure.detected_type;
showError(indexErrorMessage(result, t));
if (detected && detected !== type) offerSwitch(detected);
input.focus();
input.select();
return;
}
const subject = subjectFor(result.mint);
if (!subject) {
// Indexed, but this build has no review kind for its ecosystem. Publishing an
// event nothing could resolve would be worse than saying so.
showError(t('reviews.byUrl.error.notAMint'));
return;
}
resolved = { mint: result.mint, subject };
drawPreview(result.mint);
writer.reset();
showStep('review', () => writer.open());
}
identify.addEventListener('submit', (event) => {
event.preventDefault();
void submitAddress(pageType, input.value);
});
// The one-click switch: re-submit the same address under the ecosystem the API
// recognised, and carry straight on into the review.
switchBox.addEventListener('click', (event) => {
const button = (event.target as HTMLElement).closest<HTMLElement>('[data-rbu-switch-to]');
const detected = button?.dataset['rbuSwitchTo'] as IndexType | undefined;
if (!detected) return;
// The box is about to be asked about a different ecosystem, so it says so before
// the request goes out rather than after it comes back.
setType(detected);
void submitAddress(detected, input.value);
});
input.addEventListener('input', () => {
showError('');
switchBox.hidden = true;
});
dialog.querySelector('[data-rbu-back]')?.addEventListener('click', () => {
showStep('identify');
window.setTimeout(() => input.focus(), 160);
});
for (const button of dialog.querySelectorAll('[data-rbu-close], [data-write-close]')) {
button.addEventListener('click', () => closeModal(dialog));
}
/* ---------- opening ---------- */
/**
* Open the dialog on step 1, asking about `type`, with `prefill` in the box.
*
* Always back to the address, even if a previous visit got as far as the form: the
* reader pressed "write a review", not "carry on with the last one".
*/
function openWith(type: IndexType, prefill?: string): void {
setType(type in words ? type : ((dialog!.dataset['rbuType'] ?? 'cashu') as IndexType));
resolved = null;
identify.hidden = false;
reviewStep.hidden = true;
showError('');
switchBox.hidden = true;
setChecking(false);
if (prefill) input.value = prefill;
openModal(dialog!);
input.focus();
input.select();
}
for (const trigger of document.querySelectorAll<HTMLElement>('[data-review-url-open]')) {
trigger.addEventListener('click', () => {
/*
* A trigger may name the ecosystem it is asking about, and hand over the address
* the reader was already looking for. The index pages need neither — their
* dialog is already theirs and there is nothing to prefill — but the 404 page's
* trigger serves all three and knows exactly which address just failed.
*/
const asked = trigger.dataset['reviewUrlOpen'] as IndexType | undefined;
openWith(asked ?? ((dialog.dataset['rbuType'] ?? 'cashu') as IndexType), trigger.dataset['reviewUrlValue']);
});
}
/*
* The same thing, for a trigger that did not exist when this ran.
*
* The 404 page builds its button only once a resolution has failed, which is long
* after the loop above. Rather than have that page reach into this island's
* internals, it dispatches an event at the dialog and this answers it.
*/
dialog.addEventListener('cashumints:open', (event) => {
const detail = (event as CustomEvent<{ type?: string; value?: string }>).detail ?? {};
openWith((detail.type ?? 'cashu') as IndexType, detail.value);
});
};
onReady(setup);
</script>
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -5,7 +5,7 @@ import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
current?: 'mints' | 'reviews' | 'wallets' | 'about';
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
/** Shows the compact search affordance next to the brand (mint pages, /mints). */
mintCount?: number;
}
@@ -18,8 +18,26 @@ 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".
*
* Three ecosystems are listed now, so a bare "Mints" beside "Fedimints" and "LNURL
* mints" reads as though the others were something other than mints — or as though the
* first link covered all of them. 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.
*
* Six items is where this row stops fitting on one line. It still does at 1280px in
* every locale; below that the nav drops to a row of its own rather than collapsing
* behind a hamburger, which is the `@media (max-width: 1180px)` block in global.css and
* the comment there explains how that number was arrived at. A fourth ecosystem would
* need a real menu instead — this row is full.
*/
const links = [
{ path: '/mints', label: t('nav.mints'), key: 'mints' },
{ path: '/fedimints', label: t('nav.fedimints'), key: 'fedimints' },
{ path: '/lnurl-mints', label: t('nav.lnurlMints'), key: 'lnurl-mints' },
{ path: '/reviews', label: t('nav.reviews'), key: 'reviews' },
{ path: '/wallets', label: t('nav.wallets'), key: 'wallets' },
{ path: '/about', label: t('nav.about'), key: 'about' },
@@ -69,7 +87,7 @@ const links = [
of it to get out. The footer keeps its own copy, where someone deliberately looking
for site settings expects to find one.
*/}
<LanguageSwitcher variant="menu" />
<LanguageSwitcher placement="bar" />
{/*
The account control. Prerendered logged out, because a static build cannot know

Some files were not shown because too many files have changed in this diff Show More