6 Commits
57 changed files with 209 additions and 2595 deletions
-13
View File
@@ -96,19 +96,6 @@ SEO_PRODUCT_JSONLD=1
# 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
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/
.astro/
api/data/
deploy/
*.log
.DS_Store
.env
+35 -448
View File
@@ -52,15 +52,9 @@ rating encoding, and the bugs this rebuild fixes.
## Requirements
- Node 20.18 or newer to build and to run what a build produces
- Node 22.18 or newer to *develop*: `pnpm dev`, `pnpm seed` and the `api` test scripts
run `src/*.ts` through node directly, which needs native type stripping
- Node 22.18 or newer (native TypeScript type stripping, so no build step for the API)
- pnpm 9 or newer
`engines.node` is the first of those, not the second, on purpose: it is the floor a
deployment has to clear, and a production host should never be told it needs a newer
Node than the compiled service actually runs on.
## Setup
```bash
@@ -292,56 +286,7 @@ pnpm typecheck
pnpm build
```
Three packages in order, and the order is a dependency chain rather than a habit:
`shared` emits the types and the warning copy both other packages import, `api` compiles
`api/src` to `api/dist`, and `web` prerenders against a running API.
Output lands in `api/dist/` and `web/dist/`.
### Live lists
The prerendered mint list is a snapshot of what `GET /api/mints` said when the build ran.
It used to stay that until the next build, which is why there was a nightly timer: a mint
indexed at noon was reviewable at once — the 404 resolver saw to that — and had no card on
`/mints` until 03:30, beside cards whose ratings and statuses were equally old.
`/mints`, `/fedimints`, `/lnurl-mints` and the home page's three top-six strips now refetch
that endpoint once, after paint, and rebuild their grids from the answer. One request per
page, no relays involved: card counts have always come from the API's ingested review
aggregates, and review *bodies* remain a mint page and `/reviews` concern.
**The prerendered cards stay.** They are the first paint, they are what a crawler indexes,
and they are the whole page for a reader with no JavaScript — `<noscript>` already unhides
them, and the search, sort and filter controls act on whatever is in the DOM. Hydration
only ever replaces them with something newer, and never with nothing:
- a failed fetch does nothing at all, silently — a grid that is correct as of the last
build is a far better answer to a flaky network than an error about a list already on
screen;
- an API answering `[]` also does nothing. Serving an empty index is the failure the
[build gate](#rebuilds) exists to catch, and a page that rendered it as "no mints" would
be that bug wearing a different hat;
- whatever the reader had already set — a search they typed, a sort they picked, "hide
offline" — is re-applied to the new cards, so a refresh landing mid-interaction cannot
undo it.
Two pieces make it work. `web/src/lib/mint-cards.ts` is `MintCard.astro`'s browser twin,
the same relationship `review-cards.ts` has with the reviews panel: identical classes and
identical `data-*` attributes, because the sort keys, the search fields, the rank chips and
the shared-element view transitions are all read off the DOM. And `MintListItem` carries
the facts a chip is drawn from — `nuts`, `capabilities`, and the LNURL withdraw ceiling and
funding flag. Those replaced an N+1: `/mints` and `/lnurl-mints` each used to fetch
`GET /api/mints/:host` once per mint at build time to read two booleans off it, which is
tolerable on a build machine and unthinkable in every visitor's browser.
Strings come from the page's own inlined catalog, so a hydrated Spanish card says "En
línea", "54 reseñas" and "4,9", and its link is `/es/mint/…`. The one thing hydration
cannot improve is the home page's sentiment bars: the build gives those six cards real
rating distributions from a per-mint detail fetch, and the list payload has no
distribution in it, so a refreshed card falls back to the rating proxy the index pages
have always used.
Every page is prerendered once per language, so ~55 mints
Output lands in `web/dist/`. Every page is prerendered once per language, so ~55 mints
and 9 static routes come out as ~200 pages, each with real titles, meta descriptions,
OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full hreflang
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
@@ -630,7 +575,6 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
| `DB_POOL_MAX` | `10` | Postgres connections held open. Unused by SQLite. |
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
| `BACKFILL_MIN_EVENTS` | `200` | Events a backfill has to read before it counts as one. Under it, discovery logs `ERROR discovery starvation suspected` and health goes 503. See [Starvation](#discovery-starvation). |
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
@@ -763,7 +707,7 @@ Five endpoints, CORS open, no auth. Four read; the fifth writes.
| Endpoint | Notes |
| -------------------- | ------------------------------------------------------------------ |
| `GET /api/health` | Never cached. 503 when probes are stale, discovery failed, or discovery is starved. Carries the last cycle's per-relay outcome. |
| `GET /api/health` | Never cached. 503 when probes are stale or discovery failed. |
| `GET /api/stats` | Network counters, memoized 60s in process. |
| `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. |
| `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. |
@@ -771,82 +715,6 @@ Five endpoints, CORS open, no auth. Four read; the fifth writes.
`/icons/*` serves the cached mint icons.
### Discovery starvation
For about a year, `GET /api/health` said `ok`, every discovery cycle reported
`ok=true`, and the index sat at eight mints. The production `RELAYS` list did not
include the relay carrying the kind 38000/38172 archive, so each backfill read about
thirty events, wrote them faithfully, and the nightly build republished the result.
Nothing was broken in a way anything measured.
What was missing is that "the cycle completed" and "the cycle read anything" are
different claims, and only the first one was being made. Three things now make the
second one:
**Per-relay attribution.** A cycle records, for every relay in `RELAYS`, whether a
socket opened, how many events it sent, and whether it ended in a real EOSE. Counts are
taken before cross-relay deduplication, so they say what each relay contributed rather
than what happened to be new because of it. The one-line cycle log carries the lot:
```
INFO discovery cycle mode=backfill events=1528 … starved=false \
relays=wss://relay.cashumints.space=12 wss://nos.lol=1566 wss://relay.azzamo.net=10 \
wss://relay.snort.social=18 wss://relay.primal.net=1
```
Read that line before changing `RELAYS`. It is also how you find out that most of this
network's archive currently sits behind one relay.
**A WARN per relay, naming it.** A relay that would not connect is warned about on every
cycle. A relay that connected and sent nothing is warned about on backfills only — an
incremental cycle asking for one interval is *supposed* to come back empty, and an
hourly warning about that would train everyone to skip the line that eventually matters.
```
WARN discovery relay unreachable relay=wss://relay.example.invalid mode=backfill
WARN discovery relay returned no events relay=wss://relay.azzamo.net mode=backfill
```
**A floor.** `BACKFILL_MIN_EVENTS`, 200 by default. A backfill asks five relays for the
entire history of four kinds; on a working relay set that is thousands of events. Under
the floor:
```
ERROR discovery starvation suspected events=31 floor=200 relays=5 silent=4 unreachable=0
```
and a flag is set that `GET /api/health` reports as `discovery_starved`, which forces
`status` to `degraded` and the response to **503**. The flag is sticky across
incremental cycles: an hourly cycle finding four events must not clear a starvation a
backfill diagnosed. Only the next backfill clears it.
`GET /api/health` grew four fields for this:
```json
{
"status": "degraded",
"discovery_relays": [
{ "url": "wss://relay.cashumints.space", "connected": true, "events": 12, "eose": true },
{ "url": "wss://relay.example.invalid", "connected": false, "events": 0, "eose": false }
],
"last_discovery_events": 31,
"last_discovery_mode": "backfill",
"discovery_starved": true,
"backfill_min_events": 200
}
```
**A fresh database reports 503 until its first backfill finishes, and that is correct.**
Before any backfill has run, nothing has confirmed that this deployment's relay list
reads anything at all, and answering `ok` would be the original bug in miniature. In
practice it holds `cashumints-web.service` at its health gate — which is the point: a
first deploy should not publish a site built from an empty index. The state is stored in
the database rather than in memory for the same reason, so a restart cannot launder a
starvation into "no cycle yet".
To silence it deliberately on a deployment that genuinely has less history than this —
a private relay, a test rig — set `BACKFILL_MIN_EVENTS=1`.
### Indexing on demand
```bash
@@ -914,9 +782,6 @@ literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
## Deployment
The unit files and the nginx block quoted below are checked in under `deploy/`. Those
are the copies to edit; what is quoted here is the same text, for reading in context.
Three units and an nginx block. Everything this project runs listens on loopback and
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
file belonging to the project.
@@ -941,34 +806,19 @@ is the one that built them.
### Node
**Node 20.18 or newer is enough.** Nothing systemd starts reads a `.ts` file: `pnpm
build` compiles `api/src` to `api/dist`, the site server is plain `.mjs`, and both units
run `/usr/bin/node` against ordinary JavaScript. 20.18 rather than 20.0 only because
both `ExecStart` lines pass `--env-file-if-exists`, which landed there.
The API and the site server both run TypeScript and ESM directly, with no build step, so
**systemd's node must be 22.18 or newer** — that is the release where native type
stripping stopped needing a flag. This is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Check the one that matters:
```bash
/usr/bin/node --version
```
That is the version that matters, and it is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Install Node system-wide rather than pointing
`ExecStart` at a version manager's path, which breaks at the next upgrade and is
invisible to `ProtectHome`.
**Why this section used to say 22.18.** The API ran `src/index.ts` directly, on native
type stripping, so the host's Node version was a runtime dependency of the service. A
deploy onto a host with Node 20 met `ERR_UNKNOWN_FILE_EXTENSION`, exited in under a
second, and was restarted by systemd 464 times over fifteen hours. Every dashboard was
green throughout, because there was no dashboard: `Restart=on-failure` with no start
limit is an infinite loop that never reports a failure. Two things changed. The service
is compiled, so the host's Node version cannot break it in that way again; and the units
now stop after five failures in two minutes and run an `OnFailure=` alert, so if
something else breaks it in some other way, the machine says so. See "Failing loudly".
The version floor that is still 22.18 is the *development* one — `pnpm dev`, `pnpm seed`,
`pnpm migrate` and the `api` `test:*` scripts all hand `src/*.ts` to node. That is a
laptop requirement, not a server one.
On Node 20 the API exits immediately with `ERR_UNKNOWN_FILE_EXTENSION` for `.ts` and
restarts forever. Install Node system-wide rather than pointing `ExecStart` at a version
manager's path, which breaks at the next upgrade and is invisible to `ProtectHome`.
### The API
@@ -978,15 +828,12 @@ laptop requirement, not a server one.
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: a process
# that cannot start will not start on the 4000th attempt either, and `failed` in
# Stop after five failures in a minute rather than restarting forever: a process that
# cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a louder signal than a journal scrolling past. Both keys belong
# to [Unit] — under [Service] systemd only warns and ignores them. See "Failing loudly"
# for why the window is 120s and not 60s.
StartLimitIntervalSec=120
# to [Unit] — under [Service] systemd only warns and ignores them.
StartLimitIntervalSec=60
StartLimitBurst=5
# And carry that `failed` off the machine. %n is this unit's own name.
OnFailure=cashumints-alert@%n.service
[Service]
Type=simple
@@ -1002,10 +849,7 @@ Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
# Keep ICON_DIR either way: cached icons are files, not rows.
# Compiled JavaScript, run by the distribution's own node. See "Node" above for why
# this is not src/index.ts any more.
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
ExecStart=/usr/bin/node --env-file-if-exists=../.env src/index.ts
Restart=on-failure
RestartSec=5s
@@ -1057,9 +901,8 @@ Two behaviours are worth knowing about because they are load-bearing:
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=120
StartLimitIntervalSec=60
StartLimitBurst=5
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.
@@ -1224,238 +1067,17 @@ nginx 1.25 and later want `http2 on;` on its own line and warn about the `listen
form above; Debian 12 ships 1.22, where the newer form is an unknown directive. The form
above is the one that works on both.
### Failing loudly
Two separate silences produced today's incident, and they need separate fixes.
The first is a **crash loop that never reports a failure**. `Restart=on-failure` with no
start limit is an infinite loop by definition: systemd restarts, the process dies,
systemd restarts. The unit never reaches `failed`, so `systemctl status` stays `active
(auto-restart)`, nothing sends anything anywhere, and the only evidence is a journal
scrolling past at four lines a second. The API did this 464 times over fifteen hours.
All three units now carry:
```ini
StartLimitIntervalSec=120
StartLimitBurst=5
OnFailure=cashumints-alert@%n.service
```
**Why 120 and not 60.** `RestartSec=5s` means five attempts cost a little over twenty
seconds of waiting, plus however long each attempt survives before dying. A process that
fails *slowly* — a database connection that times out, a port that takes four seconds to
refuse — spreads five failures past a sixty second window, resets the counter, and loops
forever anyway. 120s covers the slow case. Both keys belong under `[Unit]`; systemd
takes them under `[Service]` with only a warning and then ignores them.
**Why `OnFailure=` at all.** `StartLimitBurst` turns the loop into a `failed` state,
which is much better, and is still a state somebody has to go and look at. `OnFailure=`
is what makes the machine speak first. `%n` expands to the failed unit's own name, which
arrives as the template instance in `%i`.
The second silence is a **build that publishes an index it should have refused**; that
one is the `MIN_MINTS_FOR_BUILD` gate under "Rebuilds", and the `discovery_starved` flag
under "Discovery starvation" is what feeds it.
#### The alert unit
```ini
# /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
```
Configuration is one optional file. With it absent, or with both values empty, a failure
still lands in the journal at `ERROR` and is readable with `journalctl -p err -t
cashumints-alert`; the unit is written so that its worst case is a log line rather than
a second thing to debug.
```ini
# /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/…
```
Test it without breaking anything:
```bash
sudo systemctl start 'cashumints-alert@test.service'
journalctl -t cashumints-alert -n 5 --no-pager
```
### Rebuilds
**Builds happen on deploy. There is no timer.**
```bash
sudo systemctl start cashumints-web # build, then publish
```
There used to be a `cashumints-web.timer` firing at 03:30 nightly, and it was load-bearing:
the mint list was a snapshot of whatever the API held when `astro build` ran, so a
rebuild was the only way a new mint, a new review count or a changed status ever reached
`/mints`. The list hydrates now — one `GET /api/mints` after paint, see
[Live lists](#live-lists) — so all three reach the page within a second of load, in every
language, and rebuilding 2,000 pages overnight to refresh numbers that refresh themselves
is twenty minutes of CPU for nothing.
What a build still produces, and therefore what a deploy is still for:
| Still built | Still stale between deploys |
| --- | --- |
| The prerendered HTML a crawler reads | The `ItemList` JSON-LD on the index pages |
| A social card per mint | The card for a mint indexed since the deploy |
| `sitemap.xml` and the hreflang set | A `/mint/{host}` page for a mint indexed since the deploy |
That last row is the one to know about. A mint indexed today has no prerendered page of
its own until the next deploy: `/mint/newhost` returns **404**, and the 404 page's
resolver looks the address up against the live API and renders it — readable, reviewable,
linkable, with a `noindex` on it until the deploy gives it a real page. That was already
true between nightly builds; dropping the timer only lengthens the window.
Deploy when you ship code, or when enough new mints have accumulated that their pages are
worth prerendering. Nothing breaks if you do not.
Mint pages are prerendered, so new mints and new review counts appear at the next build.
A nightly rebuild is enough; the site stays correct in between because the islands
refresh status and reviews at runtime, and an unbuilt mint still resolves through the
client-side fallback on the 404 page.
Publishing is a separate step from building, and the separation is the point: the copy
the site server reads is only touched once a build has succeeded, so a failed build
leaves the previous site up rather than replacing it with a half-written one.
**Two gates before the build starts.** The first waits for `/api/health` to answer 200,
because `After=` orders a start and does not wait for a port. The second counts
`/api/mints` and refuses to build below `MIN_MINTS_FOR_BUILD`, default 20.
The second exists because health answering 200 and the index being complete are
different claims. A year of ~31-event backfills left a perfectly healthy API serving a
real, correct, complete list of eight mints; a build against that succeeds, prerenders
eight cards, and `rsync --delete-after` replaces fifty-five with eight. The gate runs as
an `ExecStartPre`, so a refusal aborts the unit before `pnpm build` — and publishing is
`ExecStartPost`, after the build — which means the previously published site is never
touched. The `OnFailure=` alert says why.
```bash
# Raise or lower it for this host without editing the unit:
sudo systemctl edit cashumints-web # [Service] / Environment=MIN_MINTS_FOR_BUILD=40
```
```ini
# /etc/systemd/system/cashumints-web.service
[Unit]
@@ -1466,7 +1088,6 @@ Description=Rebuild the cashumints.space static site
Requires=cashumints.service
After=cashumints.service network-online.target
Wants=network-online.target
OnFailure=cashumints-alert@%n.service
[Service]
Type=oneshot
@@ -1489,40 +1110,6 @@ Environment=PUBLIC_API_URL=
# 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
@@ -1535,7 +1122,7 @@ ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/li
# ~500 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.
# A nightly rebuild should not starve the API it is reading from.
Nice=10
UMask=0022
@@ -1552,33 +1139,33 @@ ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
```
There is deliberately no `[Install]` section: this belongs to a deploy, not to a boot.
There is deliberately no `[Install]` section: a rebuild should be scheduled, not fired on
every boot.
#### Removing the timer
```ini
# /etc/systemd/system/cashumints-web.timer
[Unit]
Description=Nightly cashumints.space rebuild
On a host that still has the nightly timer installed, once:
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
```bash
sudo systemctl disable --now cashumints-web.timer
sudo rm -f /etc/systemd/system/cashumints-web.timer
sudo systemctl daemon-reload
systemctl list-timers --all | grep cashumints # expect nothing
[Install]
WantedBy=timers.target
```
`disable --now` both stops the pending job and removes the `timers.target` symlink;
without the `rm` and the `daemon-reload`, systemd keeps a unit it can still be asked to
start by name.
### First deploy
Order matters once: the site server refuses to start against a root that has no
`index.html`, so the build has to publish before it comes up.
```bash
/usr/bin/node --version # 20.18 or newer
/usr/bin/node --version # 22.18 or newer, or the API will not run
sudo systemctl enable --now cashumints # API first: the build reads from it
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
sudo systemctl enable --now cashumints-site # now it has something to serve
sudo systemctl enable --now cashumints-web.timer
sudo nginx -t && sudo systemctl reload nginx
```
+1 -2
View File
@@ -4,9 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"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",
"start": "node --env-file-if-exists=../.env src/index.ts",
"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",
-14
View File
@@ -112,20 +112,6 @@ export const config = {
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),
+14 -276
View File
@@ -19,10 +19,9 @@ import {
type FedimintAnnouncement,
type LnurlAnnouncement,
type LnurlFields,
type RelayHealth,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, setState, getState, getStateNumber } from './db.ts';
import { getDb, setState, getStateNumber } from './db.ts';
import type { Sql } from './db-driver.ts';
import { log } from './log.ts';
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
@@ -40,118 +39,8 @@ 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;
/**
@@ -177,100 +66,12 @@ 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,
tally: RelayTally | null,
): Promise<NostrEvent[]> {
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
const seen = new Map<string, NostrEvent>();
let until: number | undefined;
@@ -281,7 +82,7 @@ async function fetchKind(
let batch: NostrEvent[];
try {
batch = await queryRelays(filter, tally);
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
} catch (err) {
log.warn('relay query failed', {
kind,
@@ -322,7 +123,6 @@ async function fetchKind(
async function fetchReviewsForMint(
target: ReviewTarget,
since: number | null,
tally: RelayTally | null,
): Promise<NostrEvent[]> {
const filters: Filter[] = [];
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
@@ -378,7 +178,11 @@ async function fetchReviewsForMint(
}
const batches = await Promise.all(
filters.map((filter) => queryRelays(filter, tally).catch(() => [] as NostrEvent[])),
filters.map((filter) =>
getPool()
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
.catch(() => [] as NostrEvent[]),
),
);
return batches.flat();
@@ -792,7 +596,6 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
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;
@@ -807,7 +610,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
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));
announcementsByType.set(type, await fetchKind(kind, since));
}),
);
@@ -822,10 +625,10 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
// 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, tally);
const reviews = await fetchKind(KIND_REVIEW, since);
// The recent window catches anything a relay dropped from the unbounded query.
const recent =
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S, tally) : [];
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
const byId = new Map<string, NostrEvent>();
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
@@ -886,7 +689,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
while (cursor < targets.length) {
const target = targets[cursor++];
if (!target) continue;
const found = await fetchReviewsForMint(target, since, tally);
const found = await fetchReviewsForMint(target, since);
if (found.length > 0) {
events += found.length;
newReviews += await ingestReviews(found, index);
@@ -904,79 +707,14 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
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,
mode: backfill ? 'backfill' : 'incremental',
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, relays, starved };
return { events, newMints: [...newMints], newReviews, ok };
}
+13 -85
View File
@@ -3,7 +3,6 @@ import {
compareMints,
NEUTRAL_PRIOR_MEAN,
parseNuts,
readCapabilities,
type Health,
type MintDetail,
type MintInfo,
@@ -17,7 +16,6 @@ import {
} from '@cashumints/shared';
import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts';
import { lastDiscoveryReport } from './discovery.ts';
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
/**
@@ -107,39 +105,6 @@ 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,
@@ -148,15 +113,6 @@ 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,
@@ -170,14 +126,6 @@ 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,
}
: {}),
};
}
@@ -284,9 +232,16 @@ export async function getMintDetail(host: string): Promise<MintDetail | null> {
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;
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);
/*
* Type-specific columns are spread across the payload rather than nested under a key.
@@ -424,55 +379,28 @@ export function resetStatsCache(): void {
statsCache = null;
}
/**
* 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.
*/
/** Health bypasses the stats cache: it is the endpoint you page on. */
export async function getHealth(): Promise<Health> {
const now = Math.floor(Date.now() / 1000);
const db = await getDb();
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = await Promise.all([
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([
getStateNumber('last_probe_at'),
getStateNumber('last_discovery_at'),
getState('last_discovery_ok'),
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
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 || starved ? 'degraded' : 'ok',
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
uptime_s: now - startedAt,
last_probe_at: lastProbe,
last_discovery_at: lastDiscovery,
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,
};
}
+1 -16
View File
@@ -7,22 +7,7 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": 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,
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
-23
View File
@@ -1,23 +0,0 @@
# /etc/cashumints/alert.env
#
# Read by cashumints-alert@.service, which systemd starts when any of the three units
# fails. Everything here is optional: with the file absent or both values empty, an
# alert is still written to the journal at ERROR priority and is readable with
#
# journalctl -p err -t cashumints-alert
#
# Set one or both to have failures leave the machine.
#
# Install it root-owned and not world-readable — a webhook URL is a capability:
# sudo install -d -m 0755 /etc/cashumints
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
# sudo systemctl daemon-reload
# An ntfy topic URL. Free and public at ntfy.sh; pick a topic name nobody will guess,
# because anyone who knows it can read and post to it.
#NTFY_URL=https://ntfy.sh/cashumints-alerts-CHANGE-ME
# Anything that accepts a JSON POST. The body carries `unit`, `host`, `at`, `text` and
# `content` — the last of which is what Discord and most Slack-compatible endpoints read,
# so one payload fits all three.
#WEBHOOK_URL=https://discord.com/api/webhooks/…
-101
View File
@@ -1,101 +0,0 @@
# /etc/systemd/system/cashumints-alert@.service
#
# The unit that makes a failure audible.
#
# The other three units each carry `OnFailure=cashumints-alert@%n.service`, so systemd
# starts one of these with the failed unit's name as the instance — `%i` below is
# literally `cashumints.service`, `cashumints-web.service` or `cashumints-site.service`.
#
# Why it exists: the API once crash looped 464 times over fifteen hours and nothing said
# so. `Restart=on-failure` with no start limit is an infinite loop that never reaches a
# `failed` state, so the journal filled with identical lines nobody was reading and
# every signal stayed green. The other half of the fix is StartLimitBurst= in each unit,
# which turns the loop into a failure; this is what carries that failure off the machine.
#
# Install:
# sudo install -m 0644 deploy/cashumints-alert@.service /etc/systemd/system/
# sudo install -d -m 0755 /etc/cashumints
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
# sudo systemctl daemon-reload
#
# No [Install] section and never enabled: OnFailure= starts it, and a unit that also
# started at boot would page on every reboot.
[Unit]
Description=Notify that %i failed
# No OnFailure= here. An alerter that alerts about its own failure is a loop, and this
# one is written so its worst case is a journal line rather than a retry.
[Service]
Type=oneshot
# The one file an operator edits, and the only reason this unit is configurable at all.
# Absent is a supported state — the leading `-` says so — and then the ExecStart below
# still writes to the journal at ERROR, which is what `systemctl status` and
# `journalctl -p err` read. See alert.env.example.
EnvironmentFile=-/etc/cashumints/alert.env
# So `journalctl -t cashumints-alert` finds every alert, whichever unit triggered it.
SyslogIdentifier=cashumints-alert
# Everything is inside one shell so the "nothing configured" branch is reachable without
# a second unit. The pieces, in order:
#
# - `printf '<3>…'` on stdout. systemd reads that syslog prefix off a journal stream
# and files the line at priority 3, ERROR, so `journalctl -p err` is a complete
# history of failures on a host with no webhook configured at all. `<4>` is warning.
# A prefix rather than systemd-cat, so the unit needs nothing from the filesystem it
# has just sandboxed itself away from.
# - NTFY_URL is a topic URL (https://ntfy.sh/your-topic). It gets a plain-text body
# naming the failed unit, plus the header names ntfy understands.
# - WEBHOOK_URL gets a JSON POST instead, for Discord, Slack or anything that speaks
# `{"content": …}` — every key is sent, so one payload fits all of them.
# - `--max-time 10` and a `||` fallback on each: an alert that hangs would hold the
# failed unit's job open, and an alert that fails must not itself become a second
# failed unit for somebody to notice. The shell ends in `true` for the same reason.
#
# `%i` is the failed unit's name, passed as an argument rather than interpolated into
# the shell text: systemd expands specifiers before /bin/sh ever sees the line, and a
# unit name is not a thing to trust to quoting.
ExecStart=/bin/sh -c '\
UNIT="$1"; \
HOST="$(hostname)"; \
WHEN="$(date -Is)"; \
TEXT="$UNIT failed on $HOST at $WHEN"; \
printf "<3>%s\\n" "$TEXT"; \
if [ -n "$NTFY_URL" ]; then \
/usr/bin/curl -fsS --max-time 10 \
-H "Title: cashumints: $UNIT failed" \
-H "Priority: high" \
-H "Tags: rotating_light" \
-d "$TEXT" "$NTFY_URL" >/dev/null \
|| printf "<3>%s\\n" "alert: POST to NTFY_URL failed"; \
fi; \
if [ -n "$WEBHOOK_URL" ]; then \
/usr/bin/curl -fsS --max-time 10 \
-H "Content-Type: application/json" \
-d "{\\"unit\\":\\"$UNIT\\",\\"host\\":\\"$HOST\\",\\"at\\":\\"$WHEN\\",\\"text\\":\\"$TEXT\\",\\"content\\":\\"$TEXT\\"}" \
"$WEBHOOK_URL" >/dev/null \
|| printf "<3>%s\\n" "alert: POST to WEBHOOK_URL failed"; \
fi; \
if [ -z "$NTFY_URL" ] && [ -z "$WEBHOOK_URL" ]; then \
printf "<4>%s\\n" "alert: no NTFY_URL or WEBHOOK_URL in /etc/cashumints/alert.env, journal only"; \
fi; \
true' _ %i
# It sends one HTTP request and writes one line. It needs no identity of its own, and
# DynamicUser gives it a throwaway one rather than sharing `nobody` with everything else
# on the host that also could not be bothered to make a user.
DynamicUser=yes
NoNewPrivileges=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
# An alert that cannot reach the network in ten seconds is not worth a stuck job.
TimeoutStartSec=30
-67
View File
@@ -1,67 +0,0 @@
# /etc/systemd/system/cashumints-site.service
#
# Serves the built site on loopback. nginx proxies to it and never opens a file itself,
# which is the point: when nginx held a `root` inside /home/cashumints, every directory
# down to dist had to be traversable by www-data, and the one that was not took the
# whole site down as a blanket 404 with nothing in the error log naming the cause.
#
# This is a long-running daemon, unlike cashumints-web.service next to it — that one is
# the oneshot that produces what this one serves.
[Unit]
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
# Give up after five failures in two minutes instead of restarting forever. A process
# that cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a far louder signal than a journal scrolling past. The window
# matches cashumints.service; see the note there for why it is 120s and not 60s. These
# two are [Unit] keys; systemd ignores them under [Service] with only a warning.
StartLimitIntervalSec=120
StartLimitBurst=5
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/web
# The tree comes from cashumints-web.service, which rsyncs it here after a build.
# Serving web/dist directly would mean a rebuild empties the site for the length of it.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=SITE_PORT=8789
Environment=SITE_HOST=127.0.0.1
Environment=WEB_ROOT=/var/lib/cashumints/web
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
# In-flight responses finish; idle keep-alive connections are closed at once.
TimeoutStopSec=15s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
-141
View File
@@ -1,141 +0,0 @@
# /etc/systemd/system/cashumints-web.service
#
# The frontend is static: `output: 'static'` in astro.config.mjs, and the whole site is
# produced ahead of time. There is no frontend build to keep alive, so this unit is a
# build rather than a daemon — one shot of `pnpm build`, which compiles shared/, renders
# a social card per mint and prerenders every page from the live API. The daemon that
# hands the result out is cashumints-site.service.
#
# Run it after a deploy, and only after a deploy:
# sudo systemctl start cashumints-web
#
# There used to be a cashumints-web.timer firing this at 03:30 every night, because the
# mint list was a snapshot of whatever the API held when the build ran and a nightly
# rebuild was the only way it ever changed. The list hydrates from the API after paint
# now, so a new mint, a new review count and a changed status all reach the page within
# a second of load, and rebuilding 2,000 pages at 03:30 to refresh numbers that refresh
# themselves is 20 minutes of CPU for nothing.
#
# What a build still produces, and therefore what a deploy is still for: the prerendered
# HTML a crawler reads, the social card per mint, the sitemap, and a `/mint/{host}` page
# for every mint known at build time. A mint indexed since the last deploy has no page of
# its own until the next one; the 404 fallback resolves it against the live API, so it is
# readable and reviewable in the meantime. That was already true between nightly builds.
#
# There is deliberately no [Install] section — this belongs to a deploy, not to a boot.
[Unit]
Description=Rebuild the cashumints.space static site
# Every page's data comes from the API over loopback, so the API has to be up.
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
# site with an empty one.
Requires=cashumints.service
After=cashumints.service network-online.target
Wants=network-online.target
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
[Service]
Type=oneshot
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space
# Where the published copy lands. Shared with the API and the site server, and created
# by systemd with this unit's ownership if it is not there yet.
StateDirectory=cashumints
Environment=NODE_ENV=production
# Where the build reaches the API. Must match PORT= in cashumints.service.
Environment=API_URL=http://127.0.0.1:8788
Environment=SITE_URL=https://cashumints.space
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
# forwards it. Set this only if the API ever moves to its own hostname. Declared here
# even though it is empty, because systemd's environment wins over .env — so what a
# production build emits cannot drift with an edit to that file.
Environment=PUBLIC_API_URL=
# After= orders the start; it does not wait for the port to accept connections. At boot
# the API is still opening its database and probing, so block until it reports healthy
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
# Then: does the API actually have an index to build a site out of?
#
# Health answering 200 says the process is up and its last backfill read something. It
# does not say how many mints are in the table, and those are different questions — the
# year of ~31-event backfills had a healthy API serving a real, complete, correct list of
# eight mints. A build against that succeeds, prerenders eight cards, and rsync happily
# replaces fifty-five with eight.
#
# So count the list before spending twenty minutes building from it. Below the floor
# this exits non-zero, systemd abandons the unit at ExecStartPre, and — because publishing
# is ExecStartPost, after the build — the previously published site is never touched. The
# site stays exactly as it was and the OnFailure alert says why.
#
# Counted by the `"host":` key, one per item, rather than by counting `{`: the list
# payload carries a nested object per mint (its NUT capability switches), so brace
# counting would report roughly double. No jq: it is not installed on this host and a
# build gate should not add a dependency to run.
#
# `Q` is a double-quote character, built with printf rather than written literally,
# because this whole command is already inside systemd's single quotes and a quote of
# either kind in the grep pattern would end the argument early.
#
# A curl that fails for any reason leaves `n` empty, `$${n:-0}` reads that as zero, and
# zero is below every floor — so an API that fell over between the health check above and
# this line refuses the build rather than sailing through it.
Environment=MIN_MINTS_FOR_BUILD=20
ExecStartPre=/bin/sh -c 'Q=$$(printf "\\042"); \
n=$$(curl -sf --max-time 30 http://127.0.0.1:8788/api/mints | grep -o "$${Q}host$${Q}:" | wc -l); \
if [ "$${n:-0}" -lt "$$MIN_MINTS_FOR_BUILD" ]; then \
printf "<3>%s\\n" "refusing to build: /api/mints returned $${n:-0} mints, floor is $$MIN_MINTS_FOR_BUILD. Previous site left untouched."; \
exit 1; \
fi; \
printf "%s\\n" "build gate: $$n mints, floor $$MIN_MINTS_FOR_BUILD"'
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
# and systemd's PATH does not include it.
ExecStart=/usr/bin/pnpm build
# Publish, as a separate step from building.
#
# `astro build` empties dist before it writes, so the site server cannot read dist
# directly — a rebuild would be a minute of 404s. It serves this copy instead, and the
# copy is only touched once a build has succeeded: a failed build leaves the previous
# site up rather than replacing it with a half-written one, which is the same reason
# Requires=cashumints.service is above and the same reason the mint-count gate is an
# ExecStartPre rather than a check after the fact.
#
# --delay-updates stages the changed files and renames them in at the end, so the window
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
# --delete-after keeps removals from landing before their replacements. Unchanged files
# — every hashed asset and card, which is nearly all of it — are not touched at all.
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
# ~200 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
# TimeoutStartSec is what bounds a Type=oneshot.
TimeoutStartSec=1800
# A build should not starve the API it is reading from.
Nice=10
# The site server runs as cashumints and reads its own files, so this no longer has to
# be world-readable — it was 0022 for nginx, back when nginx opened the files as
# www-data. Kept at 0022 anyway: rsync preserves these modes into the published copy,
# and a readable static site is easier to inspect than one that needs sudo.
UMask=0022
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
# ProtectHome is deliberately absent, unlike in cashumints.service: this unit writes
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
# store are all under it.
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
-70
View File
@@ -1,70 +0,0 @@
# /etc/systemd/system/cashumints.service
[Unit]
Description=cashumints.space indexer and API
Wants=network-online.target
After=network-online.target
# Stop after five failures in two minutes rather than restarting forever.
#
# The window is 120s and not 60s because RestartSec=5s below means five attempts take
# a little over twenty seconds of restarts plus however long each attempt lives before
# it dies. A process that fails *slowly* — a database that times out, a port that takes
# four seconds to refuse — can spread five failures past a sixty second window and reset
# the counter forever, which is the loop this is supposed to stop. 120s covers that.
#
# The failure this exists for: ExecStart named a .ts file, /usr/bin/node was 20, and
# every start died in under a second. 464 restarts over fifteen hours, and because
# Restart=on-failure without a start limit never reaches a `failed` state, nothing
# anywhere went red. Both keys belong to [Unit] — under [Service] systemd only warns and
# ignores them.
StartLimitIntervalSec=120
StartLimitBurst=5
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
OnFailure=cashumints-alert@%n.service
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/api
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=PORT=8788
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# Compiled JavaScript, run by the distribution's own node.
#
# This line used to read `src/index.ts`, which made every start depend on the host
# having Node 22.18 or newer for native type stripping. A host with Node 20 answered
# that with ERR_UNKNOWN_FILE_EXTENSION in under a second, 464 times over fifteen hours,
# and nothing anywhere went red. `pnpm build` now emits api/dist, so what runs here is
# ordinary ESM and any Node from 20.18 up will start it.
#
# Deliberately /usr/bin/node and nothing else: an nvm or fnm path is invisible to this
# unit's ProtectHome and breaks silently at the next version bump.
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=30s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
-160
View File
@@ -1,160 +0,0 @@
# /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.
}
-361
View File
@@ -1,361 +0,0 @@
# 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.
+2 -2
View File
@@ -4,7 +4,7 @@
"version": "2.0.0",
"type": "module",
"engines": {
"node": ">=20.18"
"node": ">=22.18"
},
"scripts": {
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
@@ -12,7 +12,7 @@
"dev:web": "pnpm --filter ./web dev",
"seed": "pnpm --filter ./api seed",
"bones": "pnpm --filter ./web bones",
"build": "pnpm --filter ./shared build && pnpm --filter ./api build && pnpm --filter ./web build",
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
"start": "pnpm --filter ./web start",
"check:links": "pnpm --filter ./web check:links",
"typecheck": "pnpm -r typecheck",
-79
View File
@@ -1,7 +1,5 @@
/** Shapes returned by the API. The web app builds against these. */
import type { MintCapabilities } from './warnings.js';
/**
* Statuses a listed thing can be in.
*
@@ -37,40 +35,6 @@ 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 {
@@ -272,29 +236,6 @@ export interface Stats {
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`. */
export interface Health {
status: 'ok' | 'degraded';
@@ -303,26 +244,6 @@ 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;
}
/**
+1 -15
View File
@@ -268,20 +268,6 @@ 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);
@@ -294,7 +280,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) && !extraNamespaces.has(namespace)) {
if (!clientNamespaces.has(namespace)) {
clientLeaks.push({ key, file: relative, namespace });
}
}
+1 -4
View File
@@ -351,10 +351,7 @@ export function createServer() {
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',
});
log('error', 'web root has no index.html', { root: ROOT, hint: 'run pnpm build, then publish it to WEB_ROOT' });
process.exit(1);
}
+14 -24
View File
@@ -394,28 +394,14 @@ function escapeText(value: string): string {
let pendingPaint = 0;
/** Set for one paint after publishing, to mark the new card as it goes in. */
let markNewest = false;
/** Reviews published in this session, shown at the top until relays echo them back. */
/**
* Reviews published in this session, shown at the top until relays echo them back.
*
* Keyed by event id, holding how many relays took it: a review that only two of
* four relays accepted says so on its card, because that is a thing the author
* may want to act on.
*/
const optimistic = new Map<string, { accepted: number; total: number }>();
/** Optimistic cards whose brief publishing note is still visible. */
const publishingNotes = new Set<string>();
const PUBLISHING_NOTE_MS = 4_000;
const PUBLISHING_NOTE_FADE_MS = 220;
function dismissPublishingNote(id: string): void {
publishingNotes.delete(id);
const note = document.querySelector<HTMLElement>(
`[id="review-${id.toLowerCase()}"] .propagating`,
);
if (!note) return;
note.classList.add('is-out');
window.setTimeout(() => note.remove(), PUBLISHING_NOTE_FADE_MS);
}
function markOptimistic(id: string, accepted: number, total: number): void {
optimistic.set(id, { accepted, total });
publishingNotes.add(id);
window.setTimeout(() => dismissPublishingNote(id), PUBLISHING_NOTE_MS);
}
/* ---------- rendering ---------- */
@@ -490,7 +476,8 @@ function escapeText(value: string): string {
target.innerHTML = slice
.map((review) =>
reviewHtml(review, f, {
propagating: publishingNotes.has(review.id),
propagating: optimistic.has(review.id),
publishedTo: optimistic.get(review.id) ?? null,
permalink,
}),
)
@@ -680,7 +667,10 @@ function escapeText(value: string): string {
* that they would have seen had they written it on this page.
*/
if (handedForward && !all.some((review) => review.id === handedForward.id)) {
markOptimistic(handedForward.id, handedForward.accepted, handedForward.total);
optimistic.set(handedForward.id, {
accepted: handedForward.accepted,
total: handedForward.total,
});
all.unshift({
id: handedForward.id,
pubkey: handedForward.pubkey,
@@ -820,7 +810,7 @@ function escapeText(value: string): string {
},
onPublished: (published) => {
// Optimistic insert at the top. The relays will echo it back on next load.
markOptimistic(published.id, published.accepted, published.total);
optimistic.set(published.id, { accepted: published.accepted, total: published.total });
all.unshift({
id: published.id,
pubkey: published.pubkey,
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "دي أول مراجعة من الـnpub ده، ومفيش نشاط تاني ليه",
"reviews.published": "نُشرت",
"reviews.publishedPartial": "نُشرت إلى {accepted} من {total} إعادة شحن.",
"reviews.propagating": "بننشر على موزّعات Nostr",
"reviews.propagating": "البروغات، قد يستغرق الأمر لحظة للظهور في مكان آخر.",
"reviews.summary.line.one": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "{count} تصنيف دون تعليق على {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "أول تقييم من هذا npub، لم يتم العثور على أي نشاط آخر",
"reviews.published": "تم النشر.",
"reviews.publishedPartial": "تم النشر إلى {accepted} من أصل {total} مرحل.",
"reviews.propagating": "جارٍ النشر إلى مرحلات Nostr",
"reviews.propagating": "أثناء النشر، قد يستغرق الأمر لحظة حتى يظهر في مكان آخر.",
"reviews.summary.line.one": "{count} تقييم بدون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تقييمًا بدون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "تقييم {count} بدون تعليق على {when}: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "První přezkum z tohoto npub, žádná jiná činnost nenalezena",
"reviews.published": "Publikováno.",
"reviews.publishedPartial": "Zveřejněno{accepted}z{total}relé.",
"reviews.propagating": "Publikování na Nostr relayích",
"reviews.propagating": "Propagatování může chvíli trvat, než se objeví jinde.",
"reviews.summary.line.one": "{count}hodnocení bez komentáře:{breakdown}",
"reviews.summary.line.other": "{count}ratingy bez komentáře:{breakdown}",
"reviews.summary.line.few": "{count}ratingy bez komentáře:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Første anmeldelse af denne npub; der er ikke fundet andre aktiviteter",
"reviews.published": "Udgivet.",
"reviews.publishedPartial": "Udgivet til {accepted} blandt {total}-relæerne.",
"reviews.propagating": "Udgiver til Nostr-relays",
"reviews.propagating": "Når indholdet opdateres, kan det tage et øjeblik, før det vises andre steder.",
"reviews.summary.line.one": "{count}-vurdering uden kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-vurderinger uden kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-vurdering uden kommentar til {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Erste Rezension von diesem Verlag, keine weiteren Aktivitäten gefunden",
"reviews.published": "Veröffentlicht.",
"reviews.publishedPartial": "Veröffentlicht auf {accepted} der {total}-Relais.",
"reviews.propagating": "Veröffentlichen auf Nostr-Relays",
"reviews.propagating": "Die Übertragung kann einen Moment dauern, bis sie an anderer Stelle angezeigt wird.",
"reviews.summary.line.one": "{count}-Bewertung ohne Kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-Bewertungen ohne Kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-Bewertung ohne Kommentar auf {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Πρώτη κριτική από αυτό το npub, δεν βρέθηκε άλλη δραστηριότητα",
"reviews.published": "Δημοσίευση.",
"reviews.publishedPartial": "Δημοσίευση στο{accepted} του{total} ⁇ λεμάν.",
"reviews.propagating": "Δημοσίευση στα relays του Nostr",
"reviews.propagating": "Διαδοχικά, μπορεί να πάρει μια στιγμή για να εμφανιστεί αλλού.",
"reviews.summary.line.one": "{count} αξιολόγηση χωρίς σχόλιο:{breakdown}",
"reviews.summary.line.other": "{count} αξιολογήσεις χωρίς σχόλιο:{breakdown}",
"reviews.summary.lineDay.one": "{count} αξιολόγηση χωρίς σχόλιο σχετικά με{when}:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "First review from this npub, no other activity found",
"reviews.published": "Published.",
"reviews.publishedPartial": "Published to {accepted} of {total} relays.",
"reviews.propagating": "Publishing to Nostr relays",
"reviews.propagating": "Propagating, it may take a moment to appear elsewhere.",
"reviews.summary.line.one": "{count} rating without a comment: {breakdown}",
"reviews.summary.line.other": "{count} ratings without a comment: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}",
+1 -1
View File
@@ -286,7 +286,7 @@
"reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad",
"reviews.published": "Publicada.",
"reviews.publishedPartial": "Publicada en {accepted} de {total} relays.",
"reviews.propagating": "Publicando en relés Nostr",
"reviews.propagating": "Propagándose, puede tardar un momento en aparecer en otros sitios.",
"reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}",
"reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}",
"reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Premier avis sur ce npub, aucune autre activité trouvée",
"reviews.published": "Publié.",
"reviews.publishedPartial": "Publié sur {accepted} des relais {total}.",
"reviews.propagating": "Publication sur les relais Nostr",
"reviews.propagating": "En se propageant, cela peut prendre un moment pour apparaître ailleurs.",
"reviews.summary.line.one": "Note {count} sans commentaire : {breakdown}",
"reviews.summary.line.other": "Notes {count} sans commentaire : {breakdown}",
"reviews.summary.lineDay.one": "Note {count} sans commentaire sur {when} : {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "इस npub से पहली समीक्षा, कोई अन्य गतिविधि नहीं मिली।",
"reviews.published": "प्रकाशित",
"reviews.publishedPartial": "{total} रिलेज़ में से {accepted} पर प्रकाशित।",
"reviews.propagating": "Nostr रिले पर प्रकाशित किया जा रहा है",
"reviews.propagating": "प्रसारित करते समय, कहीं और प्रकट होने में थोड़ा समय लग सकता है।",
"reviews.summary.line.one": "बिना टिप्पणी के {count} रेटिंग: {breakdown}",
"reviews.summary.line.other": "टिप्पणी के बिना रेटिंग: {count}; रेटिंग: {breakdown}",
"reviews.summary.lineDay.one": "{count} रेटिंग, {when} पर बिना टिप्पणी के: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Ulasan pertama dari npub ini, tidak ada aktivitas lain ditemukan",
"reviews.published": "Diterbitkan.",
"reviews.publishedPartial": "Diterbitkan ke {accepted} dari {total} relay.",
"reviews.propagating": "Mempublikasikan ke relay Nostr",
"reviews.propagating": "Sedang disebarkan; mungkin perlu waktu untuk muncul di tempat lain.",
"reviews.summary.line.one": "Rating {count} tanpa komentar: {breakdown}",
"reviews.summary.line.other": "Rating {count} tanpa komentar: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating tanpa komentar di {when}: {breakdown}",
+2 -11
View File
@@ -121,22 +121,13 @@ export function missingKeys(): Record<string, string[]> {
* key that survives is resolved: a key this locale is missing arrives already filled
* with the English string, so the browser needs no fallback catalog and ships exactly
* one language.
*
* `extra` is for a namespace exactly one page's islands need. The home page's grids
* hydrate and have to rewrite their own "All 56 mints →" links, which live under
* `home.` — a namespace worth about 2KB that every other page, including 1,300 mint
* pages, has no use for. Passed per page through `Base.astro`'s `clientNamespaces`
* prop, it is inlined where it is read and nowhere else. `check-i18n.mjs` does not know
* about this, so a key reached this way must still be in a namespace the checker
* accepts, or listed in `CLIENT_NAMESPACES` — see the note there.
*/
export function clientCatalog(locale: Locale, extra: readonly string[] = []): Catalog {
export function clientCatalog(locale: Locale): Catalog {
const catalog = catalogFor(locale);
const allowed = new Set<string>([...CLIENT_NAMESPACES, ...extra]);
const out: Catalog = {};
for (const key of Object.keys(BASE_CATALOG)) {
const namespace = key.split('.')[0] ?? '';
if (!allowed.has(namespace)) continue;
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
}
return out;
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Prima recensione da questo npub, nessun'altra attività trovata",
"reviews.published": "Pubblicato.",
"reviews.publishedPartial": "Pubblicato in {accepted} di {total} relè.",
"reviews.propagating": "Pubblicazione sui relay Nostr",
"reviews.propagating": "Propagando, può richiedere un momento per apparire altrove.",
"reviews.summary.line.one": "{count} valutazione senza un commento: {breakdown}",
"reviews.summary.line.other": "{count} valutazioni senza un commento: {breakdown}",
"reviews.summary.lineDay.one": "{count} valutazione senza un commento su {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "このnpubからの最初のレビュー、他の活動が見つかりません",
"reviews.published": "掲載情報",
"reviews.publishedPartial": "に公開 {accepted} の {total} リレー.",
"reviews.propagating": "Nostrリレーに公開中",
"reviews.propagating": "伝播は、他の場所で出現する瞬間を取るかもしれません。",
"reviews.summary.line.one": "{count} コメントのない評価: {breakdown}",
"reviews.summary.line.other": "{count} コメントのない評価: {breakdown}",
"reviews.summary.lineDay.one": "{count} 評価なし にコメント {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden",
"reviews.published": "Gepubliceerd.",
"reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.",
"reviews.propagating": "Publiceren naar Nostr-relays",
"reviews.propagating": "Wordt verspreid, het kan even duren voordat hij elders verschijnt.",
"reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}",
"reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}",
"reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Pierwsza recenzja z tego npubu, nie znaleziono żadnej innej aktywności",
"reviews.published": "Opublikowano.",
"reviews.publishedPartial": "Opublikowano dla {accepted} sztafetyi {total}.",
"reviews.propagating": "Publikowanie na relayach Nostr",
"reviews.propagating": "Rozmnażając, może potrzebować chwili, by pojawić się gdzie indziej.",
"reviews.summary.line.one": "{count} ocena bez komentarza: {breakdown}",
"reviews.summary.line.few": "{count} oceny bez komentarza: {breakdown}",
"reviews.summary.line.many": "{count} ocen bez komentarza: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Primeira revisão deste npub, nenhuma outra atividade encontrada",
"reviews.published": "Publicado.",
"reviews.publishedPartial": "Publicado em {accepted} dos relés {total}.",
"reviews.propagating": "Publicando nos relays Nostr",
"reviews.propagating": "Propagando, pode demorar um pouco para aparecer em outro lugar.",
"reviews.summary.line.one": "Classificação {count} sem comentários: {breakdown}",
"reviews.summary.line.other": "Avaliações {count} sem comentários: {breakdown}",
"reviews.summary.lineDay.one": "Classificação {count} sem comentários em {when}: {breakdown}",
+1 -1
View File
@@ -290,7 +290,7 @@
"reviews.anonNote": "Prima revizuire a acestei npub-uri, nicio altă activitate nu a fost găsită",
"reviews.published": "Publicat.",
"reviews.publishedPartial": "Publicată în{accepted} din{total} relee.",
"reviews.propagating": "Se publică pe relayurile Nostr",
"reviews.propagating": "Propaganda, poate dura un moment să apară în altă parte.",
"reviews.summary.line.one": "{count}rating fără comentarii:{breakdown}",
"reviews.summary.line.other": "{count} ratinguri fără comentarii:{breakdown}",
"reviews.summary.line.few": "{count} ratinguri fără comentarii:{breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Первый отзыв с этого npub, другой активности не найдено",
"reviews.published": "Опубликовано.",
"reviews.publishedPartial": "Опубликовано на {accepted} из {total} relays.",
"reviews.propagating": "Публикация на relays Nostr",
"reviews.propagating": "Распространяется по сети, в других местах отзыв может появиться не сразу.",
"reviews.summary.line.one": "Рейтинг «{count}» без комментариев: {breakdown}",
"reviews.summary.line.other": "Рейтинги «{count}» без комментариев: {breakdown}",
"reviews.summary.line.few": "Рейтинги «{count}» без комментариев: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Första recensionen av denna npub, inga andra aktiviteter har hittats",
"reviews.published": "Publicerad.",
"reviews.publishedPartial": "Publicerad till reläet {accepted} i serien {total}.",
"reviews.propagating": "Publicerar till Nostr-relayer",
"reviews.propagating": "När innehållet sprids kan det ta en stund innan det visas på andra ställen.",
"reviews.summary.line.one": "{count}-betyg utan kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-betyg utan kommentar: {breakdown}",
"reviews.summary.lineDay.one": "Betyg för {count} utan kommentar om {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Bu npub'dan ilk inceleme, başka bir faaliyet bulunmadı",
"reviews.published": "Yayınlandı.",
"reviews.publishedPartial": "{accepted} {total} rölesine yayımlandı.",
"reviews.propagating": "Nostr relay'lerine yayınlanıyor",
"reviews.propagating": "Yayılırken başka bir yerde ortaya çıkması biraz zaman alabilir.",
"reviews.summary.line.one": "{count} yorumsuz puan: {breakdown}",
"reviews.summary.line.other": "{count} yorumsuz puanlar: {breakdown}",
"reviews.summary.lineDay.one": "{when} yorumsuz {count} puanı: {breakdown}",
+1 -1
View File
@@ -298,7 +298,7 @@
"reviews.anonNote": "Перший відгук від цього npub, іншої активності не знайдено",
"reviews.published": "Опубліковано.",
"reviews.publishedPartial": "Опубліковано на {accepted} із {total} relays.",
"reviews.propagating": "Публікація на relays Nostr",
"reviews.propagating": "Поширюється мережею, поява в інших місцях може тривати деякий час.",
"reviews.summary.line.one": "{count} рейтинг без коментаря: {breakdown}",
"reviews.summary.line.few": "{count} оцінки без коментарів: {breakdown}",
"reviews.summary.line.many": "{count} оцінок без коментарів: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "اس ناول سے پہلا جائزہ، کوئی اور سرگرمی نہیں پائی گئی",
"reviews.published": "شائع ہوا۔",
"reviews.publishedPartial": "شائع شدہ{accepted}کا مطلب{total}ریلویز.",
"reviews.propagating": "Nostr ریلے پر شائع ہو رہا ہے",
"reviews.propagating": "اِس لئے شاید آپ کسی اَور ملک میں جا کر اُس کے بارے میں بات کریں ۔",
"reviews.summary.line.one": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
"reviews.summary.line.other": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
"reviews.summary.lineDay.one": "{count}بغیر تبصرے کے خواندگی{when}:{breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "Xem lại lần đầu từ npb này, không tìm thấy hoạt động nào khác",
"reviews.published": "Xuất bản.",
"reviews.publishedPartial": "Do {accepted} của {total} rơle.",
"reviews.propagating": "Đang đăng lên các relay Nostr",
"reviews.propagating": "Tuyên truyền, có thể phải mất một lúc mới xuất hiện.",
"reviews.summary.line.one": "{count} đánh giá mà không bình luận: {breakdown}",
"reviews.summary.line.other": "{breakdown} {count} đánh giá mà không bình luận: 918274242",
"reviews.summary.lineDay.one": "{count} đánh giá mà không bình luận gì về {when}: {breakdown}",
+1 -1
View File
@@ -282,7 +282,7 @@
"reviews.anonNote": "这是关于这款npub的首条评论,未发现其他相关活动",
"reviews.published": "已发布。",
"reviews.publishedPartial": "已发布至 {total} 继电器中的 {accepted}。",
"reviews.propagating": "正在发布到 Nostr 中继",
"reviews.propagating": "正在同步,内容可能需要片刻时间才会显示在其他地方。",
"reviews.summary.line.one": "{count} 的评分(无评论):{breakdown}",
"reviews.summary.line.other": "{count} 的评分(无评论):{breakdown}",
"reviews.summary.lineDay.one": "{count}的评分,未对{when}发表评论:{breakdown}",
+2 -10
View File
@@ -25,14 +25,6 @@ interface Props {
description: string;
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
mintCount?: number;
/**
* Catalog namespaces this page's islands need on top of `CLIENT_NAMESPACES`.
*
* The home page passes `['home']`: its three grids refresh from the API and rewrite
* their own "All 56 mints →" links, so those strings have to reach the browser. They
* are inlined on the one page that reads them rather than on all 1,300.
*/
clientNamespaces?: readonly string[];
ogType?: string;
/**
* A real page that should not be in the index.
@@ -78,7 +70,7 @@ interface Props {
const {
title, description, current, mintCount, ogType = 'website',
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
imageAlt, clientNamespaces = [],
imageAlt,
} = Astro.props;
/*
@@ -126,7 +118,7 @@ const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
* travels in the HTML the page was sending anyway, costs no extra request, and is on
* screen before the first island has finished downloading.
*/
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale, clientNamespaces) }).replace(/</g, '\\u003c');
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c');
/**
* The social card, absolute.
-316
View File
@@ -1,316 +0,0 @@
/**
* The mint card, as HTML strings, and the hydration that puts them on a page.
*
* `MintCard.astro` renders the same card at build time and cannot run in the browser, so
* this is its parallel renderer — the same relationship `review-cards.ts` has with the
* reviews panel. The two must agree on every class name and every `data-*` attribute,
* because the sort, the filter, the rank chips and the shared-element view transitions
* on the three index pages all read the DOM rather than any model:
*
* data-mint-card what the sort collects and the transition arms
* data-name / data-domain what the search box matches
* data-status "hide offline", and the online/offline counts
* data-score / -rating / -reviews the sort keys
* data-last-review / -last-online the other two sort keys
* data-vt-icon / data-vt-name shared-element names, applied on click
*
* Why this exists at all: /mints was a snapshot of whatever the API held when `astro
* build` ran, and stayed that until the next build. A mint indexed at noon was reviewable
* immediately — the 404 resolver saw to that — and simply had no card until the nightly
* rebuild. The list now refetches after paint. The prerendered cards stay exactly as they
* were: they are the first paint, they are what a crawler and a reader with no JavaScript
* get, and this only ever replaces them with something newer.
*
* Nothing here talks to a relay. Card counts come from the API's ingested aggregates,
* which is what they always were; review *bodies* remain a detail-page and /reviews
* concern. See docs/dynamic-mint-data.md.
*
* Everything interpolated goes through `escapeHtml`. A mint's name comes from its own
* `/v1/info` — a string an operator controls — and this builds markup with strings.
*/
import {
baseUrlFromKey,
federationIdFromSlug,
getMintWarnings,
mintChip,
type MintListItem,
} from '@cashumints/shared';
import { apiBase, escapeHtml, iconGradient } from './client';
import { targetPath } from './feed-resolve';
import { displayDomain, initials, transitionName } from './format';
import { localePath, splitLocale } from '../i18n/routing';
import type { Locale } from '../i18n/config';
import { useI18n } from '../i18n/client';
import { formatters, starString, type Formatters } from '../i18n/format';
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
import { initReveal } from '../scripts/reveal';
export interface CardOptions {
/** 1-based position in the default order. Omitted, the card has no rank chip. */
rank?: number;
/** Entrance delay in ms, for a grid of known size that arrives as one gesture. */
revealDelay?: number;
/**
* Render already revealed, with no entrance.
*
* Set for a card replacing one the reader is already looking at. `[data-reveal]` is
* `opacity: 0` until `.in` lands, so without this a hydration would fade the whole
* visible grid back in a second after load — an animation that says "something
* changed" about forty cards where at most one did.
*/
revealed?: boolean;
}
/**
* One card's markup.
*
* `f` carries both the translator and the locale's number and date formatting, and
* `locale` is what prefixes the href. Both are passed in rather than read here, because
* a grid renders dozens of these and rebuilding the formatter per card would be the
* expensive part of the whole hydration.
*/
export function mintCardHtml(
mint: MintListItem,
locale: Locale,
f: Formatters,
options: CardOptions = {},
): string {
const t = f.t;
const { rank, revealDelay, revealed } = options;
/*
* One card, all three ecosystems — the same reasoning as MintCard.astro.
*
* A federation has no URL, so its second line is a shortened federation id rather than
* `fedimint:aeca6c…`, which is a database key. An LNURL mint's row key carries an
* `lnurl:` scheme in front of its URL, so the domain comes out of the key.
*/
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;
// Never `API_URL`: that is a build-machine address and a visitor's browser cannot
// reach it. `apiBase` is PUBLIC_API_URL, empty in production, which resolves /icons
// against whatever origin is serving the page.
const icon = mint.icon ? `${apiBase}${mint.icon}` : null;
const offline = mint.status === 'offline';
const announced = mint.status === 'announced';
const href = localePath(targetPath(mint), locale);
/*
* The chip, from facts the list payload now carries.
*
* It used to take one `GET /api/mints/:host` per mint to read these — fine for a build
* machine rendering the grid once a night, ruinous as an N+1 in every visitor's
* browser. `capabilities` and the two LNURL fields were added to `MintListItem` for
* exactly this. A federation has no chip and never will: `mintChip` returns null for
* one, because a federation publishes no capability list to draw a claim from.
*/
const chipSource =
mint.capabilities || mint.max_withdrawable_msat !== undefined || mint.funding_available !== undefined
? {
type: mint.type,
status: mint.status,
last_online: mint.last_online,
capabilities: mint.capabilities ?? null,
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}
: null;
const chip = chipSource
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
: null;
// Falls back to the rating split when there is no distribution, exactly as the build
// does for /mints: a 4.6 average is roughly 92% positive, which is what the bar says.
const pos =
mint.rating_avg === null ? 0 : Math.round(((mint.rating_avg - 1) / 4) * 100);
const neg = mint.rating_avg === null ? 0 : 100 - pos;
const vtIcon = escapeHtml(transitionName('icon', mint.host));
const vtName = escapeHtml(transitionName('name', mint.host));
const iconHtml = icon
? `<img class="mc-icon" src="${escapeHtml(icon)}" alt="" width="42" height="42" ` +
`loading="lazy" decoding="async" data-vt-icon="${vtIcon}">`
: `<span class="mc-icon" style="background:${escapeHtml(iconGradient(domain))}" ` +
`aria-hidden="true" data-vt-icon="${vtIcon}">${escapeHtml(initials(name))}</span>`;
const statsHtml =
mint.rating_avg === null
? `<span class="mc-none">${escapeHtml(t('card.noRatings'))}</span>`
: `<span class="mc-rating">` +
`<span class="mc-score">${escapeHtml(f.decimal(mint.rating_avg))}</span>` +
`<span class="mc-stars" aria-hidden="true">${starString(mint.rating_avg)}</span>` +
`</span>`;
/*
* "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.
*/
const last = 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');
const classes = ['mint-card'];
if (offline) classes.push('is-offline');
if (revealed) classes.push('in');
return (
`<a class="${classes.join(' ')}" href="${escapeHtml(href)}" data-reveal` +
(revealDelay === undefined ? '' : ` data-reveal-delay="${revealDelay}"`) +
` data-mint-card` +
` data-name="${escapeHtml(name.toLowerCase())}"` +
` data-domain="${escapeHtml(domain.toLowerCase())}"` +
` data-status="${escapeHtml(mint.status)}"` +
` data-score="${mint.score}"` +
` data-rating="${mint.rating_avg ?? 0}"` +
` data-reviews="${mint.review_count}"` +
` data-last-review="${mint.last_review_at ?? 0}"` +
` data-last-online="${mint.last_online ?? 0}">` +
`<div class="mc-top">` +
iconHtml +
`<span class="mc-id">` +
`<span class="mc-name" data-vt-name="${vtName}">${escapeHtml(name)}</span>` +
`<span class="mc-domain${fedimint ? ' mono' : ''}">${escapeHtml(domain)}</span>` +
`</span>` +
(rank === undefined
? ''
: `<span class="mc-rank${rank === 1 ? ' gold' : ''}">#${rank}</span>`) +
`</div>` +
`<div class="mc-stats">` +
statsHtml +
`<span class="mc-reviews">${escapeHtml(t('card.reviews', { n: mint.review_count }))}</span>` +
`</div>` +
`<div class="mc-bar" aria-hidden="true"><span class="mc-bar-fill">` +
(pos > 0 ? `<span class="pos" style="width:${pos}%"></span>` : '') +
(neg > 0 ? `<span class="neg" style="width:${neg}%"></span>` : '') +
`</span></div>` +
`<div class="mc-foot">` +
`<span class="mc-dot ${escapeHtml(mint.status)}"></span>` +
`<span class="mc-status">${escapeHtml(statusLabel(mint.status, t))}</span>` +
(chip ? `<span class="mc-chip ${escapeHtml(chip.severity)}">${escapeHtml(chip.label)}</span>` : '') +
`<span class="last">${escapeHtml(last)}</span>` +
`</div>` +
`</a>`
);
}
/**
* One ecosystem's full listing, or null.
*
* Null on anything at all going wrong, and the caller's job is then to do nothing: the
* prerendered grid is already on screen and correct as of the last build, so a failed
* refresh should be invisible rather than an error message about a list the reader can
* see. This is the same rule the reviews panel and the pulse ticker follow.
*/
export async function fetchListing(type: string): Promise<MintListItem[] | null> {
try {
const res = await fetch(`${apiBase}/api/mints?type=${encodeURIComponent(type)}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const items = (await res.json()) as MintListItem[];
// A well-formed empty answer is still not a reason to empty a grid that has cards in
// it. An API serving nothing is the failure the build gate exists to catch, and a
// page that renders it as "no mints" would be this bug wearing a different hat.
return Array.isArray(items) && items.length > 0 ? items : null;
} catch {
return null;
}
}
/**
* Replace a grid's cards with freshly rendered ones.
*
* Cards whose host was already on screen and revealed are rendered revealed, so the
* common case — the list is the same list, with newer numbers — is a silent swap rather
* than forty cards fading in again. Genuinely new hosts get the ordinary entrance from
* `initReveal`, which is also what reveals anything below the fold on scroll.
*
* Returns the new card elements, in DOM order, for the caller to re-apply its sort and
* filter to.
*/
export function renderMintGrid(
grid: HTMLElement,
items: MintListItem[],
options: { ranked?: boolean; revealDelayStep?: number } = {},
): HTMLElement[] {
const { ranked = true, revealDelayStep } = options;
const t = useI18n();
const f = formatters(t);
const { locale } = splitLocale(window.location.pathname);
// Which hosts the reader can already see. Keyed by host rather than by index: the list
// may have grown, shrunk or reordered, and the question is per mint.
const revealed = new Set<string>();
for (const card of grid.querySelectorAll<HTMLAnchorElement>('[data-mint-card]')) {
if (card.classList.contains('in')) {
const host = card.getAttribute('href')?.split('/').pop();
if (host) revealed.add(decodeURIComponent(host));
}
}
grid.innerHTML = items
.map((mint, i) =>
mintCardHtml(mint, locale, f, {
...(ranked ? { rank: i + 1 } : {}),
...(revealDelayStep === undefined ? {} : { revealDelay: i * revealDelayStep }),
revealed: revealed.has(mint.host),
}),
)
.join('');
initReveal(grid);
return [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
}
export interface HydrateOptions {
/** `cashu`, `fedimint` or `lnurl`. */
type: string;
/** The grid to rebuild. */
grid: HTMLElement;
/** Keep only the first N, for the home page's top-six strips. */
limit?: number;
ranked?: boolean;
revealDelayStep?: number;
/** Called with the new cards and the payload they were built from, on success only. */
onReplaced?: (cards: HTMLElement[], items: MintListItem[]) => void;
}
/**
* Fetch one ecosystem and rebuild its grid, after paint.
*
* Deliberately silent on failure — see `fetchListing`. Deliberately unconditional on
* success: the API is the newer of the two by construction, since the prerendered grid
* is a copy of what this same endpoint said at build time.
*/
export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
const { type, grid, limit, ranked, revealDelayStep, onReplaced } = options;
const all = await fetchListing(type);
if (!all) return;
// The reader may have navigated away while the fetch was in flight. The router has
// already swapped this grid out of the document, so rendering into it (and telling the
// caller to re-apply filters against cards nobody can see) is wasted work.
if (!grid.isConnected) return;
const items = limit === undefined ? all : all.slice(0, limit);
const cards = renderMintGrid(grid, items, {
...(ranked === undefined ? {} : { ranked }),
...(revealDelayStep === undefined ? {} : { revealDelayStep }),
});
onReplaced?.(cards, all);
}
+28 -16
View File
@@ -55,6 +55,19 @@ export interface BuildReview {
* characters or matching a common test string do not qualify. They still appear in
* full on the mint page, nothing is hidden, this strip is just a shop window.
*/
/** How many candidates to gather per card shown, so the ordering has a real choice. */
const POOL_FACTOR = 12;
/**
* How recent a review must still be to be promoted for having a named author.
*
* The ordering below prefers reviews whose author has a kind 0, and this is the leash
* on that preference. Without it the strip would headline a named review from two
* years ago under a heading that says "Latest reviews"; with it, a named review from
* within the season can lead and anything older cannot.
*/
const PROMOTE_MAX_AGE_S = 90 * 24 * 60 * 60;
const TEST_STRINGS = new Set([
'test', 'testing', 'test test', 'hello', 'hi', 'ok', 'okay', 'good', 'nice',
'great', 'cool', 'gm', 'a', '.', '..', '...', 'asdf', 'qwerty', '123',
@@ -214,30 +227,29 @@ export async function fetchLatestReviews(
profile: null,
});
// The scan runs newest first, so the first `limit` survivors are the latest
// `limit` reviews. Nothing further down the list can outrank them.
if (candidates.length >= limit) break;
if (candidates.length >= limit * POOL_FACTOR) break;
}
const profiles = await fetchProfiles(candidates.map((c) => c.pubkey));
for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null;
/*
* Strict recency, newest first, as the last thing that happens to this list.
* Recent reviews with a named author come first; everything else stays newest
* first behind them.
*
* The heading says "Latest reviews", so the order under it is the event
* `created_at` and nothing else. This used to float reviews whose author had a
* kind 0 to the front, which read as broken on the page: a named review from two
* months ago sat between two reviews from this week, and the card feet said so,
* because the "2mo ago" label is formatted from the very same `created_at` this
* sorts on. Author names are still resolved and still shown — they just no longer
* decide the order.
*
* Sorted here rather than left to the scan above, so that the invariant holds
* whatever the gathering loop does later, and `id` breaks ties between two events
* that share a second so two builds of the same events agree.
* Most review keys have published exactly one event in their life: the review
* itself. Nothing can be fetched for them, so a strict recency order fills this
* strip with anonymous npubs while a named review sits a few rows below the
* cut. This is the home page shop window, which already drops junk bodies, so
* it prefers a review a reader can attach a person to. Nothing is hidden:
* every review is on its mint page, and PROMOTE_MAX_AGE_S keeps "latest"
* meaning latest.
*/
candidates.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
const cutoff = Math.floor(Date.now() / 1000) - PROMOTE_MAX_AGE_S;
const promoted = (review: BuildReview): number =>
review.profile?.found && review.created_at >= cutoff ? 1 : 0;
candidates.sort((a, b) => promoted(b) - promoted(a) || b.created_at - a.created_at);
return candidates.slice(0, limit);
} catch {
+17 -3
View File
@@ -37,8 +37,16 @@ export interface MintRef {
}
export interface CardOptions {
/** Adds the transient publishing note for a review published in this session. */
/** Adds the "propagating to relays" note, for a review published in this session. */
propagating?: boolean;
/**
* How many relays took it, when the review was published in this session.
*
* Only said out loud when some relay refused: "published to 2 of 4" is a fact the
* author can act on (try again later, and it is already on the network), where a
* clean 4 of 4 is just noise on the card.
*/
publishedTo?: { accepted: number; total: number } | null;
/** Adds a link to the mint being reviewed. */
mint?: MintRef | null;
/**
@@ -295,9 +303,15 @@ function actionsHtml(review: LoadedReview, f: Formatters, options: CardOptions):
/* ---------- the card ---------- */
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
// The note pulses gently for a few seconds after a review is published.
// The note pulses gently while the review is still in flight. It stops the moment
// the relays echo the review back, because then the card is redrawn without it.
const relays = options.publishedTo;
const partial =
relays && relays.accepted < relays.total
? f.t('reviews.publishedPartial', { accepted: relays.accepted, total: relays.total })
: f.t('reviews.published');
const propagating = options.propagating
? `<p class="rev-note propagating">${escapeHtml(f.t('reviews.propagating'))}</p>`
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
: '';
// The anchor id only for real event ids, so `#review-{id}` deep links resolve and
-3
View File
@@ -98,9 +98,6 @@ export const FIXTURE_MINT: MintDetail = {
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
info: null,
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
// Both NUTs published and neither switched off, so this fixture draws no card chip —
// which is what a representative healthy mint should look like.
capabilities: { mintDisabled: false, meltDisabled: false, mintPublished: true, meltPublished: true },
first_seen: daysAgo(420),
last_probe: daysAgo(0),
updated_at: daysAgo(0),
+3 -43
View File
@@ -138,7 +138,7 @@ const schema = [
)
}
<div class="mint-grid" data-mint-grid="fedimint">
<div class="mint-grid" data-mint-grid>
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
</div>
@@ -247,7 +247,6 @@ const schema = [
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -259,7 +258,7 @@ const schema = [
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid="fedimint"]');
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
@@ -268,15 +267,7 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -424,37 +415,6 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'fedimint',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
+8 -74
View File
@@ -100,18 +100,7 @@ const signedBody = t('home.why.signed.body', {
});
---
{/*
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
links, so those strings have to reach the browser; 1,300 mint pages have no use for
them. See `clientCatalog` in src/i18n/index.ts.
*/}
<Base
title={t('home.title')}
description={description}
current="mints"
clientNamespaces={['home']}
>
<Base title={t('home.title')} description={description} current="mints">
{/*
The hero sits on an animated colour field: ColorBends from React Bits, ported to
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
@@ -191,11 +180,9 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
<span class="sec-sub">{t('home.top.sub')}</span>
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
{t('home.top.all', { n: mints.length })}
</a>
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
</div>
<div class="mint-grid" data-home-grid="cashu">
<div class="mint-grid">
{top.map((mint, i) => (
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
))}
@@ -213,11 +200,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
<a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
<a class="sec-link" href={localePath('/fedimints', locale)}>
{t('home.topFedimints.all', { n: federations.length })}
</a>
</div>
<div class="mint-grid" data-home-grid="fedimint">
<div class="mint-grid">
{topFederations.map((federation, i) => (
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
))}
@@ -238,11 +225,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
{t('home.topLnurl.all', { n: stats.lnurl_total })}
</a>
</div>
<div class="mint-grid" data-home-grid="lnurl">
<div class="mint-grid">
{topLnurl.map((mint, i) => (
<MintCard
mint={mint}
@@ -628,67 +615,14 @@ const signedBody = t('home.why.signed.body', {
</style>
<script>
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
/** One card every six seconds, until the reader touches the track. */
const AUTO_MS = 6000;
/** Matches the track's CSS gap. */
const GAP = 16;
/**
* The three top-six strips, and the "All N" link over each of them.
*
* The home page prerenders six cards per ecosystem and a count beside them, from the
* same API the index pages read. Between builds the strips went stale in two ways at
* once: a new mint could not appear in the top six however good it was, and the count
* beside the link said how many mints existed at 03:30. Both are one fetch away.
*
* The key is the catalog string for that link, which is why this page inlines the
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
*
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
* cards get real rating distributions from a per-mint detail fetch at build time, and
* the list payload has no distribution in it. A refreshed card falls back to the
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
*/
const STRIPS = [
{ type: 'cashu', key: 'home.top.all' },
{ type: 'fedimint', key: 'home.topFedimints.all' },
{ type: 'lnurl', key: 'home.topLnurl.all' },
] as const;
const hydrateStrips = (): void => {
const t = useI18n();
for (const strip of STRIPS) {
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
// A section with nothing in it is not rendered at all, and a strip that was empty
// at build time stays empty until the next one: there is no heading to hang cards
// under. Rare, and not worth building a section in JavaScript for.
if (!grid) continue;
void hydrateMintGrid({
type: strip.type,
grid,
limit: 6,
// The reveal delay the build gives these six, so a refreshed strip arrives the
// same way the prerendered one did.
revealDelayStep: 50,
onReplaced: (_cards, all) => {
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
if (link) swapText(link, t(strip.key, { n: all.length }));
},
});
}
};
const setup = (): void => {
requestAnimationFrame(() => hydrateStrips());
// No track at all means the relays gave the build nothing, and the section is
// showing its empty state instead.
const found = {
+24 -64
View File
@@ -2,7 +2,8 @@
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro';
import { fetchLnurlMints } from '../../lib/api';
import type { LnurlDetail } from '@cashumints/shared';
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo';
@@ -31,18 +32,26 @@ const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale);
/*
One request, no N+1.
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
purely to read the two probed facts behind the "no withdrawals" and "no mint / melt"
chips: an advertised withdraw ceiling of zero, and an unreachable Lightning node.
`MintListItem` carries both now, exactly as it carries the Cashu NUT switches, so the
chips come off the same payload as everything else — which is what makes the hydration
below possible without an N+1 in every visitor's browser.
*/
const mints = await fetchLnurlMints();
/*
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
chips need each mint's detail. One fetch per mint, at build time, against the API on
the same machine — exactly what /mints does for its NUT switches. A mint whose detail
cannot be read simply gets no chip.
*/
const details = await Promise.all(
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
);
const chipSources = details.map((detail: LnurlDetail | null) =>
detail
? {
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
funding_available: detail.funding_available ?? null,
}
: null,
);
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
@@ -142,17 +151,8 @@ const schema = [
{t('lnurlMints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid="lnurl">
{mints.map((mint, i) => (
<MintCard
mint={mint}
rank={i + 1}
lnurl={{
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}}
/>
))}
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
</div>
<p class="no-results" data-no-results hidden>
@@ -258,7 +258,6 @@ const schema = [
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -272,7 +271,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid="lnurl"]');
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
@@ -281,15 +280,7 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -440,37 +431,6 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'lnurl',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
+17 -55
View File
@@ -2,7 +2,8 @@
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro';
import { fetchMints } from '../../lib/api';
import { readCapabilities } from '@cashumints/shared';
import { fetchMint, fetchMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo';
@@ -19,18 +20,19 @@ const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale);
/*
One request, no N+1.
This used to be `fetchMints()` followed by one `fetchMint(host)` per mint, purely to
read the two NUT switches behind the "melt only" and "frozen" chips. `MintListItem`
carries `capabilities` now, so the chips come off the same payload as everything else.
That was worth doing for the build — fifty-six requests down to one — and it was
necessary for the hydration below, which does the same read in every visitor's browser
and could not have done it fifty-six times.
*/
const mints = await fetchMints();
/*
The list payload carries no NUT information, so the "melt only" and "frozen" chips
need each mint's cached info. One detail fetch per mint, at build time, against the
API on the same machine: the mint pages already do exactly this in getStaticPaths.
A mint whose detail cannot be read simply gets no chip.
*/
const capabilities = await Promise.all(
mints.map((mint) =>
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
),
);
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
@@ -125,8 +127,8 @@ const schema = [
{t('mints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid="cashu">
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
</div>
<p class="no-results" data-no-results hidden>
@@ -230,7 +232,6 @@ const schema = [
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -244,7 +245,7 @@ const schema = [
// `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid="cashu"]');
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
@@ -253,15 +254,7 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -412,37 +405,6 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'cashu',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
-9
View File
@@ -208,15 +208,6 @@ export function armSharedTransitions(): void {
* only does its work at module scope is dead after the first navigation. It does fire
* `astro:page-load` on every arrival, including the first, but on the first it waits
* for `window.load`, which is far too late to be the only trigger. Hence both.
*
* The listener is never removed, on purpose: a layout island (the language switcher,
* the login dialog) has DOM on every page, and this is what keeps it alive. The flip
* side is that `setup` runs on *every* page the router swaps in, not only pages that
* include the script. A page-level script must therefore look its DOM up by a marker
* unique to that page — `data-mint-grid="cashu"`, `data-home-grid="fedimint"` — and do
* nothing when the marker is absent. A marker shared between pages means a stale page's
* setup finds the current page's DOM and rewrites it: the listing pages once flashed
* back to the previous ecosystem's mints for exactly that reason.
*/
export function onReady(setup: () => void): void {
let done = false;
+1 -6
View File
@@ -1268,13 +1268,8 @@ main { flex: 1; }
0%, 25% { opacity: .85; }
100% { opacity: 0; }
}
/* Visible for a few seconds after a review is published from this tab. */
/* Still travelling. Stops when the relays echo the review back on the next load. */
.rev-note.propagating { animation: soft-pulse 2s var(--ease-in-out) infinite; }
.rev-note.propagating.is-out {
animation: none;
opacity: 0;
transition: opacity var(--dur-quick) var(--ease-out);
}
/* ---------- status dot ---------- */
.status-dot { position: relative; }
-60
View File
@@ -1,60 +0,0 @@
/**
* The three listing pages must each own their grid.
*
* `<ClientRouter />` swaps pages without reloading, and `onReady` in `scripts/reveal.ts`
* re-runs every page's setup on every arrival — deliberately, since that is what keeps
* the layout islands alive. So once `/mints` has been visited, its setup also runs on
* `/fedimints`. If both pages mark their grid the same way, the stale cashu setup finds
* the fedimint grid, fetches `?type=cashu` and rewrites it: the page shows fedimints for
* a moment, then flashes back to cashu mints. That happened.
*
* The guard is that each page's marker carries its ecosystem, and its script only looks
* for its own. This checks the template, the query and the hydrate call agree, and that
* no bare `data-mint-grid` has crept back in. Plain node, source read as text, like
* `i18n-wiring.test.mjs`.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/pages/[...locale]');
const read = (file) => readFileSync(path.join(pagesDir, file), 'utf8');
const LISTINGS = [
['mints.astro', 'cashu'],
['fedimints.astro', 'fedimint'],
['lnurl-mints.astro', 'lnurl'],
];
for (const [file, type] of LISTINGS) {
test(`${file} marks, queries and hydrates its grid as ${type}`, () => {
const source = read(file);
assert.match(
source,
new RegExp(`<div class="mint-grid" data-mint-grid="${type}">`),
`${file}: template grid is not marked data-mint-grid="${type}"`,
);
assert.match(
source,
new RegExp(`querySelector<HTMLElement>\\('\\[data-mint-grid="${type}"\\]'\\)`),
`${file}: script does not query [data-mint-grid="${type}"]`,
);
assert.match(
source,
new RegExp(`type: '${type}',`),
`${file}: hydrateMintGrid is not called with type '${type}'`,
);
// A bare marker, in either the template or the query, is the bug coming back.
assert.doesNotMatch(source, /data-mint-grid[>\s]/, `${file}: bare data-mint-grid in template`);
assert.doesNotMatch(source, /\[data-mint-grid\]/, `${file}: bare [data-mint-grid] selector`);
});
}
test('the three listings use three different markers', () => {
const types = new Set(LISTINGS.map(([, type]) => type));
assert.equal(types.size, LISTINGS.length);
});