Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7664fbe0 | ||
|
|
70b35f4ccc | ||
|
|
1eade490c8 | ||
|
|
860a4de009 | ||
|
|
24fe2003b6 | ||
|
|
14548179a0 | ||
|
|
060c7f1a59 | ||
|
|
0ebc8ada54 | ||
|
|
9ffa53094d | ||
|
|
65307ba278 | ||
|
|
06ba3d35e7 | ||
|
|
79a115be38 | ||
|
|
301679d340 | ||
|
|
b95aab2bcd | ||
|
|
c74c7fc187 | ||
|
|
2a9444942b | ||
|
|
c97b44018d | ||
|
|
36c01861f5 | ||
|
|
be322cb0d8 | ||
|
|
47e6537dde | ||
|
|
1c5df18e81 | ||
|
|
6f17b572b1 |
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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`:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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">⚡</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`);
|
||||
@@ -8,44 +8,62 @@
|
||||
* a test that actually kills a mint rather than a unit test of a helper.
|
||||
*
|
||||
* Runs against a throwaway copy of the seeded database, so the real one is untouched.
|
||||
* The copy is made with the migrator, which means the check works whichever backend
|
||||
* holds the real data — and, as a side effect, exercises the migrator on every run.
|
||||
*
|
||||
* Set CHECK_DB_URL to run the copy on Postgres instead of a temporary SQLite file:
|
||||
*
|
||||
* CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offline
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseDbTarget, resolveDbConfig } from './config.ts';
|
||||
import { TABLES } from './db-schema.ts';
|
||||
import { openDb } from './db.ts';
|
||||
import { migrate } from './migrate.ts';
|
||||
|
||||
const source = process.env['DB_PATH'] ?? path.resolve(import.meta.dirname, '..', 'data', 'cashumints.db');
|
||||
if (!fs.existsSync(source)) {
|
||||
console.error(`No seeded database at ${source}. Run "pnpm seed" first.`);
|
||||
const source = resolveDbConfig();
|
||||
if (source.dialect === 'sqlite' && !fs.existsSync(source.file)) {
|
||||
console.error(`No seeded database at ${source.file}. Run "pnpm seed" first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Point the whole process at a copy before anything opens the real database.
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-'));
|
||||
const copy = path.join(scratch, 'test.db');
|
||||
fs.copyFileSync(source, copy);
|
||||
process.env['DB_PATH'] = copy;
|
||||
const target = parseDbTarget(process.env['CHECK_DB_URL'] ?? path.join(scratch, 'test.db'));
|
||||
|
||||
// A reused Postgres test database still holds the last run's rows, and a stale mint row
|
||||
// would be picked as the victim. Start empty either way.
|
||||
const wipe = await openDb(target);
|
||||
for (const table of TABLES) await wipe.run(`DELETE FROM ${table}`);
|
||||
await wipe.close();
|
||||
|
||||
await migrate({ from: source, to: target, force: true, dryRun: false });
|
||||
|
||||
// Point the whole process at the copy before anything else opens a database.
|
||||
process.env['DATABASE_URL'] = target.dialect === 'postgres' ? target.url : `sqlite:${target.file}`;
|
||||
delete process.env['DB_PATH'];
|
||||
|
||||
const { getDb, closeDb } = await import('./db.ts');
|
||||
const { probeMint } = await import('./probe.ts');
|
||||
const { getMintDetail } = await import('./queries.ts');
|
||||
const { getMintDetail, listMints, resetStatsCache } = await import('./queries.ts');
|
||||
const { mintByUrl } = await import('./mints.ts');
|
||||
|
||||
const db = getDb();
|
||||
const db = await getDb();
|
||||
assert.equal(db.label, target.label, 'the check must not be talking to the real database');
|
||||
|
||||
// Pick a mint that is online and has real cached metadata to lose.
|
||||
const victim = db
|
||||
.prepare(
|
||||
`SELECT * FROM mints
|
||||
WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL
|
||||
ORDER BY LENGTH(info_json) DESC LIMIT 1`,
|
||||
)
|
||||
.get() as { url: string; host: string; name: string } | undefined;
|
||||
const victim = await db.get<{ url: string; host: string; name: string }>(
|
||||
`SELECT url, host, name FROM mints
|
||||
WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL
|
||||
ORDER BY LENGTH(info_json) DESC LIMIT 1`,
|
||||
);
|
||||
|
||||
assert.ok(victim, 'seeded database has no online mint with cached metadata');
|
||||
console.log(`victim: ${victim.name} (${victim.host})`);
|
||||
|
||||
const before = getMintDetail(victim.host);
|
||||
const before = await getMintDetail(victim.host);
|
||||
assert.ok(before, 'detail must exist before the mint dies');
|
||||
assert.equal(before.status, 'online');
|
||||
assert.ok(before.info, 'must have cached /v1/info before');
|
||||
@@ -53,18 +71,20 @@ assert.ok(before.nuts.length > 0, 'must have parsed NUTs before');
|
||||
|
||||
// Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test.
|
||||
const DEAD = 'https://this-mint-is-gone.invalid';
|
||||
db.prepare('UPDATE mints SET url = ? WHERE host = ?').run(DEAD, victim.host);
|
||||
db.prepare('UPDATE reviews SET mint_url = ? WHERE mint_url = ?').run(DEAD, victim.url);
|
||||
await db.run('UPDATE mints SET url = ? WHERE host = ?', DEAD, victim.host);
|
||||
await db.run('UPDATE reviews SET mint_url = ? WHERE mint_url = ?', DEAD, victim.url);
|
||||
|
||||
// Three failures is the offline threshold.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const row = mintByUrl(DEAD);
|
||||
const row = await mintByUrl(DEAD);
|
||||
assert.ok(row, 'row must survive the URL change');
|
||||
const result = await probeMint(row);
|
||||
assert.equal(result.ok, false, 'a dead URL must not probe ok');
|
||||
}
|
||||
|
||||
const after = getMintDetail(victim.host);
|
||||
resetStatsCache();
|
||||
|
||||
const after = await getMintDetail(victim.host);
|
||||
assert.ok(after, 'the mint page must still resolve when the mint is dead');
|
||||
assert.equal(after.status, 'offline', 'three failures means offline');
|
||||
|
||||
@@ -86,17 +106,17 @@ assert.ok(after.last_online !== null, 'last_online must be set so the banner has
|
||||
assert.ok(after.last_online <= Math.floor(Date.now() / 1000));
|
||||
|
||||
// And it must sink below every online mint without disappearing.
|
||||
const { listMints } = await import('./queries.ts');
|
||||
const list = listMints();
|
||||
const list = await listMints();
|
||||
const index = list.findIndex((m) => m.host === victim.host);
|
||||
assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it');
|
||||
const lastOnline = list.map((m) => m.status).lastIndexOf('online');
|
||||
assert.ok(index > lastOnline, 'offline mints rank below every online mint');
|
||||
|
||||
closeDb();
|
||||
await closeDb();
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
|
||||
console.log(
|
||||
`ok, offline mint still serves ${after.nuts.length} NUTs, ${after.review_count} reviews, ` +
|
||||
`and full cached metadata (offline since ${new Date(after.last_online * 1000).toISOString()})`,
|
||||
`ok on ${db.dialect}, offline mint still serves ${after.nuts.length} NUTs, ` +
|
||||
`${after.review_count} reviews, and full cached metadata ` +
|
||||
`(offline since ${new Date(after.last_online * 1000).toISOString()})`,
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The one interface every backend implements.
|
||||
*
|
||||
* SQL is written once, in the portable subset both SQLite and Postgres speak, and
|
||||
* bound with `?` placeholders. The Postgres driver rewrites those to `$1..$n`; the
|
||||
* SQLite driver passes them straight through. See db-schema.ts for the rules that
|
||||
* keep a statement portable.
|
||||
*
|
||||
* Everything is async because `pg` is async. better-sqlite3 is not, so its driver
|
||||
* resolves already-settled promises — the cost is a microtask per query, which is
|
||||
* nothing next to the HTTP and relay work around it.
|
||||
*/
|
||||
|
||||
export type Dialect = 'sqlite' | 'postgres';
|
||||
|
||||
/** What a statement can be bound to. `undefined` is rejected by both drivers. */
|
||||
export type Param = string | number | null;
|
||||
|
||||
export interface RunResult {
|
||||
/** Rows inserted, updated or deleted. `ON CONFLICT DO NOTHING` that did nothing is 0. */
|
||||
changes: number;
|
||||
}
|
||||
|
||||
/** The read/write surface. A transaction hands back one of these bound to its connection. */
|
||||
export interface Sql {
|
||||
all<T>(sql: string, ...params: Param[]): Promise<T[]>;
|
||||
get<T>(sql: string, ...params: Param[]): Promise<T | undefined>;
|
||||
run(sql: string, ...params: Param[]): Promise<RunResult>;
|
||||
}
|
||||
|
||||
export interface Db extends Sql {
|
||||
readonly dialect: Dialect;
|
||||
/** Connection description safe to log: a Postgres password is never in it. */
|
||||
readonly label: string;
|
||||
/** Run DDL. Each element is one statement, no placeholders. */
|
||||
exec(statements: readonly string[]): Promise<void>;
|
||||
/**
|
||||
* Run `fn` inside a transaction. Statements issued through the handle `fn` receives
|
||||
* are part of it and see its uncommitted writes; anything issued through the Db
|
||||
* itself waits until the transaction settles. A throw rolls back and re-throws.
|
||||
*/
|
||||
transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import pg from 'pg';
|
||||
import type { Db, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
|
||||
/**
|
||||
* node-postgres hands back BIGINT and NUMERIC as strings, because either can hold a
|
||||
* value JavaScript's number cannot. Nothing here can: the counters are row counts, the
|
||||
* timestamps are unix seconds, and the averages are ratings between 1 and 5. Left
|
||||
* as-is the strings would flow straight into the JSON the API serves, turning
|
||||
* `review_count: 12` into `"12"` and quietly breaking every comparison on the way.
|
||||
*/
|
||||
types.setTypeParser(20, (v) => Number.parseInt(v, 10)); // int8
|
||||
types.setTypeParser(1700, (v) => Number.parseFloat(v)); // numeric
|
||||
|
||||
/**
|
||||
* Rewrite `?` placeholders to `$1..$n`.
|
||||
*
|
||||
* Quoted text is copied verbatim so a `?` inside a string literal is not renumbered.
|
||||
* Postgres escapes a quote by doubling it, which needs no special case: the closing
|
||||
* quote ends one literal and the next character opens another.
|
||||
*/
|
||||
export function toPgPlaceholders(sql: string): string {
|
||||
let out = '';
|
||||
let quote: string | null = null;
|
||||
let n = 0;
|
||||
|
||||
for (const ch of sql) {
|
||||
if (quote !== null) {
|
||||
if (ch === quote) quote = null;
|
||||
out += ch;
|
||||
} else if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
out += ch;
|
||||
} else if (ch === '?') {
|
||||
out += `$${++n}`;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const translated = new Map<string, string>();
|
||||
|
||||
function translate(sql: string): string {
|
||||
let pgSql = translated.get(sql);
|
||||
if (pgSql === undefined) {
|
||||
pgSql = toPgPlaceholders(sql);
|
||||
translated.set(sql, pgSql);
|
||||
}
|
||||
return pgSql;
|
||||
}
|
||||
|
||||
/** Bind a Sql surface to anything that can run a query: the pool, or one client. */
|
||||
function surface(run: (text: string, values: Param[]) => Promise<pg.QueryResult>): Sql {
|
||||
return {
|
||||
async all<T>(sql: string, ...params: Param[]): Promise<T[]> {
|
||||
return (await run(translate(sql), params)).rows as T[];
|
||||
},
|
||||
async get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
|
||||
return (await run(translate(sql), params)).rows[0] as T | undefined;
|
||||
},
|
||||
async run(sql: string, ...params: Param[]): Promise<RunResult> {
|
||||
return { changes: (await run(translate(sql), params)).rowCount ?? 0 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip the password so a connection string is safe to put in a log line. */
|
||||
export function redact(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.password) parsed.password = '***';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return 'postgres';
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresDb implements Db {
|
||||
readonly dialect = 'postgres' as const;
|
||||
readonly label: string;
|
||||
|
||||
#pool: pg.Pool;
|
||||
#sql: Sql;
|
||||
|
||||
constructor(connectionString: string, poolMax: number) {
|
||||
this.#pool = new Pool({ connectionString, max: poolMax });
|
||||
// A pool client can die between checkouts — a Postgres restart, a killed session, an
|
||||
// idle timeout on a proxy. Without a listener node-postgres emits that on the pool as
|
||||
// an unhandled 'error' and takes the process down with it.
|
||||
this.#pool.on('error', () => undefined);
|
||||
|
||||
this.#sql = surface((text, values) => this.#pool.query(text, values));
|
||||
this.label = redact(connectionString);
|
||||
}
|
||||
|
||||
all<T>(sql: string, ...params: Param[]): Promise<T[]> {
|
||||
return this.#sql.all<T>(sql, ...params);
|
||||
}
|
||||
|
||||
get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
|
||||
return this.#sql.get<T>(sql, ...params);
|
||||
}
|
||||
|
||||
run(sql: string, ...params: Param[]): Promise<RunResult> {
|
||||
return this.#sql.run(sql, ...params);
|
||||
}
|
||||
|
||||
async exec(statements: readonly string[]): Promise<void> {
|
||||
// One connection for the lot: concurrent CREATE ... IF NOT EXISTS on the same
|
||||
// catalog rows deadlocks in Postgres, and this runs on every boot.
|
||||
const client = await this.#pool.connect();
|
||||
try {
|
||||
for (const sql of statements) await client.query(sql);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T> {
|
||||
const client = await this.#pool.connect();
|
||||
const tx = surface((text, values) => client.query(text, values));
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(tx);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch {
|
||||
// The connection is already unusable; release() below discards it.
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* One schema for both backends.
|
||||
*
|
||||
* The types below were picked because SQLite and Postgres agree on all of them:
|
||||
* TEXT, INTEGER and SMALLINT are literal Postgres types and map to SQLite's INTEGER
|
||||
* and TEXT affinities, and BIGINT is what unix seconds need in Postgres, where plain
|
||||
* INTEGER runs out in 2038. `CREATE TABLE/INDEX IF NOT EXISTS` is spelled the same in
|
||||
* both, so first boot on an empty Postgres database needs no separate setup step.
|
||||
*
|
||||
* Rules for keeping a statement portable, learned from the ones that were not:
|
||||
* - Bind with `?`. The Postgres driver rewrites to `$1..$n`.
|
||||
* - A subquery in FROM needs an alias. Postgres rejects it without one.
|
||||
* - Count a condition with `COUNT(*) FILTER (WHERE ...)`, never `SUM(cond)`:
|
||||
* Postgres has no implicit boolean-to-integer cast.
|
||||
* - `INSERT OR IGNORE` is SQLite-only. `ON CONFLICT DO NOTHING` works in both and,
|
||||
* with no conflict target, covers every unique constraint on the table.
|
||||
*/
|
||||
|
||||
export const TABLES = ['mints', 'reviews', 'probes', 'state'] as const;
|
||||
export type TableName = (typeof TABLES)[number];
|
||||
|
||||
export const SCHEMA: readonly string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS mints (
|
||||
url TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'cashu',
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon_url TEXT,
|
||||
icon_file TEXT,
|
||||
pubkey TEXT,
|
||||
info_json TEXT,
|
||||
ecosystem_json TEXT,
|
||||
nuts_json TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
consecutive_fails INTEGER NOT NULL DEFAULT 0,
|
||||
last_online BIGINT,
|
||||
last_probe BIGINT,
|
||||
first_seen BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS reviews (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
mint_url TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
rating INTEGER,
|
||||
k TEXT,
|
||||
created_at BIGINT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS probes (
|
||||
mint_url TEXT NOT NULL,
|
||||
ts BIGINT NOT NULL,
|
||||
ok SMALLINT NOT NULL,
|
||||
latency_ms INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
];
|
||||
|
||||
/** Column order used by the migrator, so source and target line up without guessing. */
|
||||
export const COLUMNS: Record<TableName, readonly string[]> = {
|
||||
mints: [
|
||||
'url', 'host', 'type', 'name', 'description', 'icon_url', 'icon_file', 'pubkey',
|
||||
'info_json', 'ecosystem_json', 'nuts_json', 'version', 'status', 'consecutive_fails',
|
||||
'last_online', 'last_probe', 'first_seen', 'updated_at',
|
||||
],
|
||||
reviews: ['event_id', 'mint_url', 'pubkey', 'rating', 'k', 'created_at'],
|
||||
probes: ['mint_url', 'ts', 'ok', 'latency_ms'],
|
||||
state: ['key', 'value'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Primary key per table, for the migrator's upsert. `probes` has none — it is an
|
||||
* append-only log with no natural key — so the migrator replaces it wholesale.
|
||||
*/
|
||||
export const PRIMARY_KEY: Record<TableName, string | null> = {
|
||||
mints: 'url',
|
||||
reviews: 'event_id',
|
||||
probes: null,
|
||||
state: 'key',
|
||||
};
|
||||
|
||||
/**
|
||||
* Statements that bring an existing database up to the schema above.
|
||||
*
|
||||
* `CREATE TABLE IF NOT EXISTS` does nothing to a table that is already there, so a
|
||||
* database seeded before federations were indexed never grows `mints.type` on its own.
|
||||
* These run once per boot, after `SCHEMA`, and are the whole of this project's
|
||||
* migration story.
|
||||
*
|
||||
* The `ADD COLUMN`s are spelled without `IF NOT EXISTS` on purpose: Postgres accepts it
|
||||
* there and SQLite does not, so the portable form is to run the statement and swallow
|
||||
* the "duplicate column" both backends raise. `applySchemaUpgrades` in db.ts does that.
|
||||
*
|
||||
* Order matters, and the index at the end is why this list exists rather than the index
|
||||
* sitting with its table in `SCHEMA`: on an upgraded database the column is created
|
||||
* here, so an index over it declared any earlier fails on a column that is not there
|
||||
* yet — which is exactly how the first version of this failed to boot.
|
||||
*
|
||||
* Every statement must be re-runnable and must not need a value: a NOT NULL column
|
||||
* needs a constant DEFAULT (which is what makes existing rows read as Cashu), and
|
||||
* anything else is nullable.
|
||||
*/
|
||||
export const SCHEMA_UPGRADES: readonly string[] = [
|
||||
`ALTER TABLE mints ADD COLUMN type TEXT NOT NULL DEFAULT 'cashu'`,
|
||||
`ALTER TABLE mints ADD COLUMN ecosystem_json TEXT`,
|
||||
`ALTER TABLE reviews ADD COLUMN k TEXT`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_mints_type ON mints(type, status)`,
|
||||
];
|
||||
@@ -0,0 +1,124 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Db, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
/**
|
||||
* better-sqlite3 behind the async Db interface.
|
||||
*
|
||||
* Every statement runs synchronously; the promises are already settled by the time
|
||||
* they are returned. Prepared statements are cached by SQL text, which the callers
|
||||
* rely on: they pass constant strings and would otherwise re-prepare on every request.
|
||||
*
|
||||
* The one subtlety is transactions. better-sqlite3's own `db.transaction()` cannot
|
||||
* wrap an async function, so this driver issues BEGIN/COMMIT itself — and that opens a
|
||||
* window the synchronous version did not have, where an unrelated `await`ing caller
|
||||
* could slip a statement between them and have it committed, or rolled back, with the
|
||||
* transaction. `#openTx` closes the window: statements issued through the Db while a
|
||||
* transaction is open queue behind it, and only the handle passed to the transaction
|
||||
* body bypasses the queue. Transactions themselves are serialized for the same reason.
|
||||
*/
|
||||
export class SqliteDb implements Db {
|
||||
readonly dialect = 'sqlite' as const;
|
||||
readonly label: string;
|
||||
|
||||
#db: Database.Database;
|
||||
#statements = new Map<string, Database.Statement>();
|
||||
/** Resolves when the transaction in flight settles. Null when none is open. */
|
||||
#openTx: Promise<unknown> | null = null;
|
||||
|
||||
constructor(file: string) {
|
||||
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
||||
|
||||
this.#db = new Database(file);
|
||||
this.#db.pragma('journal_mode = WAL');
|
||||
this.#db.pragma('synchronous = NORMAL');
|
||||
this.#db.pragma('busy_timeout = 5000');
|
||||
this.label = `sqlite:${file}`;
|
||||
}
|
||||
|
||||
#prepare(sql: string): Database.Statement {
|
||||
let stmt = this.#statements.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = this.#db.prepare(sql);
|
||||
this.#statements.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/** The synchronous core, shared by the Db surface and the transaction handle. */
|
||||
#direct: Sql = {
|
||||
all: <T>(sql: string, ...params: Param[]): Promise<T[]> =>
|
||||
Promise.resolve(this.#prepare(sql).all(...params) as T[]),
|
||||
get: <T>(sql: string, ...params: Param[]): Promise<T | undefined> =>
|
||||
Promise.resolve(this.#prepare(sql).get(...params) as T | undefined),
|
||||
run: (sql: string, ...params: Param[]): Promise<RunResult> =>
|
||||
Promise.resolve({ changes: this.#prepare(sql).run(...params).changes }),
|
||||
};
|
||||
|
||||
/** Wait out any open transaction, so a statement never lands inside someone else's. */
|
||||
async #settled(): Promise<void> {
|
||||
while (this.#openTx) await this.#openTx.catch(() => undefined);
|
||||
}
|
||||
|
||||
async all<T>(sql: string, ...params: Param[]): Promise<T[]> {
|
||||
await this.#settled();
|
||||
return this.#direct.all<T>(sql, ...params);
|
||||
}
|
||||
|
||||
async get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
|
||||
await this.#settled();
|
||||
return this.#direct.get<T>(sql, ...params);
|
||||
}
|
||||
|
||||
async run(sql: string, ...params: Param[]): Promise<RunResult> {
|
||||
await this.#settled();
|
||||
return this.#direct.run(sql, ...params);
|
||||
}
|
||||
|
||||
exec(statements: readonly string[]): Promise<void> {
|
||||
for (const sql of statements) this.#db.exec(sql);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T> {
|
||||
// Claim the slot synchronously, before the first await: two transactions started in
|
||||
// the same tick would otherwise both find no transaction open and issue a nested
|
||||
// BEGIN, which SQLite rejects outright.
|
||||
const previous = this.#openTx;
|
||||
|
||||
const work = (async (): Promise<T> => {
|
||||
if (previous) await previous.catch(() => undefined);
|
||||
|
||||
// IMMEDIATE takes the write lock up front rather than on the first write, so two
|
||||
// writers fail fast against busy_timeout instead of deadlocking mid-transaction.
|
||||
this.#db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = await fn(this.#direct);
|
||||
this.#db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
this.#db.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Already rolled back by SQLite. The original error is the one that matters.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
this.#openTx = work;
|
||||
|
||||
return work.finally(() => {
|
||||
// Only the last transaction in the chain clears the slot; anything queued behind
|
||||
// this one has already replaced it.
|
||||
if (this.#openTx === work) this.#openTx = null;
|
||||
});
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#statements.clear();
|
||||
this.#db.close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,125 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.ts';
|
||||
import { config, type DbConfig } from './config.ts';
|
||||
import type { Db } from './db-driver.ts';
|
||||
import { PostgresDb } from './db-postgres.ts';
|
||||
import { SCHEMA, SCHEMA_UPGRADES } from './db-schema.ts';
|
||||
import { SqliteDb } from './db-sqlite.ts';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS mints (
|
||||
url TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
icon_url TEXT,
|
||||
icon_file TEXT,
|
||||
pubkey TEXT,
|
||||
info_json TEXT,
|
||||
nuts_json TEXT,
|
||||
version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
consecutive_fails INTEGER NOT NULL DEFAULT 0,
|
||||
last_online INTEGER,
|
||||
last_probe INTEGER,
|
||||
first_seen INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
|
||||
export type { Db, Dialect, Param, RunResult, Sql } from './db-driver.ts';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
mint_url TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
rating INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
|
||||
/**
|
||||
* Open a connection and make sure the schema is there.
|
||||
*
|
||||
* Both backends create their tables on first use, so pointing the API at an empty
|
||||
* Postgres database is the whole setup: no separate migration step to forget.
|
||||
*/
|
||||
export async function openDb(target: DbConfig = config.db): Promise<Db> {
|
||||
const db: Db =
|
||||
target.dialect === 'postgres'
|
||||
? new PostgresDb(target.url, target.poolMax)
|
||||
: new SqliteDb(target.file);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS probes (
|
||||
mint_url TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
|
||||
try {
|
||||
await db.exec(SCHEMA);
|
||||
await applySchemaUpgrades(db);
|
||||
} catch (err) {
|
||||
await db.close().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
let db: DB | null = null;
|
||||
|
||||
export function getDb(): DB {
|
||||
if (db) return db;
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
fs.mkdirSync(config.iconDir, { recursive: true });
|
||||
|
||||
const handle = new Database(config.dbPath);
|
||||
handle.pragma('journal_mode = WAL');
|
||||
handle.pragma('synchronous = NORMAL');
|
||||
handle.pragma('busy_timeout = 5000');
|
||||
handle.exec(SCHEMA);
|
||||
|
||||
db = handle;
|
||||
return handle;
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
db?.close();
|
||||
db = null;
|
||||
/**
|
||||
* Bring an existing database up to the current schema.
|
||||
*
|
||||
* `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a
|
||||
* deployment seeded before federations were indexed would otherwise boot against a
|
||||
* `mints` table with no `type` column and fail on the first query. Running these here
|
||||
* means an upgrade is a restart, with no separate step to forget.
|
||||
*
|
||||
* An already-present column is the expected outcome on every boot after the first, so
|
||||
* that error is swallowed and anything else is re-thrown. Both backends are matched on
|
||||
* the message rather than on a code: SQLite raises `duplicate column name: type` and
|
||||
* Postgres `column "type" of relation "mints" already exists`, and neither exposes
|
||||
* anything more structured through the drivers here.
|
||||
*/
|
||||
async function applySchemaUpgrades(db: Db): Promise<void> {
|
||||
for (const statement of SCHEMA_UPGRADES) {
|
||||
try {
|
||||
await db.exec([statement]);
|
||||
} catch (err) {
|
||||
/*
|
||||
* Postgres localizes its message text (lc_messages), so a German server says
|
||||
* neither phrase below and every boot after the first would re-throw. Its error
|
||||
* codes are locale-proof: 42701 duplicate_column, 42P07 duplicate_table. SQLite
|
||||
* has no code for this, so its English message is still matched.
|
||||
*/
|
||||
const code = (err as { code?: unknown }).code;
|
||||
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
const alreadyThere =
|
||||
code === '42701' ||
|
||||
code === '42P07' ||
|
||||
message.includes('duplicate column') ||
|
||||
message.includes('already exists');
|
||||
if (!alreadyThere) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getState(key: string): string | null {
|
||||
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
let opening: Promise<Db> | null = null;
|
||||
|
||||
/**
|
||||
* The process-wide connection, opened on first use.
|
||||
*
|
||||
* Async because `pg` is: there is no synchronous way to reach a Postgres server. The
|
||||
* promise is memoized, so concurrent callers during startup share one connection
|
||||
* rather than racing to open several.
|
||||
*/
|
||||
export function getDb(): Promise<Db> {
|
||||
if (!opening) {
|
||||
fs.mkdirSync(config.iconDir, { recursive: true });
|
||||
opening = openDb().catch((err: unknown) => {
|
||||
// A failed open must not be cached, or every later call replays the same error
|
||||
// against a connection that was never established.
|
||||
opening = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return opening;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
const pending = opening;
|
||||
opening = null;
|
||||
if (pending) await pending.then((db) => db.close()).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function getState(key: string): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ value: string }>('SELECT value FROM state WHERE key = ?', key);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setState(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.run(key, value);
|
||||
export async function setState(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
'INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
export function getStateNumber(key: string): number | null {
|
||||
const raw = getState(key);
|
||||
export async function getStateNumber(key: string): Promise<number | null> {
|
||||
const raw = await getState(key);
|
||||
if (raw === null) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Delete probe rows older than 90 days. Called once a day. */
|
||||
export function pruneProbes(): number {
|
||||
export async function pruneProbes(): Promise<number> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
|
||||
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
|
||||
const db = await getDb();
|
||||
return (await db.run('DELETE FROM probes WHERE ts < ?', cutoff)).changes;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The only check this site can actually run against a Fedimint federation.
|
||||
*
|
||||
* A Cashu mint answers `GET /v1/info` over plain HTTPS, which is why probing one is
|
||||
* fifteen lines. A federation has no such endpoint. Its guardians speak a JSON-RPC
|
||||
* dialect over websockets, their addresses are bech32m-encoded inside the invite code,
|
||||
* and confirming one is up means decoding that code, opening a socket to a quorum of
|
||||
* guardians and agreeing a consensus session with them. That is a Fedimint client, not
|
||||
* a probe, and writing half of one here would produce a status less trustworthy than
|
||||
* saying nothing.
|
||||
*
|
||||
* What exists instead is fedimint.observer, which already keeps those client
|
||||
* connections open and publishes the result:
|
||||
*
|
||||
* GET https://observer.fedimint.org/api/federations
|
||||
* [{ "id": "<64 hex>", "name": "...", "invite": "fed11...", "health": "online" }, ...]
|
||||
*
|
||||
* So this is a real check, and it is somebody else's. Both halves of that matter and
|
||||
* both are recorded: a federation whose status came from here carries
|
||||
* `status_source: "fedimint.observer"` and its page prints that beside the status,
|
||||
* rather than implying this site opened a socket. A federation the observer does not
|
||||
* track gets `announced` — Nostr says it exists, nothing says it runs — and never
|
||||
* `online`, never `offline`, and never a made-up uptime figure.
|
||||
*
|
||||
* When the observer itself is unreachable, nothing is written. A federation confirmed
|
||||
* up an hour ago is not demoted because a third party had a bad minute; the row keeps
|
||||
* its last real answer and `last_probe` is left alone so `/api/health` can still see
|
||||
* that the cycle happened.
|
||||
*/
|
||||
import { log } from './log.ts';
|
||||
|
||||
/** Where the health data comes from, recorded on every row it decides. */
|
||||
export const OBSERVER_SOURCE = 'fedimint.observer';
|
||||
|
||||
/** Empty disables the lookup entirely: every federation then stays `announced`. */
|
||||
export const OBSERVER_URL =
|
||||
process.env['FEDIMINT_OBSERVER_URL'] ?? 'https://observer.fedimint.org/api/federations';
|
||||
|
||||
/** One request for every federation, so it gets longer than a single mint probe does. */
|
||||
const OBSERVER_TIMEOUT_MS = 12_000;
|
||||
|
||||
export interface ObserverFederation {
|
||||
id: string;
|
||||
name: string | null;
|
||||
/** `online` or `offline` as published. Anything else is treated as unknown. */
|
||||
health: 'online' | 'offline' | null;
|
||||
}
|
||||
|
||||
/** Federation id (lowercase hex) to what the observer says about it. */
|
||||
export type ObserverIndex = Map<string, ObserverFederation>;
|
||||
|
||||
function readHealth(value: unknown): 'online' | 'offline' | null {
|
||||
if (value === 'online') return 'online';
|
||||
if (value === 'offline') return 'offline';
|
||||
// Anything else is a word this build has no meaning for, and guessing at it is
|
||||
// exactly what "no fake statuses" rules out.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the observer about every federation it tracks.
|
||||
*
|
||||
* Returns null when the lookup failed, which is deliberately distinguishable from an
|
||||
* empty map: an empty map means the observer answered and tracks nothing, and the
|
||||
* caller writes `announced` everywhere; null means it did not answer, and the caller
|
||||
* writes nothing at all.
|
||||
*/
|
||||
export async function fetchObserverIndex(userAgent: string): Promise<ObserverIndex | null> {
|
||||
if (!OBSERVER_URL.trim()) return new Map();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OBSERVER_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(OBSERVER_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': userAgent },
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const body: unknown = await res.json();
|
||||
if (!Array.isArray(body)) throw new Error('not a JSON array');
|
||||
|
||||
const index: ObserverIndex = new Map();
|
||||
for (const entry of body) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const row = entry as Record<string, unknown>;
|
||||
const id = typeof row['id'] === 'string' ? row['id'].toLowerCase() : '';
|
||||
if (!/^[0-9a-f]{64}$/.test(id)) continue;
|
||||
index.set(id, {
|
||||
id,
|
||||
name: typeof row['name'] === 'string' ? row['name'] : null,
|
||||
health: readHealth(row['health']),
|
||||
});
|
||||
}
|
||||
|
||||
return index;
|
||||
} catch (err) {
|
||||
log.warn('fedimint observer unreachable', {
|
||||
url: OBSERVER_URL,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Read a response body up to `maxBytes`, or refuse it.
|
||||
*
|
||||
* `res.arrayBuffer()` and `res.json()` buffer however much the server sends before any
|
||||
* size check can run, and a mint is an untrusted server: the abort timer bounds how
|
||||
* *long* a read may take, this bounds how *large* it may get, and both are needed. The
|
||||
* Content-Length header is checked first as a courtesy — a chunked or lying response
|
||||
* still hits the streaming cap.
|
||||
*
|
||||
* Returns null when the body is over the limit or unreadable. The caller treats that
|
||||
* exactly like a failed fetch.
|
||||
*/
|
||||
export async function readBodyBounded(res: Response, maxBytes: number): Promise<Buffer | null> {
|
||||
const declared = Number(res.headers.get('content-length') ?? '');
|
||||
if (Number.isFinite(declared) && declared > maxBytes) return null;
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return null;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
// Aborted by the caller's timer, or the connection died mid-body.
|
||||
return null;
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
if (removed > 0) log.info('pruned probes', { rows: removed });
|
||||
void pruneProbes()
|
||||
.then((removed) => {
|
||||
if (removed > 0) log.info('pruned probes', { rows: removed });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log.error('prune failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}, DAY_MS);
|
||||
|
||||
// Startup: probe what we already know, then a full discovery backfill so a fresh
|
||||
@@ -74,17 +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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,6 +25,71 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
|
||||
return fails < 3 ? 'degraded' : 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest /v1/info accepted. Real payloads are a few KB; the whole document is
|
||||
* stored in `info_json` and served back on every detail request, so an unbounded one
|
||||
* is both a memory spike here and a bloated row forever after.
|
||||
*/
|
||||
const MAX_INFO_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Write everything a successful Cashu probe learned, and record the probe.
|
||||
*
|
||||
* 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 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);
|
||||
|
||||
await db.run(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
icon_url = ?,
|
||||
icon_file = ?,
|
||||
pubkey = COALESCE(?, pubkey),
|
||||
info_json = ?,
|
||||
nuts_json = ?,
|
||||
version = COALESCE(?, version),
|
||||
status = 'online',
|
||||
consecutive_fails = 0,
|
||||
last_online = ?,
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
info.name ?? null,
|
||||
info.description ?? null,
|
||||
info.icon_url ?? null,
|
||||
iconFile,
|
||||
info.pubkey ?? null,
|
||||
JSON.stringify(info),
|
||||
JSON.stringify(nuts),
|
||||
info.version ?? null,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
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();
|
||||
@@ -32,7 +103,10 @@ async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: numb
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const info = (await res.json()) as MintInfo;
|
||||
const body = await readBodyBounded(res, MAX_INFO_BYTES);
|
||||
if (!body) throw new Error(`info larger than ${MAX_INFO_BYTES} bytes or unreadable`);
|
||||
|
||||
const info = JSON.parse(body.toString('utf8')) as MintInfo;
|
||||
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
|
||||
|
||||
return { info, latencyMs: Date.now() - started };
|
||||
@@ -48,51 +122,12 @@ async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: numb
|
||||
* what lets an offline mint still render its full page, which is acceptance criterion #1.
|
||||
*/
|
||||
export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
const db = getDb();
|
||||
const db = await getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
const { info, latencyMs } = await fetchInfo(row.url);
|
||||
const nuts = parseNuts(info.nuts);
|
||||
|
||||
const iconFile = await cacheIcon(row, info.icon_url ?? null);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE mints SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
icon_url = ?,
|
||||
icon_file = ?,
|
||||
pubkey = COALESCE(?, pubkey),
|
||||
info_json = ?,
|
||||
nuts_json = ?,
|
||||
version = COALESCE(?, version),
|
||||
status = 'online',
|
||||
consecutive_fails = 0,
|
||||
last_online = ?,
|
||||
last_probe = ?,
|
||||
updated_at = ?
|
||||
WHERE url = ?`,
|
||||
).run(
|
||||
info.name ?? null,
|
||||
info.description ?? null,
|
||||
info.icon_url ?? null,
|
||||
iconFile,
|
||||
info.pubkey ?? null,
|
||||
JSON.stringify(info),
|
||||
JSON.stringify(nuts),
|
||||
info.version ?? null,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
row.url,
|
||||
);
|
||||
|
||||
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)').run(
|
||||
row.url,
|
||||
now,
|
||||
latencyMs,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT mint_url,
|
||||
COUNT(*) AS review_count,
|
||||
AVG(rating) AS rating_avg,
|
||||
MAX(created_at) AS last_review_at
|
||||
FROM latest GROUP BY mint_url`,
|
||||
)
|
||||
.all() as AggRow[];
|
||||
/**
|
||||
* Review counts and averages, for one mint or for all of them.
|
||||
*
|
||||
* A detail page passes its own URL. Computing the whole table to read one row off it
|
||||
* is free on a local SQLite file and is not on a Postgres server across a socket.
|
||||
*/
|
||||
async function aggregates(mintUrl?: string): Promise<Map<string, AggRow>> {
|
||||
const db = await getDb();
|
||||
const where = mintUrl === undefined ? '' : 'WHERE mint_url = ?';
|
||||
const params = mintUrl === undefined ? [] : [mintUrl];
|
||||
|
||||
const rows = await db.all<AggRow>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT mint_url,
|
||||
COUNT(*) AS review_count,
|
||||
AVG(rating) AS rating_avg,
|
||||
MAX(created_at) AS last_review_at
|
||||
FROM latest ${where} GROUP BY mint_url`,
|
||||
...params,
|
||||
);
|
||||
|
||||
return new Map(rows.map((r) => [r.mint_url, r]));
|
||||
}
|
||||
@@ -64,7 +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(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT rating, COUNT(*) AS n FROM latest
|
||||
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
|
||||
)
|
||||
.all(mintUrl) as { rating: number; n: number }[];
|
||||
async function distribution(mintUrl: string): Promise<RatingDistribution> {
|
||||
const db = await getDb();
|
||||
const rows = await db.all<{ rating: number; n: number }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT rating, COUNT(*) AS n FROM latest
|
||||
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
|
||||
mintUrl,
|
||||
);
|
||||
|
||||
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
|
||||
for (const r of rows) {
|
||||
@@ -142,34 +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(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
|
||||
)
|
||||
.get(mintUrl, cutoff) as { n: number };
|
||||
return row.n;
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ n: number }>(
|
||||
`WITH latest AS (${LATEST_REVIEWS})
|
||||
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
|
||||
mintUrl,
|
||||
cutoff,
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
function uptime30d(mintUrl: string): number | null {
|
||||
async function uptime30d(mintUrl: string): Promise<number | null> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
|
||||
const row = getDb()
|
||||
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
|
||||
.get(mintUrl, cutoff) as { up: number | null; n: number };
|
||||
const db = await getDb();
|
||||
const row = await db.get<{ up: number | null; n: number }>(
|
||||
'SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?',
|
||||
mintUrl,
|
||||
cutoff,
|
||||
);
|
||||
|
||||
if (!row.n || row.up === null) return null;
|
||||
if (!row?.n || row.up === null) return null;
|
||||
return Math.round(row.up * 1000) / 1000;
|
||||
}
|
||||
|
||||
function recentProbes(mintUrl: string): ProbeSample[] {
|
||||
async function recentProbes(mintUrl: string): Promise<ProbeSample[]> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
|
||||
return getDb()
|
||||
.prepare(
|
||||
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
|
||||
)
|
||||
.all(mintUrl, cutoff) as ProbeSample[];
|
||||
const db = await getDb();
|
||||
return db.all<ProbeSample>(
|
||||
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
|
||||
mintUrl,
|
||||
cutoff,
|
||||
);
|
||||
}
|
||||
|
||||
function parseInfo(json: string | null): MintInfo | null {
|
||||
@@ -181,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(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'online') AS online,
|
||||
COUNT(*) FILTER (WHERE status = 'offline') AS offline,
|
||||
COUNT(*) FILTER (WHERE status = 'degraded') AS degraded
|
||||
FROM mints WHERE type = 'cashu'`,
|
||||
),
|
||||
db.get<{ total: number; online: number; offline: number; announced: number }>(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'online') AS online,
|
||||
COUNT(*) FILTER (WHERE status = 'offline') AS offline,
|
||||
COUNT(*) FILTER (WHERE status = 'announced') AS announced
|
||||
FROM mints WHERE type = 'fedimint'`,
|
||||
),
|
||||
db.get<{ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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/…
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,9 @@ importers:
|
||||
nostr-tools:
|
||||
specifier: ^2.10.4
|
||||
version: 2.24.3(typescript@5.9.3)
|
||||
pg:
|
||||
specifier: ^8.13
|
||||
version: 8.23.0
|
||||
devDependencies:
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.12
|
||||
@@ -32,6 +35,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.20.1
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
@@ -60,9 +66,15 @@ importers:
|
||||
'@astrojs/check':
|
||||
specifier: ^0.9.4
|
||||
version: 0.9.10(prettier@3.9.6)(typescript@5.9.3)
|
||||
'@resvg/resvg-js':
|
||||
specifier: ^2.6.2
|
||||
version: 2.6.2
|
||||
boneyard-js:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))
|
||||
satori:
|
||||
specifier: ^0.33.0
|
||||
version: 0.33.0
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
@@ -634,6 +646,82 @@ packages:
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@resvg/resvg-js@2.6.2':
|
||||
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@rollup/pluginutils@5.4.0':
|
||||
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -798,6 +886,11 @@ packages:
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||
|
||||
@@ -822,6 +915,9 @@ packages:
|
||||
'@types/node@22.20.1':
|
||||
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@@ -919,6 +1015,10 @@ packages:
|
||||
base-64@1.0.0:
|
||||
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||
|
||||
base64-js@0.0.8:
|
||||
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -972,6 +1072,9 @@ packages:
|
||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
camelize@1.0.1:
|
||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -1015,6 +1118,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
@@ -1035,9 +1141,26 @@ packages:
|
||||
crossws@0.3.5:
|
||||
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
|
||||
|
||||
css-background-parser@0.1.0:
|
||||
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
|
||||
|
||||
css-box-shadow@1.0.0-3:
|
||||
resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
|
||||
|
||||
css-color-keywords@1.0.0:
|
||||
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
css-gradient-parser@0.0.17:
|
||||
resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
|
||||
|
||||
css-tree@2.2.1:
|
||||
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
@@ -1130,6 +1253,10 @@ packages:
|
||||
emmet@2.4.11:
|
||||
resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==}
|
||||
|
||||
emoji-regex-xs@2.0.1:
|
||||
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
@@ -1164,6 +1291,9 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
escape-string-regexp@5.0.0:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1199,6 +1329,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.7.3:
|
||||
resolution: {integrity: sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
@@ -1243,6 +1376,9 @@ packages:
|
||||
h3@1.15.11:
|
||||
resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==}
|
||||
|
||||
harfbuzzjs@0.10.0:
|
||||
resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
|
||||
|
||||
@@ -1273,6 +1409,10 @@ packages:
|
||||
hastscript@9.0.1:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
hex-rgb@4.3.0:
|
||||
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
hono@4.13.3:
|
||||
resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
@@ -1344,6 +1484,9 @@ packages:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
linebreak@1.1.0:
|
||||
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -1582,6 +1725,12 @@ packages:
|
||||
package-manager-detector@1.8.0:
|
||||
resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
|
||||
|
||||
pako@0.2.9:
|
||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||
|
||||
parse-css-color@0.2.1:
|
||||
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
|
||||
|
||||
parse-latin@7.0.0:
|
||||
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
|
||||
|
||||
@@ -1591,6 +1740,40 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
||||
|
||||
pg-connection-string@2.14.0:
|
||||
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
|
||||
|
||||
pg-int8@1.0.1:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pg-pool@3.14.0:
|
||||
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
pg-protocol@1.16.0:
|
||||
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
|
||||
|
||||
pg-types@2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pg@8.23.0:
|
||||
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
piccolore@0.1.3:
|
||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||
|
||||
@@ -1615,10 +1798,29 @@ packages:
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.5.26:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postgres-bytea@1.0.1:
|
||||
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-date@1.0.7:
|
||||
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1733,6 +1935,10 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
satori@0.33.0:
|
||||
resolution: {integrity: sha512-yKmqoWcwSV5VPSrLa5huaH90Uszptnl0gU4Nwfg/bneQZ8j8Dd68n0124mN/7ZSJiKWV9VOeCscbLsBm4LHJGQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
@@ -1769,6 +1975,10 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1781,6 +1991,9 @@ packages:
|
||||
resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
string.prototype.codepointat@0.2.1:
|
||||
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -1876,6 +2089,9 @@ packages:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -2151,6 +2367,10 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
xxhash-wasm@1.1.0:
|
||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||
|
||||
@@ -2196,6 +2416,9 @@ packages:
|
||||
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zod-to-json-schema@3.25.2:
|
||||
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
|
||||
peerDependencies:
|
||||
@@ -2621,6 +2844,57 @@ snapshots:
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@resvg/resvg-js-android-arm-eabi@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-android-arm64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-darwin-arm64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-darwin-x64@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-linux-x64-musl@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
|
||||
optional: true
|
||||
|
||||
'@resvg/resvg-js@2.6.2':
|
||||
optionalDependencies:
|
||||
'@resvg/resvg-js-android-arm-eabi': 2.6.2
|
||||
'@resvg/resvg-js-android-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-arm64': 2.6.2
|
||||
'@resvg/resvg-js-darwin-x64': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
|
||||
'@resvg/resvg-js-linux-x64-musl': 2.6.2
|
||||
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
|
||||
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
|
||||
|
||||
'@rollup/pluginutils@5.4.0(rollup@4.62.4)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -2750,6 +3024,11 @@ snapshots:
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
dependencies:
|
||||
fflate: 0.7.3
|
||||
string.prototype.codepointat: 0.2.1
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
@@ -2778,6 +3057,12 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@ungap/structured-clone@1.3.3': {}
|
||||
@@ -2978,6 +3263,8 @@ snapshots:
|
||||
|
||||
base-64@1.0.0: {}
|
||||
|
||||
base64-js@0.0.8: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
better-sqlite3@11.10.0:
|
||||
@@ -3022,6 +3309,8 @@ snapshots:
|
||||
|
||||
camelcase@8.0.0: {}
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chalk@5.6.2: {}
|
||||
@@ -3054,6 +3343,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
@@ -3068,6 +3359,14 @@ snapshots:
|
||||
dependencies:
|
||||
uncrypto: 0.1.3
|
||||
|
||||
css-background-parser@0.1.0: {}
|
||||
|
||||
css-box-shadow@1.0.0-3: {}
|
||||
|
||||
css-color-keywords@1.0.0: {}
|
||||
|
||||
css-gradient-parser@0.0.17: {}
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
@@ -3076,6 +3375,12 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-to-react-native@3.2.0:
|
||||
dependencies:
|
||||
camelize: 1.0.1
|
||||
css-color-keywords: 1.0.0
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
css-tree@2.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.28
|
||||
@@ -3155,6 +3460,8 @@ snapshots:
|
||||
'@emmetio/abbreviation': 2.3.3
|
||||
'@emmetio/css-abbreviation': 2.1.8
|
||||
|
||||
emoji-regex-xs@2.0.1: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
@@ -3229,6 +3536,8 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
@@ -3251,6 +3560,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.7.3: {}
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
flattie@1.1.1: {}
|
||||
@@ -3291,6 +3602,8 @@ snapshots:
|
||||
ufo: 1.6.4
|
||||
uncrypto: 0.1.3
|
||||
|
||||
harfbuzzjs@0.10.0: {}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
@@ -3378,6 +3691,8 @@ snapshots:
|
||||
property-information: 7.2.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
hex-rgb@4.3.0: {}
|
||||
|
||||
hono@4.13.3: {}
|
||||
|
||||
html-escaper@3.0.3: {}
|
||||
@@ -3424,6 +3739,11 @@ snapshots:
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
linebreak@1.1.0:
|
||||
dependencies:
|
||||
base64-js: 0.0.8
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
lru-cache@11.5.2: {}
|
||||
@@ -3838,6 +4158,13 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.8.0: {}
|
||||
|
||||
pako@0.2.9: {}
|
||||
|
||||
parse-css-color@0.2.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
hex-rgb: 4.3.0
|
||||
|
||||
parse-latin@7.0.0:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
@@ -3853,6 +4180,41 @@ snapshots:
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
optional: true
|
||||
|
||||
pg-connection-string@2.14.0: {}
|
||||
|
||||
pg-int8@1.0.1: {}
|
||||
|
||||
pg-pool@3.14.0(pg@8.23.0):
|
||||
dependencies:
|
||||
pg: 8.23.0
|
||||
|
||||
pg-protocol@1.16.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.1
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pg@8.23.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.14.0
|
||||
pg-pool: 3.14.0(pg@8.23.0)
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.4.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
piccolore@0.1.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -3869,12 +4231,24 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.26:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-bytea@1.0.1: {}
|
||||
|
||||
postgres-date@1.0.7: {}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -4068,6 +4442,22 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
satori@0.33.0:
|
||||
dependencies:
|
||||
'@shuding/opentype.js': 1.4.0-beta.0
|
||||
css-background-parser: 0.1.0
|
||||
css-box-shadow: 1.0.0-3
|
||||
css-gradient-parser: 0.0.17
|
||||
css-to-react-native: 3.2.0
|
||||
emoji-regex-xs: 2.0.1
|
||||
escape-html: 1.0.3
|
||||
fflate: 0.7.3
|
||||
harfbuzzjs: 0.10.0
|
||||
linebreak: 1.1.0
|
||||
parse-css-color: 0.2.1
|
||||
postcss-value-parser: 4.2.0
|
||||
yoga-layout: 3.2.1
|
||||
|
||||
sax@1.6.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
@@ -4131,6 +4521,8 @@ snapshots:
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -4148,6 +4540,8 @@ snapshots:
|
||||
get-east-asian-width: 1.6.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string.prototype.codepointat@0.2.1: {}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -4236,6 +4630,11 @@ snapshots:
|
||||
|
||||
undici@8.10.0: {}
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
dependencies:
|
||||
pako: 0.2.9
|
||||
tiny-inflate: 1.0.3
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -4461,6 +4860,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
xxhash-wasm@1.1.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
@@ -4505,6 +4906,8 @@ snapshots:
|
||||
|
||||
yoctocolors@2.2.0: {}
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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]}` : ''}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 888 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 910 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 10 KiB |
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,46 +135,64 @@ function icoFromPng(png) {
|
||||
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
/*
|
||||
* 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();
|
||||
|
||||
await page.setContent(cardHtml, { waitUntil: 'load' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: path.join(publicDir, 'og.png') });
|
||||
console.log('og.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);
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 512, height: 512 });
|
||||
await page.setContent(iconHtml, { waitUntil: 'load' });
|
||||
const master = await page.screenshot({ type: 'png' });
|
||||
// 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');
|
||||
|
||||
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);
|
||||
}
|
||||
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');
|
||||
|
||||
// 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');
|
||||
// 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');
|
||||
|
||||
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');
|
||||
if (!process.argv.includes('--no-card')) {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
// 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 {
|
||||
await browser.close();
|
||||
await page.setContent(cardHtml, { waitUntil: 'load' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: path.join(publicDir, 'og.png') });
|
||||
console.log('og.png');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)`);
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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>)}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/*
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
/**
|
||||
* The invite codes block on a federation page.
|
||||
*
|
||||
* An invite code is the whole point of the page: it is what a wallet needs to join, it
|
||||
* is 150 to 300 characters long, and it is opaque. So it gets the two affordances that
|
||||
* length demands and nothing else — one click to copy the whole thing, and a QR to
|
||||
* point a phone at.
|
||||
*
|
||||
* Both are prerendered. The code is truncated in the middle by CSS-free string maths
|
||||
* (`shortInviteCode`) rather than by an ellipsis rule, so the tail is visible: two codes
|
||||
* for the same federation differ at the end, and a row that cuts there shows two
|
||||
* identical rows. The QR is encoded at build time like the mint page's, so opening it
|
||||
* costs no JavaScript beyond the toggle itself and works on a page whose island never
|
||||
* loaded — the panel is a `<details>`, which opens on its own.
|
||||
*
|
||||
* Most federations publish exactly one code. Some publish several (different guardian
|
||||
* address sets for the same federation), and every one of them is equally valid, so
|
||||
* they are all listed rather than one being picked.
|
||||
*/
|
||||
import { shortInviteCode } from '@cashumints/shared';
|
||||
import { qrSvg } from '../lib/qr';
|
||||
import { useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
codes: string[];
|
||||
/** Named in the QR's caption, so a screenshot of it says which federation it joins. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const { codes, name } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
/*
|
||||
* Encoded once per code, at build time.
|
||||
*
|
||||
* An invite code is long enough that its QR runs to a high version with small modules,
|
||||
* which is exactly why the frame below is given as much width as the panel has: a
|
||||
* 300 character code at 180px is not readable by a phone camera.
|
||||
*/
|
||||
const rows = codes.map((code) => ({
|
||||
code,
|
||||
short: shortInviteCode(code),
|
||||
qr: qrSvg(code),
|
||||
}));
|
||||
---
|
||||
|
||||
<div class="panel invite-panel" data-reveal>
|
||||
<h2 class="side-title">
|
||||
{t('fedimint.invite.title')}
|
||||
{rows.length > 1 && <span class="invite-count">{t('fedimint.invite.count', { n: rows.length })}</span>}
|
||||
</h2>
|
||||
<p class="invite-note">{t('fedimint.invite.note')}</p>
|
||||
|
||||
{
|
||||
rows.map((row, index) => (
|
||||
<div class="invite">
|
||||
<div class="invite-row">
|
||||
{/*
|
||||
The code is the copy control itself, not a label beside one: it is the only
|
||||
thing on this page anybody copies, and hiding that behind a small icon
|
||||
button would be making the primary action the least visible.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
class="cid invite-code"
|
||||
data-copy-id={row.code}
|
||||
title={row.code}
|
||||
aria-label={t('common.copyFull', { kind: t('fedimint.invite.kind') })}
|
||||
>
|
||||
<span class="cid-text mono">{row.short}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="invite-qr-btn"
|
||||
data-invite-qr={index}
|
||||
aria-expanded="false"
|
||||
aria-controls={`invite-qr-${index}`}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="5" height="5" rx="1" /><rect x="16" y="3" width="5" height="5" rx="1" />
|
||||
<rect x="3" y="16" width="5" height="5" rx="1" />
|
||||
<path d="M21 16h-3a2 2 0 0 0-2 2v3M21 21v.01M12 7v3a2 2 0 0 1-2 2H7M12 3h.01M12 16v.01M16 12h1M21 12v.01M12 21v-1" />
|
||||
</svg>
|
||||
<span>{t('fedimint.invite.qr')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Hidden rather than absent, so the toggle has something to reveal without a
|
||||
round trip and the code is in the markup for anything that reads it.
|
||||
*/}
|
||||
<div class="invite-qr" id={`invite-qr-${index}`} hidden>
|
||||
<div class="qr-frame">
|
||||
<Fragment set:html={row.qr} />
|
||||
</div>
|
||||
<p class="invite-qr-cap">{t('fedimint.invite.qrCaption', { name })}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.invite-panel { padding: 20px; }
|
||||
.side-title { display: flex; align-items: baseline; gap: 8px; }
|
||||
.invite-count { font-family: var(--mono); font-size: 11px; color: var(--faint); letter-spacing: 0; text-transform: none; }
|
||||
.invite-note { font-size: 12.5px; color: var(--muted); line-height: 1.55; margin-bottom: 12px; }
|
||||
|
||||
.invite + .invite { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.invite-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* The code fills the row and the QR button keeps its own width beside it. */
|
||||
.invite-code {
|
||||
flex: 1; min-width: 0; justify-content: flex-start;
|
||||
background: var(--card2); border: 1px solid var(--line-soft); border-radius: 9px;
|
||||
padding: 9px 11px; font-size: 12.5px; color: var(--text);
|
||||
}
|
||||
.invite-code:hover { color: var(--amber); border-color: #38342A; }
|
||||
.invite-code .cid-text { overflow-wrap: anywhere; }
|
||||
|
||||
.invite-qr-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
|
||||
font-family: var(--body); font-size: 12.5px; font-weight: 500; color: var(--muted);
|
||||
background: none; border: 1px solid var(--line); border-radius: 9px;
|
||||
padding: 9px 12px; cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.invite-qr-btn:hover { color: var(--text); border-color: #38342A; }
|
||||
.invite-qr-btn[aria-expanded='true'] { color: var(--amber); border-color: var(--amber-dim); }
|
||||
|
||||
.invite-qr { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
/* `display: flex` above outranks the `hidden` attribute's own `display: none`, so
|
||||
the code opened on page load until this said otherwise. */
|
||||
.invite-qr[hidden] { display: none; }
|
||||
/*
|
||||
* The white plate is the quiet zone a camera needs, so it stays white in every theme.
|
||||
* Wider than the mint page's 236px: an invite code encodes to a much denser code, and
|
||||
* at that size the modules are too small for a phone to resolve.
|
||||
*/
|
||||
.invite-qr .qr-frame {
|
||||
background: #FFFFFF; border-radius: 12px; padding: 12px;
|
||||
width: 100%; max-width: 300px; aspect-ratio: 1; box-sizing: border-box;
|
||||
}
|
||||
.invite-qr .qr-frame :global(svg) { display: block; }
|
||||
.invite-qr-cap { font-size: 11.5px; color: var(--faint); text-align: center; }
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Two controls on one row stop fitting; the code keeps the full width. */
|
||||
.invite-row { flex-wrap: wrap; }
|
||||
.invite-qr-btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../lib/client';
|
||||
import { onReady, prefersReducedMotion } from '../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
// The codes are ordinary copyable identifiers; one delegated handler drives every
|
||||
// one of them, the same one the npubs on a review card use.
|
||||
wireCopyableIds();
|
||||
|
||||
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-invite-qr]')) {
|
||||
if (button.dataset['qrWired']) continue;
|
||||
button.dataset['qrWired'] = '1';
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const panel = document.getElementById(button.getAttribute('aria-controls') ?? '');
|
||||
if (!panel) return;
|
||||
const open = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!open));
|
||||
panel.hidden = open;
|
||||
// The code arrives rather than appearing, which reads as a response to the
|
||||
// press. Nothing moves under it: the panel is the last thing in its block.
|
||||
if (!open && !prefersReducedMotion()) {
|
||||
panel.classList.add('appearing');
|
||||
panel.addEventListener('animationend', () => panel.classList.remove('appearing'), { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,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,57 +50,59 @@ 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>
|
||||
<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. */}
|
||||
<span class="sr-only">{t('lang.label')}</span>
|
||||
<span class="lang-code">{currentCode}</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>
|
||||
<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 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-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>
|
||||
|
||||
<nav class="lang-list" aria-label={t('lang.switcher')}>
|
||||
{
|
||||
links.map((other) => (
|
||||
<a
|
||||
href={other.href}
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
>
|
||||
<span>{other.label}</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" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</details>
|
||||
) : (
|
||||
<nav class="lang-switch" aria-label={t('lang.switcher')}>
|
||||
<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, and
|
||||
* the weight is what tells everyone else.
|
||||
* `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
|
||||
@@ -101,18 +110,24 @@ const currentCode = locale.toUpperCase();
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
data-lang-option
|
||||
data-search={`${other.label} ${other.code}`}
|
||||
>
|
||||
{other.label}
|
||||
<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" />
|
||||
</svg>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,13 +207,20 @@ const bar =
|
||||
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
|
||||
<span class="last">
|
||||
{
|
||||
offline
|
||||
? mint.last_online
|
||||
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('card.neverSeen')
|
||||
: mint.last_review_at
|
||||
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||
: t('card.noReviews')
|
||||
/*
|
||||
"Announced" has no "last seen" to print, and printing one anyway is exactly
|
||||
the fake status this site refuses to show. What it has instead is the fact
|
||||
that nothing has checked it, said in as many words.
|
||||
*/
|
||||
announced
|
||||
? t('card.notChecked')
|
||||
: offline
|
||||
? mint.last_online
|
||||
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('card.neverSeen')
|
||||
: mint.last_review_at
|
||||
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||
: t('card.noReviews')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||