Ship LNURL mint pages, indexing UI, and reviews rewrite.

Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:43 +02:00
co-authored by Cursor
parent 2a9444942b
commit c74c7fc187
66 changed files with 7603 additions and 1241 deletions
+60 -1
View File
@@ -264,6 +264,16 @@ CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offli
`CHECK_DB_URL` does the same for `pnpm --filter ./api test`, which then runs the
review-dedupe SQL against both backends instead of just SQLite.
On-demand indexing. The address rules that keep `POST /api/index` from being an SSRF
hole, the redirect hops, the slug collapse that stops one mint becoming two rows, the
invite-code decoder and the rate limiter. Nothing here touches the network: the resolver
and the fetch are both injected, because a rule that can only be exercised against the
real internet stops being exercised the first time CI runs offline.
```bash
pnpm --filter ./api test:index
```
Type checking across the workspace:
```bash
@@ -537,6 +547,7 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
| `PROBE_TIMEOUT_MS` | `5000` | Per-mint request timeout |
| `FEDIMINT_OBSERVER_URL` | `https://observer.fedimint.org/api/federations` | Where federation health is read from. Empty disables the lookup, and every federation stays `announced`. See [Ecosystems](#ecosystems). |
| `SCORE_PRIOR_MEAN` | `3` | Bayesian prior. See "Ranking" below before changing. |
| `INDEX_RATE_LIMIT` | `10` | `POST /api/index` submissions allowed per address per hour |
### Web (`web/`)
@@ -657,7 +668,7 @@ or copy refers to it, and it is planned as a later stage rather than half-built
## API
Four endpoints, CORS open, no auth.
Five endpoints, CORS open, no auth. Four read; the fifth writes.
| Endpoint | Notes |
| -------------------- | ------------------------------------------------------------------ |
@@ -665,9 +676,54 @@ Four endpoints, CORS open, no auth.
| `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. |
| `POST /api/index` | Index a mint nobody has announced yet. Rate limited. See below. |
`/icons/*` serves the cached mint icons.
### Indexing on demand
```bash
curl -X POST https://cashumints.space/api/index \
-H 'content-type: application/json' \
-d '{"type":"lnurl","input":"mint.600.wtf"}'
```
`type` is `cashu`, `fedimint` or `lnurl`; `input` is a URL, or an invite code for a
federation. The site itself calls this from two places: the 404 page, when somebody opens
`/mint/…`, `/fedimint/…` or `/lnurl-mint/…` for something this build has never heard of,
and the "Write a review" dialog on the three index pages.
What it does, in order, stopping at the first step that settles it:
1. **Already indexed** — `200` with the ordinary `GET /api/mints/:host` payload plus
`"existing": true`. Nothing is probed; a submission is not a reason to re-probe.
2. **Answers as what it claims** — one request, the standard `PROBE_TIMEOUT_MS`. The row
is written by the same functions the probe cycle uses, so it is indistinguishable from
one the loop created. `201`, same payload shape, plus `"indexed_from": "probe"`.
3. **Answers as something else** — `422` with `error: "wrong_type"` and `detected_type`,
which is what lets the dialog offer "this is a Cashu mint, review it there" as a
button. A federation's invite code is decoded rather than fetched: there is no
endpoint to probe.
4. **Nothing answers** — one bounded relay lookup (3s) for a NIP-87 announcement or any
review naming the address. Found: the row is written from the announcement with
`status = offline`, which is the mint that rugged last week and is exactly the one
somebody wants to review. Nothing anywhere: `404`, `error: "unverifiable"`.
Everything indexed this way is an ordinary row afterwards: the probe loop owns it from
the next cycle.
Because it fetches an address a stranger chose, it is hardened accordingly (`api/src/safe-fetch.ts`):
https only, DNS resolved and every returned address checked against the private, loopback,
link-local, CGNAT and multicast ranges *before* a socket opens, redirects followed by hand
and capped at two with every hop re-checked, a 256KB response cap, and a per-IP limit of
`INDEX_RATE_LIMIT` an hour with a clean `429`. Two simultaneous submissions of one address
share a single probe. `pnpm --filter ./api test:index` covers all of it without a network.
The limit needs to be able to tell two visitors apart, so behind the nginx block below add
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` to `location /api/`. The
last entry of that header is the one used, because it is the one the proxy vouched for;
the header is ignored entirely when the peer is not loopback.
### Ranking
Bayesian weighted rating:
@@ -792,6 +848,9 @@ server {
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ {
proxy_pass http://127.0.0.1:8787;
# POST /api/index is rate limited per address, and without this every visitor
# arrives as 127.0.0.1 and shares one budget.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /icons/ {