diff --git a/README.md b/README.md
index f46ad90..eea5dfa 100644
--- a/README.md
+++ b/README.md
@@ -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/ {
diff --git a/web/og-fixtures/_thumbs.png b/web/og-fixtures/_thumbs.png
new file mode 100644
index 0000000..d96c2ed
Binary files /dev/null and b/web/og-fixtures/_thumbs.png differ
diff --git a/web/og-fixtures/fixture-lnurl-no-withdrawals.png b/web/og-fixtures/fixture-lnurl-no-withdrawals.png
new file mode 100644
index 0000000..b80b72f
Binary files /dev/null and b/web/og-fixtures/fixture-lnurl-no-withdrawals.png differ
diff --git a/web/og-fixtures/fixture-lnurl-offline.png b/web/og-fixtures/fixture-lnurl-offline.png
new file mode 100644
index 0000000..b546d86
Binary files /dev/null and b/web/og-fixtures/fixture-lnurl-offline.png differ
diff --git a/web/og-fixtures/fixture-lnurl-online.png b/web/og-fixtures/fixture-lnurl-online.png
new file mode 100644
index 0000000..bf3ddd5
Binary files /dev/null and b/web/og-fixtures/fixture-lnurl-online.png differ
diff --git a/web/scripts/og/build-og.mjs b/web/scripts/og/build-og.mjs
index b6f498a..01eb215 100644
--- a/web/scripts/og/build-og.mjs
+++ b/web/scripts/og/build-og.mjs
@@ -28,7 +28,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
import { renderSvg } from './card.mjs';
-import { cashuModel, defaultModel, fedimintModel } from './model.mjs';
+import { cashuModel, defaultModel, fedimintModel, lnurlModel } from './model.mjs';
import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -96,7 +96,9 @@ async function buildFixtures() {
const model =
raw.type === 'fedimint'
? fedimintModel(raw, { now: FIXTURE_NOW })
- : cashuModel(raw, { now: FIXTURE_NOW });
+ : raw.type === 'lnurl'
+ ? lnurlModel(raw, { now: FIXTURE_NOW })
+ : cashuModel(raw, { now: FIXTURE_NOW });
const png = await renderPng(model, null);
const file = path.join(dir, `${raw.host}.png`);
await writeFile(file, png);
@@ -114,9 +116,10 @@ async function build() {
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
const previousImages = previous.images ?? {};
- const [cashu, fedimint, stats] = await Promise.all([
+ const [cashu, fedimint, lnurl, stats] = await Promise.all([
getJson('/api/mints?type=cashu'),
getJson('/api/mints?type=fedimint'),
+ getJson('/api/mints?type=lnurl'),
getJson('/api/stats'),
]);
@@ -135,6 +138,7 @@ async function build() {
const listings = [
...cashu.map((m) => ({ listing: m, kind: 'cashu' })),
...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })),
+ ...lnurl.map((m) => ({ listing: m, kind: 'lnurl' })),
];
// A handful at a time: satori renders in milliseconds, the icon fetches are local,
@@ -158,7 +162,9 @@ async function build() {
const model =
kind === 'fedimint'
? fedimintModel(detail, { iconHash: icon?.hash ?? null, now })
- : cashuModel(detail, { iconHash: icon?.hash ?? null, now });
+ : kind === 'lnurl'
+ ? lnurlModel(detail, { iconHash: icon?.hash ?? null, now })
+ : cashuModel(detail, { iconHash: icon?.hash ?? null, now });
const hash = hashModel(model);
const file = `${model.slug}.${hash}.png`;
diff --git a/web/scripts/og/card.mjs b/web/scripts/og/card.mjs
index bb289ae..241f246 100644
--- a/web/scripts/og/card.mjs
+++ b/web/scripts/og/card.mjs
@@ -29,6 +29,8 @@ const AMBER = '#F0A93B';
const VIOLET = '#A78BFA';
const MINT = '#5FD68F';
const RED = '#E06450';
+/** The LNURL ecosystem label. The only cool blue here — see DESIGN.md. */
+const LNURL = '#4EA8DE';
const GROTESK = 'Space Grotesk';
const INTER = 'Inter';
@@ -137,22 +139,29 @@ function statusChip(model) {
}
}
-/** The type badge that makes a federation card unmistakable at thumbnail size. */
-function fedimintBadge() {
+/**
+ * The type badge that makes a non-Cashu card unmistakable at thumbnail size.
+ *
+ * Three things separate the three ecosystems at 300px wide, and the badge is only one
+ * of them: the word itself, the badge's colour, and the glow behind the whole card all
+ * change together. Cashu gets no badge at all, which is its own signal — it is the
+ * default and the majority of the index.
+ */
+function typeBadge(label, color, borderColor) {
return h(
'div',
{
display: 'flex',
padding: '9px 14px',
borderRadius: '9px',
- border: `1.5px solid rgba(167,139,250,.5)`,
- color: VIOLET,
+ border: `1.5px solid ${borderColor}`,
+ color,
fontFamily: MONO,
fontWeight: 500,
fontSize: '18px',
letterSpacing: '3px',
},
- 'FEDIMINT',
+ label,
);
}
@@ -380,15 +389,86 @@ function thirdCellFedimint(model) {
);
}
-/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
+/**
+ * Third cell, LNURL: the withdraw range — never NUT limits, never a module list.
+ *
+ * This is the number that answers an LNURL reader's actual question: how much can one
+ * of this mint's notes be worth? It is shown in sats, converted from millisatoshi in
+ * the model, and it is a *range* rather than a pair of independent bounds, which is
+ * why the two rows read "min"/"max" the way the Cashu limits cell does.
+ *
+ * The lightning address takes the space the Fedimint cell gives its network badge: it
+ * is the one string on the whole card someone might copy out of a screenshot.
+ */
+function thirdCellLnurl(model) {
+ const base = {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1.1,
+ padding: '30px 36px',
+ borderLeft: `1px solid ${LINE_SOFT}`,
+ };
+
+ if (model.warning) {
+ const color = model.warning.severity === 'critical' ? RED : AMBER;
+ return h(
+ 'div',
+ { ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
+ cellLabel('WARNING', color),
+ h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '34px', lineHeight: 1.05, color }, model.warning.label),
+ h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
+ );
+ }
+
+ const lines = [];
+ if (model.withdraw) {
+ if (model.withdraw.min !== null) lines.push(['min', `${compact(model.withdraw.min)} sat`]);
+ if (model.withdraw.max !== null) lines.push(['max', `${compact(model.withdraw.max)} sat`]);
+ }
+
+ return h(
+ 'div',
+ base,
+ cellLabel('WITHDRAW'),
+ lines.length === 0
+ ? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
+ : h(
+ 'div',
+ { display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
+ ...lines.map(([k, v]) =>
+ h(
+ 'div',
+ { display: 'flex', alignItems: 'baseline', gap: '14px' },
+ h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
+ h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
+ ),
+ ),
+ ),
+ model.address &&
+ h(
+ 'div',
+ { fontFamily: MONO, fontWeight: 400, fontSize: '18px', color: FAINT, marginTop: '16px', maxWidth: '300px' },
+ fitMono(model.address, 30),
+ ),
+ );
+}
+
+/** The background glow: amber healthy, red for trouble, violet Fedimint, blue LNURL. */
function glow(model) {
const danger =
- model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical');
+ model.status === 'offline' ||
+ (model.type !== 'fedimint' && model.warning && model.warning.severity === 'critical');
const color = danger
? 'rgba(224,100,80,.13)'
: model.type === 'fedimint'
? 'rgba(167,139,250,.11)'
- : 'rgba(240,169,59,.10)';
+ : model.type === 'lnurl'
+ // Deliberately the strongest of the three. Amber and violet only have to
+ // separate a card from the site's own background; this one has to separate an
+ // LNURL card from a Fedimint card at 300px, where the badge text is a smudge
+ // and the tint is the first thing a reader actually resolves.
+ ? 'rgba(78,168,222,.20)'
+ : 'rgba(240,169,59,.10)';
return h('div', {
position: 'absolute',
top: '-260px',
@@ -414,10 +494,12 @@ function brandRow(tagline) {
/** One mint or federation card. `iconData` is a data: URI or null. */
export function mintCard(model, iconData) {
const isFed = model.type === 'fedimint';
+ const isLnurl = model.type === 'lnurl';
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
- // Room beside the icon: canvas minus padding, icon, gaps and the chips.
- const chipRoom = 240 + (isFed ? 180 : 0);
+ // Room beside the icon: canvas minus padding, icon, gaps and the chips. A badge is
+ // ~180px for FEDIMINT and ~120px for the shorter LNURL.
+ const chipRoom = 240 + (isFed ? 180 : isLnurl ? 120 : 0);
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
return h(
@@ -433,7 +515,7 @@ export function mintCard(model, iconData) {
fontFamily: INTER,
},
glow(model),
- brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
+ brandRow(isFed ? 'Fedimint reviews' : isLnurl ? 'LNURL mint reviews' : 'Cashu mint reviews'),
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
@@ -459,7 +541,8 @@ export function mintCard(model, iconData) {
model.name,
),
statusChip(model),
- isFed && fedimintBadge(),
+ isFed && typeBadge('FEDIMINT', VIOLET, 'rgba(167,139,250,.5)'),
+ isLnurl && typeBadge('LNURL', LNURL, 'rgba(78,168,222,.5)'),
),
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine),
// The Fedimint soft warning is a muted line, deliberately not a red cell.
@@ -479,7 +562,7 @@ export function mintCard(model, iconData) {
},
ratingCell(model),
reviewsCell(model),
- isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
+ isFed ? thirdCellFedimint(model) : isLnurl ? thirdCellLnurl(model) : thirdCellCashu(model),
),
);
}
@@ -525,13 +608,16 @@ export function defaultCard(model) {
h(
'div',
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
- 'Cashu mint and Fedimint reviews, signed on Nostr',
+ 'Cashu, Fedimint and LNURL mint reviews, signed on Nostr',
),
h(
'div',
- { display: 'flex', gap: '86px', marginTop: '64px' },
+ // Four stats where there were three, so the gap tightens rather than the row
+ // running past the card's own margins.
+ { display: 'flex', gap: '64px', marginTop: '64px' },
stat(model.mints, 'Cashu mints'),
stat(model.federations, 'federations'),
+ stat(model.lnurl, 'LNURL mints'),
stat(model.reviews, 'signed reviews'),
),
);
diff --git a/web/scripts/og/fixtures.mjs b/web/scripts/og/fixtures.mjs
index 19ad955..6f3b930 100644
--- a/web/scripts/og/fixtures.mjs
+++ b/web/scripts/og/fixtures.mjs
@@ -1,7 +1,7 @@
/**
* The edge cases the template must survive, as synthetic API payloads.
*
- * Run through the same cashuModel/fedimintModel derivation as real data — a fixture
+ * Run through the same cashuModel/fedimintModel/lnurlModel derivation as real data — a fixture
* that bypassed the model would test a card no build can produce. `NOW` is pinned so
* the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as
* eyeball snapshots.
@@ -32,6 +32,31 @@ const base = {
},
};
+
+/**
+ * The shared shape of an LNURL row's detail payload, as `GET /api/mints/:host` returns
+ * it: the ecosystem fields flattened across the top level, millisatoshi on the wire.
+ */
+const lnurlBase = {
+ type: 'lnurl',
+ status: 'online',
+ last_online: NOW - 300,
+ last_probe: NOW - 300,
+ first_seen: NOW - 200 * 86400,
+ review_count: 9,
+ rating_avg: 4.4,
+ rating_distribution: dist(6, 2, 1),
+ last_review_at: NOW - 20 * 86400,
+ features: ['mint', 'melt', 'rotate', 'split', 'merge', 'lud06', 'lud03', 'lud16', 'signed-notes'],
+ network: 'mainnet',
+ mint_pubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
+ funding_available: true,
+ invalid_reason: null,
+ min_withdrawable_msat: 5_000,
+ max_withdrawable_msat: 999_899_000,
+ lightning_address: 'mint@lnurl.example',
+};
+
export const FIXTURES = [
{
// A name at the 30-character mark: must step down, never clip.
@@ -109,4 +134,42 @@ export const FIXTURES = [
network: 'mainnet',
status_source: null,
},
+ {
+ // The healthy LNURL card: blue glow, LNURL badge, a withdraw range in the third
+ // cell, and the lightning address under it.
+ ...lnurlBase,
+ host: 'fixture-lnurl-online',
+ url: 'lnurl:https://lnurl.example',
+ base_url: 'https://lnurl.example',
+ name: null,
+ icon: null,
+ },
+ {
+ // Offline 20 days: red glow beats the blue one, warning cell replaces the range.
+ // The blue badge stays, because which ecosystem it is has not changed.
+ ...lnurlBase,
+ host: 'fixture-lnurl-offline',
+ url: 'lnurl:https://lnurl.gone.example',
+ base_url: 'https://lnurl.gone.example',
+ name: 'Departed Notes',
+ icon: null,
+ status: 'offline',
+ last_online: NOW - 20 * 86400,
+ rating_avg: 2.1,
+ rating_distribution: dist(0, 1, 1, 3, 5),
+ review_count: 10,
+ },
+ {
+ // A withdraw ceiling of zero, while ONLINE: green status chip stays, and the
+ // critical warning cell appears anyway. The one card that must never print
+ // "max 0 sat" as though it were a limit.
+ ...lnurlBase,
+ host: 'fixture-lnurl-no-withdrawals',
+ url: 'lnurl:https://lnurl.locked.example',
+ base_url: 'https://lnurl.locked.example',
+ name: 'Locked Notes Mint',
+ icon: null,
+ min_withdrawable_msat: 0,
+ max_withdrawable_msat: 0,
+ },
];
diff --git a/web/scripts/og/model.mjs b/web/scripts/og/model.mjs
index 819e623..441bbd6 100644
--- a/web/scripts/og/model.mjs
+++ b/web/scripts/og/model.mjs
@@ -14,10 +14,15 @@
* The warning logic is imported from shared/, not restated: the image must never
* disagree with the page it decorates about whether a mint is frozen or gone.
*/
-import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared';
+import { getMintWarnings, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
-/** Bump when the template's layout or copy changes, to regenerate every image. */
-export const TEMPLATE_VERSION = 1;
+/**
+ * Bump when the template's layout or copy changes, to regenerate every image.
+ *
+ * 2: the LNURL variant. Its badge, glow and third cell are new, and the shared
+ * `warningCell` learned three LNURL warning kinds, so every card's hash input moved.
+ */
+export const TEMPLATE_VERSION = 2;
const DAY = 86_400;
@@ -115,6 +120,11 @@ function warningCell(warning, mint, now) {
'offline-long': 'Offline',
'offline': 'Offline',
'fedimint-offline': 'Reported offline',
+ // LNURL. `lnurl-withdrawals-disabled` gets the same two words as the Cashu
+ // melt-disabled cell, because it is the same statement to a reader.
+ 'lnurl-withdrawals-disabled': 'No withdrawals',
+ 'lnurl-no-funding': 'No mint / melt',
+ 'lnurl-invalid': 'Not responding properly',
}[warning.kind];
if (!label) return null;
@@ -135,11 +145,26 @@ function warningCell(warning, mint, now) {
case 'frozen':
sub = 'Nothing moves in or out';
break;
+ case 'lnurl-withdrawals-disabled':
+ sub = 'Notes cannot be redeemed';
+ break;
+ case 'lnurl-no-funding':
+ // The precise half of the story, and the half that stops a reader writing the
+ // mint off: its notes still work, only Lightning is shut.
+ sub = 'Notes still rotate and split';
+ break;
+ case 'lnurl-invalid':
+ sub = 'Endpoint answered with junk';
+ break;
}
// The mint is also offline while a capability warning outranks that fact: the cell
// still has to say so, the way the page banner folds it into its last sentence.
- if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
+ if (
+ ['melt-only', 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled'].includes(warning.kind) &&
+ mint.status === 'offline' &&
+ since !== null
+ ) {
sub = `Offline since ${shortDate(since)}`;
}
@@ -227,6 +252,53 @@ export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now(
};
}
+/**
+ * One LNURL mint's card model.
+ *
+ * The same skeleton as the other two, with the third cell answering this ecosystem's
+ * own question: how much can one of this mint's bearer notes be worth? That is the
+ * withdraw range, in sats, converted here once from the millisatoshi the wire carries.
+ *
+ * `withdraw` is null when the ceiling is zero, and deliberately: a card printing
+ * "max 0 sat" reads as a number rather than as a refusal, and the warning cell that
+ * outranks it says the true thing instead.
+ */
+export function lnurlModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
+ const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
+ const domain = displayDomain(baseUrl);
+ const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
+ const warning = getMintWarnings(mint, { now })[0] ?? null;
+ const offline = mint.status === 'offline';
+ const offlineDays =
+ offline && mint.last_online !== null
+ ? Math.max(0, Math.floor((now - mint.last_online) / DAY))
+ : null;
+
+ const min = msatToSat(mint.min_withdrawable_msat ?? null);
+ const max = msatToSat(mint.max_withdrawable_msat ?? null);
+
+ return {
+ v: TEMPLATE_VERSION,
+ type: 'lnurl',
+ slug: mint.host,
+ name,
+ domain,
+ status: mint.status,
+ offlineDays,
+ rating: mint.rating_avg,
+ reviewCount: mint.review_count,
+ sentiment: sentiment(mint.rating_distribution ?? {}),
+ lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
+ withdraw: max !== null && max > 0 ? { min, max } : null,
+ /** The LUD-16 address, which is the one string a reader might actually copy. */
+ address: mint.lightning_address ?? null,
+ warning: warningCell(warning, mint, now),
+ iconHash,
+ gradient: iconHash ? null : iconGradient(domain),
+ initials: iconHash ? null : initials(name),
+ };
+}
+
/** The default card: brand, tagline, and the network's own numbers. */
export function defaultModel(stats) {
return {
@@ -235,6 +307,7 @@ export function defaultModel(stats) {
slug: 'default',
mints: stats.cashu_total,
federations: stats.fedimint_total,
+ lnurl: stats.lnurl_total,
reviews: stats.reviews_total,
};
}
diff --git a/web/scripts/og/thumbs.mjs b/web/scripts/og/thumbs.mjs
new file mode 100644
index 0000000..3b55314
--- /dev/null
+++ b/web/scripts/og/thumbs.mjs
@@ -0,0 +1,46 @@
+/**
+ * A contact sheet of the three ecosystems' cards at thumbnail size.
+ *
+ * Not part of the build: run it by hand (`node scripts/og/thumbs.mjs`) to check the
+ * one thing a full-size render cannot show you — whether a reader scrolling a timeline
+ * can tell a Cashu card from a Fedimint one from an LNURL one before any text is
+ * legible. Writes og-fixtures/_thumbs.png.
+ */
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { Resvg } from '@resvg/resvg-js';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const dir = path.resolve(here, '../../og-fixtures');
+
+/** 300px wide: roughly how a link preview renders in a feed. */
+const W = 300;
+const H = Math.round((630 / 1200) * W);
+const GAP = 16;
+const LABEL = 22;
+
+const cards = [
+ ['Cashu', 'fixture-no-icon.png'],
+ ['Fedimint', 'fixture-fed-announced.png'],
+ ['LNURL', 'fixture-lnurl-online.png'],
+];
+
+const parts = await Promise.all(
+ cards.map(async ([label, file], i) => {
+ const data = await readFile(path.join(dir, file));
+ const x = i * (W + GAP);
+ return (
+ `` +
+ `${label}`
+ );
+ }),
+);
+
+const svg =
+ ``;
+
+await writeFile(path.join(dir, '_thumbs.png'), new Resvg(svg).render().asPng());
+console.log(`og: og-fixtures/_thumbs.png (${cards.length} cards at ${W}px)`);
diff --git a/web/src/bones/mint-page.bones.json b/web/src/bones/mint-page.bones.json
index 114c06b..b515a9b 100644
--- a/web/src/bones/mint-page.bones.json
+++ b/web/src/bones/mint-page.bones.json
@@ -4,7 +4,7 @@
"name": "mint-page",
"viewportWidth": 324,
"width": 324,
- "height": 327,
+ "height": 407,
"bones": [
[
0,
@@ -16,14 +16,14 @@
[
20.9877,
0,
- 59.2593,
+ 54.4078,
43,
8
],
[
20.9877,
43,
- 59.2593,
+ 54.4078,
22,
8
],
@@ -102,8 +102,15 @@
0,
265,
100,
- 63,
+ 84,
8
+ ],
+ [
+ 0,
+ 366,
+ 42.284,
+ 41,
+ 10
]
]
},
@@ -111,7 +118,7 @@
"name": "mint-page",
"viewportWidth": 712,
"width": 712,
- "height": 193,
+ "height": 252,
"bones": [
[
0,
@@ -123,19 +130,19 @@
[
9.5506,
0,
- 26.9663,
+ 24.7586,
43,
8
],
[
9.5506,
43,
- 26.9663,
+ 24.7586,
22,
8
],
[
- 38.764,
+ 36.5564,
18,
10.8146,
29,
@@ -143,7 +150,7 @@
true
],
[
- 40.4494,
+ 38.2417,
29,
0.9831,
7,
@@ -211,6 +218,13 @@
100,
42,
8
+ ],
+ [
+ 0,
+ 211,
+ 19.2416,
+ 41,
+ 10
]
]
},
@@ -218,7 +232,7 @@
"name": "mint-page",
"viewportWidth": 1184,
"width": 1184,
- "height": 172,
+ "height": 231,
"bones": [
[
0,
@@ -230,19 +244,19 @@
[
5.7432,
0,
- 16.2162,
+ 14.8886,
43,
8
],
[
5.7432,
43,
- 16.2162,
+ 14.8886,
22,
8
],
[
- 23.3108,
+ 21.9832,
18,
6.5034,
29,
@@ -250,7 +264,7 @@
true
],
[
- 24.3243,
+ 22.9967,
29,
0.5912,
7,
@@ -318,9 +332,16 @@
100,
21,
8
+ ],
+ [
+ 0,
+ 190,
+ 11.5709,
+ 41,
+ 10
]
]
}
},
- "_hash": "f3f7f7e86a10ecca631b74af17fb0335"
+ "_hash": "e76f6212eab4b1517002712803fae94e"
}
\ No newline at end of file
diff --git a/web/src/bones/reviews-panel.bones.json b/web/src/bones/reviews-panel.bones.json
index 79757f4..b293b1f 100644
--- a/web/src/bones/reviews-panel.bones.json
+++ b/web/src/bones/reviews-panel.bones.json
@@ -4,132 +4,181 @@
"name": "reviews-panel",
"viewportWidth": 322,
"width": 322,
- "height": 727,
+ "height": 876,
"bones": [
[
6.2112,
- 20,
+ 18,
11.8012,
38,
"50%"
],
[
22.9814,
- 19,
+ 17,
32.6087,
22,
8
],
[
22.9814,
- 41,
+ 39,
70.8075,
- 16,
+ 17,
5
],
[
22.9814,
- 70,
+ 68,
20.429,
16,
8
],
[
- 46.5159,
- 68,
+ 45.8948,
+ 66,
+ 0.9317,
+ 20,
+ 8
+ ],
+ [
+ 49.3109,
+ 66,
11.8012,
- 19,
+ 20,
8
],
[
22.9814,
- 98,
+ 96,
70.8075,
- 116,
+ 144,
+ 8
+ ],
+ [
+ 22.9814,
+ 246,
+ 70.8075,
+ 27,
8
],
[
6.2112,
- 255,
+ 310,
11.8012,
38,
"50%"
],
[
22.9814,
- 254,
+ 309,
23.6025,
22,
8
],
[
22.9814,
- 277,
+ 331,
70.8075,
- 16,
+ 17,
5
],
[
22.9814,
- 305,
+ 360,
20.429,
16,
8
],
[
- 46.5159,
- 303,
+ 45.8948,
+ 358,
+ 0.9317,
+ 20,
+ 8
+ ],
+ [
+ 49.3109,
+ 358,
12.1118,
- 19,
+ 20,
8
],
[
22.9814,
- 333,
+ 388,
70.8075,
- 116,
+ 120,
+ 8
+ ],
+ [
+ 22.9814,
+ 514,
+ 70.8075,
+ 27,
8
],
[
6.2112,
- 490,
+ 578,
11.8012,
38,
"50%"
],
[
22.9814,
- 489,
+ 577,
31.0559,
22,
8
],
[
22.9814,
- 512,
+ 599,
70.8075,
- 16,
+ 17,
5
],
[
22.9814,
- 540,
+ 628,
20.429,
16,
8
],
[
- 46.5159,
- 538,
+ 45.8948,
+ 626,
+ 0.9317,
+ 20,
+ 8
+ ],
+ [
+ 49.3109,
+ 626,
12.7329,
- 19,
+ 20,
8
],
[
22.9814,
- 568,
+ 656,
70.8075,
- 139,
+ 144,
+ 8
+ ],
+ [
+ 22.9814,
+ 806,
+ 20.8075,
+ 17,
+ 8
+ ],
+ [
+ 22.9814,
+ 831,
+ 70.8075,
+ 27,
8
]
]
@@ -138,132 +187,174 @@
"name": "reviews-panel",
"viewportWidth": 710,
"width": 710,
- "height": 455,
+ "height": 533,
"bones": [
[
2.8169,
- 20,
+ 18,
5.3521,
38,
"50%"
],
[
10.4225,
- 21,
+ 17,
14.7887,
22,
8
],
[
10.4225,
- 43,
- 75.2421,
- 16,
+ 39,
+ 67.7773,
+ 17,
5
],
[
- 87.9181,
- 22,
+ 80.4533,
+ 21,
9.265,
16,
8
],
[
- 91.831,
- 42,
- 5.3521,
+ 90.5634,
19,
+ 0.4225,
+ 20,
+ 8
+ ],
+ [
+ 91.831,
+ 19,
+ 5.3521,
+ 20,
8
],
[
10.4225,
- 77,
+ 68,
+ 82.3944,
+ 48,
+ 8
+ ],
+ [
+ 10.4225,
+ 124,
86.7606,
- 46,
+ 27,
8
],
[
2.8169,
- 164,
+ 188,
5.3521,
38,
"50%"
],
[
10.4225,
- 165,
+ 187,
10.7042,
22,
8
],
[
10.4225,
- 187,
- 75.2421,
- 16,
+ 209,
+ 67.6364,
+ 17,
5
],
[
- 87.9181,
- 166,
+ 80.3125,
+ 191,
9.265,
16,
8
],
+ [
+ 90.4225,
+ 189,
+ 0.4225,
+ 20,
+ 8
+ ],
[
91.6901,
- 186,
+ 189,
5.493,
- 19,
+ 20,
8
],
[
10.4225,
- 221,
+ 238,
+ 82.3944,
+ 48,
+ 8
+ ],
+ [
+ 10.4225,
+ 294,
86.7606,
- 46,
+ 27,
8
],
[
2.8169,
- 308,
+ 358,
5.3521,
38,
"50%"
],
[
10.4225,
- 309,
+ 357,
14.0845,
22,
8
],
[
10.4225,
- 331,
- 75.2421,
- 16,
+ 379,
+ 67.3548,
+ 17,
5
],
[
- 87.9181,
- 310,
+ 80.0308,
+ 361,
9.265,
16,
8
],
+ [
+ 90.1408,
+ 359,
+ 0.4225,
+ 20,
+ 8
+ ],
[
91.4085,
- 330,
+ 359,
5.7746,
- 19,
+ 20,
8
],
[
10.4225,
- 365,
+ 408,
+ 82.3944,
+ 72,
+ 8
+ ],
+ [
+ 10.4225,
+ 488,
86.7606,
- 70,
+ 27,
8
]
]
@@ -272,136 +363,178 @@
"name": "reviews-panel",
"viewportWidth": 784,
"width": 784,
- "height": 431,
+ "height": 533,
"bones": [
[
2.551,
- 20,
+ 18,
4.8469,
38,
"50%"
],
[
9.4388,
- 21,
+ 17,
13.3929,
22,
8
],
[
9.4388,
- 43,
- 77.5789,
- 16,
+ 39,
+ 70.8187,
+ 17,
5
],
[
- 89.0585,
- 22,
+ 82.2983,
+ 21,
8.3905,
16,
8
],
[
- 92.602,
- 42,
- 4.8469,
+ 91.4541,
19,
+ 0.3827,
+ 20,
+ 8
+ ],
+ [
+ 92.602,
+ 19,
+ 4.8469,
+ 20,
8
],
[
9.4388,
- 77,
+ 68,
+ 74.6173,
+ 48,
+ 8
+ ],
+ [
+ 9.4388,
+ 124,
88.0102,
- 46,
+ 27,
8
],
[
2.551,
- 164,
+ 188,
4.8469,
38,
"50%"
],
[
9.4388,
- 165,
+ 187,
9.6939,
22,
8
],
[
9.4388,
- 187,
- 77.5789,
- 16,
+ 209,
+ 70.6912,
+ 17,
5
],
[
- 89.0585,
- 166,
+ 82.1708,
+ 191,
8.3905,
16,
8
],
+ [
+ 91.3265,
+ 189,
+ 0.3827,
+ 20,
+ 8
+ ],
[
92.4745,
- 186,
+ 189,
4.9745,
- 19,
+ 20,
8
],
[
9.4388,
- 221,
+ 238,
+ 74.6173,
+ 48,
+ 8
+ ],
+ [
+ 9.4388,
+ 294,
88.0102,
- 46,
+ 27,
8
],
[
2.551,
- 308,
+ 358,
4.8469,
38,
"50%"
],
[
9.4388,
- 309,
+ 357,
12.7551,
22,
8
],
[
9.4388,
- 331,
- 77.5789,
- 16,
+ 379,
+ 70.4361,
+ 17,
5
],
[
- 89.0585,
- 310,
+ 81.9157,
+ 361,
8.3905,
16,
8
],
+ [
+ 91.0714,
+ 359,
+ 0.3827,
+ 20,
+ 8
+ ],
[
92.2194,
- 330,
+ 359,
5.2296,
- 19,
+ 20,
8
],
[
9.4388,
- 365,
+ 408,
+ 74.6173,
+ 72,
+ 8
+ ],
+ [
+ 9.4388,
+ 488,
88.0102,
- 46,
+ 27,
8
]
]
}
},
- "_hash": "788093c50463a4ad673ece50c6db52da"
+ "_hash": "d9fb0ac370be8e706ff4dfea00b0081b"
}
\ No newline at end of file
diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro
index 4f2d13d..22023c7 100644
--- a/web/src/components/Footer.astro
+++ b/web/src/components/Footer.astro
@@ -1,5 +1,23 @@
---
+/**
+ * The site footer: one component, every page, three zones.
+ *
+ * Zone one is four columns — who this is, then the two link groups people actually
+ * navigate to, then the language control. Zone two is a thin bar with the copyright
+ * and the credit, and nothing else: the moment anything else moves into that row it
+ * stops being a signature and starts being a fourth navigation surface.
+ *
+ * What used to be here was four flat rows, the last of which was ~20 raw language
+ * links wrapped over two lines. That wall is now the same switcher the header carries
+ * (see LanguageSwitcher.astro, `placement="footer"`), so there is one language control
+ * on the site in two places rather than two controls that happen to agree.
+ *
+ * Everything below is static per locale and prerenders. No hex, no npub, no counts,
+ * nothing fetched: the footer is the one region of every page that must be identical
+ * on the home page and on a mint page that failed to load.
+ */
import LanguageSwitcher from './LanguageSwitcher.astro';
+import Moai from './Moai.astro';
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
@@ -9,23 +27,29 @@ const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
-/**
- * Two rows: the pages people navigate to, then the ones they go looking for. The
- * disclaimer sits in the second row here and again at the bottom of every mint page,
- * which is where the decision it is about actually gets made.
+/*
+ * Route slugs stay English in every language and `localePath` adds the prefix, exactly
+ * as the header does. See src/i18n/routing.ts for why that trade was made.
*/
-const nav = [
- { path: '/mints', label: t('footer.allMints') },
- { path: '/fedimints', label: t('footer.allFedimints') },
- { path: '/reviews', label: t('footer.allReviews') },
- { path: '/wallets', label: t('footer.wallets') },
- { path: '/about', label: t('footer.about') },
+const explore = [
+ { href: localePath('/mints', locale), label: t('footer.allMints') },
+ { href: localePath('/fedimints', locale), label: t('footer.allFedimints') },
+ { href: localePath('/lnurl-mints', locale), label: t('footer.allLnurlMints') },
+ { href: localePath('/reviews', locale), label: t('footer.allReviews') },
+ { href: localePath('/wallets', locale), label: t('footer.wallets') },
];
-const legal = [
- { path: '/disclaimer', label: t('footer.disclaimer') },
- { path: '/terms', label: t('footer.terms') },
- { path: '/privacy', label: t('footer.privacy') },
+/*
+ * GitHub sits in this column rather than beside the credit line because it is a place
+ * to go, not a signature. It is the only external link in the columns, so it carries
+ * `rel="noopener"` and nothing else needs to.
+ */
+const site = [
+ { href: localePath('/about', locale), label: t('footer.about'), external: false },
+ { href: GITHUB, label: 'GitHub', external: true },
+ { href: localePath('/terms', locale), label: t('footer.terms'), external: false },
+ { href: localePath('/privacy', locale), label: t('footer.privacy'), external: false },
+ { href: localePath('/disclaimer', locale), label: t('footer.disclaimer'), external: false },
];
/*
@@ -39,31 +63,65 @@ const madeBy = t('footer.madeBy', {
});
---
-