Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Fedimint federations: what a NIP-87 kind 38173 announcement actually contains, and
|
||||
* the handful of values derived from it that the API, the pages and the islands all
|
||||
* have to agree on.
|
||||
*
|
||||
* Everything here was written against real events pulled off the relay pool this site
|
||||
* already reads, not against a reading of the spec, for the same reason NOTES.md gives
|
||||
* for the Cashu side: what matters is what publishers actually put on relays. Three
|
||||
* places where the two differ, all resolved in favour of the wire:
|
||||
*
|
||||
* - the `n` tag is `bitcoin`, not `mainnet`. `normalizeNetwork` maps it.
|
||||
* - `modules` are protocol short names (`ln`, `mint`, `wallet`, `lnv2`, `meta`,
|
||||
* `stability_pool`), not the prose words. `MODULE_ALIASES` maps them.
|
||||
* - `content` carries `{"federation_name": "..."}` rather than a kind-0 `name`.
|
||||
* `parseFedimintAnnouncement` accepts either.
|
||||
*
|
||||
* A federation has no URL and no HTTP info endpoint, so its identity is the federation
|
||||
* id from the `d` tag and nothing else. `fedimintKey` turns that into the synthetic
|
||||
* primary key its row uses, and `fedimintSlug` into the routing slug.
|
||||
*/
|
||||
import {
|
||||
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
|
||||
} from './nostr.js';
|
||||
|
||||
/** Routing slugs are prefixed so a federation can never collide with a mint host. */
|
||||
export const FEDIMINT_SLUG_PREFIX = 'fed-';
|
||||
|
||||
/** How much of the federation id the slug carries. The full id is in the payload. */
|
||||
export const FEDIMINT_SLUG_CHARS = 16;
|
||||
|
||||
/** The scheme on the synthetic `mints.url` a federation row is keyed by. */
|
||||
export const FEDIMINT_KEY_SCHEME = 'fedimint:';
|
||||
|
||||
/** A federation id is a 32 byte hash, written as 64 hex characters. */
|
||||
export function isFederationId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
/** `fed-` plus the first 16 characters of the federation id. */
|
||||
export function fedimintSlug(federationId: string): string {
|
||||
return FEDIMINT_SLUG_PREFIX + federationId.toLowerCase().slice(0, FEDIMINT_SLUG_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary key a federation row uses in place of a mint URL.
|
||||
*
|
||||
* `mints.url` is the table's primary key and a federation has no URL to put there. A
|
||||
* `fedimint:` scheme keeps the column non-null and unique, is obviously not something
|
||||
* to fetch, and is what a review row points at.
|
||||
*/
|
||||
export function fedimintKey(federationId: string): string {
|
||||
return FEDIMINT_KEY_SCHEME + federationId.toLowerCase();
|
||||
}
|
||||
|
||||
/** The federation id inside a `fedimint:` key, or null if that is not what this is. */
|
||||
export function federationIdFromKey(key: string): string | null {
|
||||
if (!key.startsWith(FEDIMINT_KEY_SCHEME)) return null;
|
||||
const id = key.slice(FEDIMINT_KEY_SCHEME.length);
|
||||
return isFederationId(id) ? id.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/* ---------- invite codes ---------- */
|
||||
|
||||
/**
|
||||
* A fedimint invite code: bech32m holding the federation id and the guardian addresses
|
||||
* a wallet needs to join. Long, opaque, and the only thing a reader actually copies off
|
||||
* a federation page.
|
||||
*
|
||||
* Every real code starts `fed11`, which is two things and not a typo: `fed1` is the
|
||||
* human-readable part, and the `1` after it is bech32's separator. A first attempt at
|
||||
* this anchored on `fed1` followed by a bech32 data character and rejected every code
|
||||
* on the network, because bech32's alphabet deliberately excludes `1`.
|
||||
*
|
||||
* Past the prefix it is loose on purpose. The code is handed to a wallet verbatim and
|
||||
* nothing in this codebase decodes it, so the check only has to keep junk out of a copy
|
||||
* button — and being stricter than the wallets that consume it would mean dropping a
|
||||
* federation from the site over a character this code has no opinion about.
|
||||
*/
|
||||
export function isInviteCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^fed1[a-z0-9]{20,}$/i.test(value);
|
||||
}
|
||||
|
||||
/** Every usable invite code in a list, lowercased, in order, without repeats. */
|
||||
export function cleanInviteCodes(values: readonly string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of values) {
|
||||
const code = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
||||
if (!isInviteCode(code) || seen.has(code)) continue;
|
||||
seen.add(code);
|
||||
out.push(code);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- modules ---------- */
|
||||
|
||||
/**
|
||||
* The three modules a federation page gives a named row of its own, and every short
|
||||
* name that satisfies each.
|
||||
*
|
||||
* Versioned modules are aliases rather than separate rows: a federation running `lnv2`
|
||||
* and no `ln` can still do Lightning, and a row reading "Lightning — not supported"
|
||||
* beside a `lnv2` chip would be false. The version still shows, as its own chip in the
|
||||
* list underneath.
|
||||
*/
|
||||
export const MODULE_ALIASES: Record<string, readonly string[]> = {
|
||||
lightning: ['ln', 'lnv2', 'lightning'],
|
||||
mint: ['mint', 'mintv2'],
|
||||
wallet: ['wallet', 'walletv2'],
|
||||
};
|
||||
|
||||
/** Order of the named rows, highest interest first. Mirrors HIGHLIGHT_NUTS. */
|
||||
export const HIGHLIGHT_MODULES = ['lightning', 'mint', 'wallet'] as const;
|
||||
export type HighlightModule = (typeof HIGHLIGHT_MODULES)[number];
|
||||
|
||||
/**
|
||||
* Plain-language names for the module short names seen in the wild, and the English
|
||||
* source of truth for them. The catalogs carry a translation per key under
|
||||
* `fedimint.module.`; a module neither knows renders as its own short name, which is
|
||||
* still a true label.
|
||||
*/
|
||||
export const MODULE_NAMES_EN: Record<string, string> = {
|
||||
lightning: 'Lightning',
|
||||
mint: 'Ecash mint',
|
||||
wallet: 'On-chain wallet',
|
||||
meta: 'Metadata',
|
||||
stability_pool: 'Stability pool',
|
||||
multi_sig_stability_pool: 'Stability pool (multisig)',
|
||||
'fedi-social': 'Social recovery',
|
||||
unknown: 'Unknown module',
|
||||
};
|
||||
|
||||
/** Which highlight row a module short name belongs to, or null for the chip list. */
|
||||
export function highlightModuleFor(module: string): HighlightModule | null {
|
||||
const name = module.toLowerCase();
|
||||
for (const key of HIGHLIGHT_MODULES) {
|
||||
if (MODULE_ALIASES[key]?.includes(name)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this federation runs anything satisfying one of the named rows. */
|
||||
export function hasModule(modules: readonly string[], key: HighlightModule): boolean {
|
||||
const aliases = MODULE_ALIASES[key] ?? [];
|
||||
return modules.some((module) => aliases.includes(module.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a `modules` tag: `"ln,mint,wallet,lnv2,meta"`.
|
||||
*
|
||||
* Comma separated in every event seen, but whitespace is tolerated because a publisher
|
||||
* writing `"ln, mint"` meant the same thing.
|
||||
*/
|
||||
export function parseModules(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const part of value.split(/[,\s]+/)) {
|
||||
const module = part.trim().toLowerCase();
|
||||
if (!module || module.length > 40 || seen.has(module)) continue;
|
||||
if (!/^[a-z0-9_-]+$/.test(module)) continue;
|
||||
seen.add(module);
|
||||
out.push(module);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- network ---------- */
|
||||
|
||||
/** The networks this site has a name for. Anything else renders as published. */
|
||||
export const KNOWN_NETWORKS = ['mainnet', 'testnet', 'testnet4', 'signet', 'regtest'] as const;
|
||||
|
||||
/**
|
||||
* Normalize an `n` tag.
|
||||
*
|
||||
* NIP-87 describes the value as mainnet/testnet/signet/regtest; every real announcement
|
||||
* on the network writes `bitcoin` for mainnet, which is what fedimint's own config
|
||||
* calls it. Both are accepted and both come out as `mainnet`, so one federation cannot
|
||||
* appear on two networks depending on which word its operator used.
|
||||
*/
|
||||
export function normalizeNetwork(value: string | null | undefined): string | null {
|
||||
const raw = value?.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
if (raw === 'bitcoin' || raw === 'main' || raw === 'mainnet') return 'mainnet';
|
||||
if (!/^[a-z0-9]{1,20}$/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** True for anything that is not real bitcoin. Worth a badge of its own on a page. */
|
||||
export function isTestNetwork(network: string | null): boolean {
|
||||
return network !== null && network !== 'mainnet';
|
||||
}
|
||||
|
||||
/* ---------- the announcement ---------- */
|
||||
|
||||
export interface FedimintAnnouncement {
|
||||
/** The `d` tag: the federation id, lowercase hex. */
|
||||
federationId: string;
|
||||
/** Every `u` tag that is a usable invite code. At least one, or this is not valid. */
|
||||
inviteCodes: string[];
|
||||
/** The `modules` tag, split. */
|
||||
modules: string[];
|
||||
/** The `n` tag, normalized. */
|
||||
network: string | null;
|
||||
/** From `content`, which is kind-0-shaped metadata or fedimint's own. */
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
about: string | null;
|
||||
/** Who published it. Needed for the `a` tag a review points back with. */
|
||||
announcerPubkey: string;
|
||||
announcedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a kind 38173 event, or return null if it is not one this site can use.
|
||||
*
|
||||
* An announcement with no valid `d` and no invite code is not something a reader can
|
||||
* act on: there is nothing to join and nothing to key the row by. Everything else is
|
||||
* optional and simply renders as absent.
|
||||
*/
|
||||
export function parseFedimintAnnouncement(event: NostrEventLike): FedimintAnnouncement | null {
|
||||
const d = tagValue(event.tags, 'd');
|
||||
if (!isFederationId(d)) return null;
|
||||
|
||||
const inviteCodes = cleanInviteCodes(tagValues(event.tags, 'u'));
|
||||
if (inviteCodes.length === 0) return null;
|
||||
|
||||
const meta = parseAnnouncementMetadata(event.content);
|
||||
|
||||
return {
|
||||
federationId: d.toLowerCase(),
|
||||
inviteCodes,
|
||||
modules: parseModules(tagValue(event.tags, 'modules')),
|
||||
network: normalizeNetwork(tagValue(event.tags, 'n')),
|
||||
name: meta.name,
|
||||
picture: meta.picture,
|
||||
about: meta.about,
|
||||
announcerPubkey: event.pubkey,
|
||||
announcedAt: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnnouncementMetadata {
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
about: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `content` of an announcement, which NIP-87 describes as kind-0-style metadata.
|
||||
*
|
||||
* Every fedimint announcement seen writes `{"federation_name": "..."}` instead, so both
|
||||
* spellings are read. Unparseable content degrades to no metadata rather than throwing:
|
||||
* this is arbitrary text written by anyone with a relay connection.
|
||||
*/
|
||||
export function parseAnnouncementMetadata(content: string): AnnouncementMetadata {
|
||||
const empty: AnnouncementMetadata = { name: null, picture: null, about: null };
|
||||
if (!content.trim()) return empty;
|
||||
|
||||
let meta: Record<string, unknown>;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty;
|
||||
meta = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const first = (...keys: string[]): unknown => {
|
||||
for (const key of keys) if (meta[key] !== undefined && meta[key] !== null) return meta[key];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
name: sanitizeDisplayText(first('name', 'federation_name', 'display_name'), 64) ?? null,
|
||||
picture: sanitizePictureUrl(first('picture', 'federation_icon_url', 'icon_url', 'image')) ?? null,
|
||||
about: sanitizeDisplayText(first('about', 'description', 'federation_description'), 400) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The federation id a slug carries, which is the first 16 characters of it.
|
||||
*
|
||||
* The list payload has no `federation_id` — that lives on the detail — so a card built
|
||||
* from the list reads its identifier back out of the routing slug. Shortened is all a
|
||||
* card has room for anyway, and the full id is on the page it links to.
|
||||
*/
|
||||
export function federationIdFromSlug(slug: string): string {
|
||||
return slug.startsWith(FEDIMINT_SLUG_PREFIX) ? slug.slice(FEDIMINT_SLUG_PREFIX.length) : slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* The short form of an invite code, for a row that has to fit one.
|
||||
*
|
||||
* More head than tail: the prefix is what tells a reader it is an invite code at all,
|
||||
* and the tail is what tells two of the same federation's codes apart.
|
||||
*/
|
||||
export function shortInviteCode(code: string, head = 14, tail = 8): string {
|
||||
if (code.length <= head + tail + 1) return code;
|
||||
return `${code.slice(0, head)}…${code.slice(-tail)}`;
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './normalize.js';
|
||||
export * from './score.js';
|
||||
export * from './nuts.js';
|
||||
export * from './warnings.js';
|
||||
export * from './fedimint.js';
|
||||
|
||||
+38
-1
@@ -26,13 +26,50 @@ function isDisallowedHost(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (hostname.endsWith('.onion')) return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
if (hostname === '::1' || hostname === '[::1]') return true;
|
||||
if (PRIVATE_IPV4.test(hostname)) return true;
|
||||
if (isPrivateIpv6(hostname)) return true;
|
||||
// A bare label with no dot cannot be a public host.
|
||||
if (!hostname.includes('.') && !hostname.includes(':')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6 forms that are loopback, link-local (fe80::/10), unique-local (fc00::/7) or an
|
||||
* IPv4-mapped address whose IPv4 part is private. The URL parser brackets an IPv6
|
||||
* hostname, so both spellings are accepted.
|
||||
*/
|
||||
function isPrivateIpv6(hostname: string): boolean {
|
||||
const bare =
|
||||
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
||||
if (!bare.includes(':')) return false;
|
||||
if (bare === '::' || bare === '::1') return true;
|
||||
if (/^f[cd]/i.test(bare)) return true;
|
||||
if (/^fe[89ab]/i.test(bare)) return true;
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
|
||||
if (mapped?.[1]) return PRIVATE_IPV4.test(mapped[1]) || mapped[1].startsWith('127.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* May the indexer fetch this URL? http(s) only, and never a private or local host.
|
||||
*
|
||||
* For URLs a mint *publishes* rather than the URL it lives at — its `icon_url` above
|
||||
* all. Those never pass through `normalizeMintUrl`, so without this check a mint's
|
||||
* /v1/info could point the indexer at a cloud metadata endpoint or anything else on
|
||||
* the API host's own network. Callers that follow redirects must re-check every hop.
|
||||
*/
|
||||
export function isFetchableUrl(value: URL | string): boolean {
|
||||
let u: URL;
|
||||
try {
|
||||
u = typeof value === 'string' ? new URL(value) : value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
|
||||
const hostname = u.hostname.toLowerCase();
|
||||
return hostname !== '' && !isDisallowedHost(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mint URL as found in a Nostr `u` tag or typed by a user.
|
||||
* Returns null when the input is not a usable public mint URL.
|
||||
|
||||
+89
-7
@@ -11,9 +11,48 @@ import type { Profile } from './types.js';
|
||||
export const KIND_REVIEW = 38000;
|
||||
/** NIP-87 Cashu mint announcement. */
|
||||
export const KIND_MINT_ANNOUNCEMENT = 38172;
|
||||
/** NIP-87 Fedimint federation announcement. */
|
||||
export const KIND_FEDIMINT_ANNOUNCEMENT = 38173;
|
||||
/** Profile metadata. */
|
||||
export const KIND_PROFILE = 0;
|
||||
|
||||
/**
|
||||
* The one place an ecosystem is tied to its announcement kind.
|
||||
*
|
||||
* Everything downstream reads this rather than testing for 38172 or 38173 itself: the
|
||||
* indexer's subscription list, the review resolver, the `k` tag a published review
|
||||
* carries, and the filter on /reviews. Adding a third ecosystem is an entry here plus
|
||||
* a probe strategy and its pages — see the "Adding an ecosystem" section of README.
|
||||
*
|
||||
* `mints.type` holds these keys verbatim, so they are a stored value: never rename one
|
||||
* without a data migration.
|
||||
*/
|
||||
export const ANNOUNCEMENT_KINDS = {
|
||||
cashu: KIND_MINT_ANNOUNCEMENT,
|
||||
fedimint: KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ecosystems in display order. Cashu first: it is what this site was, and what most of
|
||||
* its content still is.
|
||||
*/
|
||||
export const ECOSYSTEMS = Object.keys(ANNOUNCEMENT_KINDS) as Array<keyof typeof ANNOUNCEMENT_KINDS>;
|
||||
|
||||
/** The announcement kind for an ecosystem, or null for one this build does not know. */
|
||||
export function announcementKind(type: string): number | null {
|
||||
return (ANNOUNCEMENT_KINDS as Record<string, number>)[type] ?? null;
|
||||
}
|
||||
|
||||
/** The ecosystem an announcement kind belongs to, or null. */
|
||||
export function ecosystemForKind(kind: number | string | null): string | null {
|
||||
const n = typeof kind === 'string' ? Number.parseInt(kind, 10) : kind;
|
||||
if (n === null || !Number.isFinite(n)) return null;
|
||||
for (const [type, value] of Object.entries(ANNOUNCEMENT_KINDS)) {
|
||||
if (value === n) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
|
||||
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
|
||||
@@ -180,6 +219,45 @@ export function isCashuMintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/** True when the event's `k` tag marks it as being about a Fedimint federation. */
|
||||
export function isFedimintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_FEDIMINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind a review says it is about, from its `k` tag, as a number.
|
||||
*
|
||||
* null when the tag is missing or unparseable, which is the shape most reviews already
|
||||
* on the network have: the old publisher wrote `k` but plenty of other clients do not.
|
||||
* A review with no `k` is treated as Cashu by every resolver here, because that is the
|
||||
* only ecosystem that existed when those events were written.
|
||||
*/
|
||||
export function reviewTargetKind(event: NostrEventLike): number | null {
|
||||
const raw = tagValue(event.tags, 'k');
|
||||
if (!raw) return null;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which ecosystem a review is about: its `k` tag, falling back to Cashu.
|
||||
*
|
||||
* The fallback is not a guess about the future, it is a fact about the past. Every
|
||||
* kind 38000 written before Fedimint support existed is about a Cashu mint, and a great
|
||||
* many of them carry no `k` at all (see NOTES.md). A review whose `k` names a kind this
|
||||
* build does not know returns null and is skipped rather than filed under Cashu.
|
||||
*/
|
||||
export function reviewEcosystem(event: NostrEventLike): string | null {
|
||||
const kind = reviewTargetKind(event);
|
||||
if (kind === null) return 'cashu';
|
||||
return ecosystemForKind(kind);
|
||||
}
|
||||
|
||||
/** The `d` identifier a review points at, verbatim. Resolution is the caller's job. */
|
||||
export function reviewTargetId(event: NostrEventLike): string | null {
|
||||
return tagValue(event.tags, 'd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every spelling of a mint URL that may appear in a `u` tag.
|
||||
*
|
||||
@@ -210,8 +288,12 @@ export const MAX_PROFILE_NAME = 24;
|
||||
* Written as a code point scan rather than a regex because the set is exactly the
|
||||
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
|
||||
* let a name reorder the text around it.
|
||||
*
|
||||
* Exported because a Fedimint announcement's `content` carries a federation name
|
||||
* written by the same kind of stranger, and `fedimint.ts` has no business owning a
|
||||
* second copy of these rules.
|
||||
*/
|
||||
function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
export function sanitizeDisplayText(value: unknown, max: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
let out = '';
|
||||
@@ -238,7 +320,7 @@ function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
* been verified against a domain. Anything not shaped like an address is dropped.
|
||||
*/
|
||||
function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
const text = sanitizeText(value, max);
|
||||
const text = sanitizeDisplayText(value, max);
|
||||
if (!text || text.length > max) return undefined;
|
||||
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
|
||||
// "_@domain" is NIP-05's root form and reads better as the bare domain.
|
||||
@@ -249,7 +331,7 @@ function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
|
||||
* relay has no business being written into an `img src`.
|
||||
*/
|
||||
function sanitizePicture(value: unknown): string | undefined {
|
||||
export function sanitizePictureUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const url = value.trim();
|
||||
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
|
||||
@@ -278,11 +360,11 @@ export function parseProfileContent(pubkey: string, content: string): Profile {
|
||||
|
||||
const profile: Profile = {
|
||||
pubkey,
|
||||
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
|
||||
name: sanitizeDisplayText(meta['name'], MAX_PROFILE_NAME),
|
||||
display_name:
|
||||
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePicture(meta['picture']),
|
||||
sanitizeDisplayText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeDisplayText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePictureUrl(meta['picture']),
|
||||
nip05: sanitizeNip05(meta['nip05'], 64),
|
||||
found: true,
|
||||
};
|
||||
|
||||
+38
-8
@@ -55,18 +55,48 @@ export function bayesianScore(m: ScoreInput, priorMean: number, now: number): nu
|
||||
return Math.round(score * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three tiers, and the middle one exists for federations.
|
||||
*
|
||||
* `announced` is a Fedimint status: nothing has ever confirmed the thing is running, so
|
||||
* it cannot sit with the confirmed-online rows — but it is not evidence of being down
|
||||
* either, so it must not sink to the bottom with the rows a check actually failed on.
|
||||
* Not knowing belongs between knowing and knowing otherwise.
|
||||
*
|
||||
* A Cashu mint is never `announced`, so this is exactly the two-tier order it always
|
||||
* had for them.
|
||||
*/
|
||||
function healthRank(status: string): number {
|
||||
if (status === 'offline') return 2;
|
||||
if (status === 'announced') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort: every online mint before every offline one, then by score descending.
|
||||
* Offline mints must stay findable (people need to reach them to review them), they
|
||||
* just never appear above a live mint.
|
||||
*/
|
||||
export function compareMints<T extends { status: string; score: number; review_count: number }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
const aOff = a.status === 'offline' ? 1 : 0;
|
||||
const bOff = b.status === 'offline' ? 1 : 0;
|
||||
if (aOff !== bOff) return aOff - bOff;
|
||||
export function compareMints<
|
||||
T extends { status: string; score: number; review_count: number; host?: string },
|
||||
>(a: T, b: T): number {
|
||||
const aRank = healthRank(a.status);
|
||||
const bRank = healthRank(b.status);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.review_count - a.review_count;
|
||||
if (b.review_count !== a.review_count) return b.review_count - a.review_count;
|
||||
|
||||
/*
|
||||
* A final tiebreak on the slug, so two indistinguishable rows still have an order.
|
||||
*
|
||||
* Without it the winner is whatever the database happened to return first, which is a
|
||||
* different answer on SQLite and on Postgres — the same data served in two orders, and
|
||||
* a nightly rebuild that reshuffles rows for no reason. It went unnoticed while ties
|
||||
* were rare (two unreviewed mints); federations made it the common case, since every
|
||||
* one that nobody has reviewed scores exactly the prior.
|
||||
*
|
||||
* Optional in the type because the ranking checks compare bare score objects that have
|
||||
* no slug, and there is nothing to tiebreak in a two-element fixture.
|
||||
*/
|
||||
return (a.host ?? '').localeCompare(b.host ?? '');
|
||||
}
|
||||
|
||||
+81
-4
@@ -1,6 +1,24 @@
|
||||
/** Shapes returned by the API. The web app builds against these. */
|
||||
|
||||
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
|
||||
/**
|
||||
* Statuses a listed thing can be in.
|
||||
*
|
||||
* `announced` is the Fedimint addition, and it is deliberately not a synonym for
|
||||
* `unknown`. `unknown` means "we have not checked yet"; `announced` means "there is no
|
||||
* check we can run" — the federation exists on Nostr, nothing has confirmed it since,
|
||||
* and nothing here will claim otherwise. A Cashu mint never carries it.
|
||||
*/
|
||||
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
|
||||
|
||||
/**
|
||||
* Which ecosystem a listing belongs to.
|
||||
*
|
||||
* Deliberately open rather than a closed union: `mints.type` is a stored TEXT column
|
||||
* with no CHECK constraint, so an older build reading a database written by a newer one
|
||||
* has to be able to hold a value it does not have a page for. Compare against
|
||||
* `ANNOUNCEMENT_KINDS` rather than switching exhaustively on this.
|
||||
*/
|
||||
export type MintType = 'cashu' | 'fedimint' | (string & {});
|
||||
|
||||
/** One item of `GET /api/mints`. */
|
||||
export interface MintListItem {
|
||||
@@ -8,6 +26,8 @@ export interface MintListItem {
|
||||
host: string;
|
||||
name: string | null;
|
||||
icon: string | null;
|
||||
/** 'cashu' or 'fedimint'. Rows written before the column existed read as 'cashu'. */
|
||||
type: MintType;
|
||||
status: MintStatus;
|
||||
last_online: number | null;
|
||||
review_count: number;
|
||||
@@ -25,8 +45,13 @@ export interface ProbeSample {
|
||||
|
||||
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
|
||||
|
||||
/** `GET /api/mints/:host`. */
|
||||
export interface MintDetail extends MintListItem {
|
||||
/**
|
||||
* `GET /api/mints/:host`.
|
||||
*
|
||||
* The Fedimint keys are optional and absent on a Cashu mint, which is what keeps the
|
||||
* Cashu payload unchanged. Read them through `FedimintDetail` after checking `type`.
|
||||
*/
|
||||
export interface MintDetail extends MintListItem, Partial<FedimintFields> {
|
||||
description: string | null;
|
||||
pubkey: string | null;
|
||||
info: MintInfo | null;
|
||||
@@ -41,7 +66,49 @@ export interface MintDetail extends MintListItem {
|
||||
probes_recent: ProbeSample[];
|
||||
}
|
||||
|
||||
/** `GET /api/stats`. */
|
||||
/**
|
||||
* What `ecosystem_json` holds for a Fedimint row, and what `GET /api/mints/:host`
|
||||
* spreads across the detail payload for one.
|
||||
*
|
||||
* Flattened rather than nested so a Cashu payload is byte for byte what it always was:
|
||||
* these keys are simply absent on one. A third ecosystem adds its own interface here
|
||||
* and its own optional keys below; nothing existing has to move.
|
||||
*/
|
||||
export interface FedimintFields {
|
||||
/** The `d` tag of the announcement: 64 hex characters. `host` is derived from it. */
|
||||
federation_id: string;
|
||||
/** Every `u` tag that was a usable invite code, in announcement order. */
|
||||
invite_codes: string[];
|
||||
/** The `modules` tag, split: `["ln", "mint", "wallet", "lnv2", "meta"]`. */
|
||||
modules: string[];
|
||||
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
|
||||
network: string | null;
|
||||
/** `created_at` of the newest announcement seen for this federation. */
|
||||
announced_at: number | null;
|
||||
/** Who published that announcement. The `a` tag of a review points back at them. */
|
||||
announcer_pubkey: string | null;
|
||||
/**
|
||||
* Who confirmed the status, when anything did: `"fedimint.observer"` today.
|
||||
*
|
||||
* null means nothing has, and then `status` is `announced` and never `online`. The
|
||||
* page prints this next to the status, because "someone else says it is up" is a
|
||||
* different claim from "we checked".
|
||||
*/
|
||||
status_source: string | null;
|
||||
}
|
||||
|
||||
/** `GET /api/mints/:host` for a Fedimint federation: the detail plus its own fields. */
|
||||
export type FedimintDetail = MintDetail & FedimintFields;
|
||||
|
||||
/**
|
||||
* `GET /api/stats`.
|
||||
*
|
||||
* The four `mints_*` fields count Cashu mints and only Cashu mints, exactly as they did
|
||||
* before federations were indexed: they are read by the pulse ticker, the /mints
|
||||
* description and the home page, and a number that silently changed meaning would be
|
||||
* worse than a new field. `cashu_total` is the same number under a name that says so,
|
||||
* and every ecosystem added later gets its own `*_total` beside `fedimint_total`.
|
||||
*/
|
||||
export interface Stats {
|
||||
mints_total: number;
|
||||
mints_online: number;
|
||||
@@ -50,6 +117,16 @@ export interface Stats {
|
||||
reviews_total: number;
|
||||
last_review_at: number | null;
|
||||
updated_at: number;
|
||||
/** Same value as `mints_total`, named for the ecosystem it counts. */
|
||||
cashu_total: number;
|
||||
fedimint_total: number;
|
||||
/** Federations a real check confirmed were up. Never inferred from an announcement. */
|
||||
fedimint_online: number;
|
||||
fedimint_offline: number;
|
||||
/** Federations announced on Nostr that no check has ever confirmed. */
|
||||
fedimint_announced: number;
|
||||
/** Reviews of federations (`k` = 38173), included in `reviews_total`. */
|
||||
fedimint_reviews: number;
|
||||
}
|
||||
|
||||
/** `GET /api/health`. */
|
||||
|
||||
+133
-1
@@ -8,6 +8,12 @@
|
||||
* `/v1/info` (NUT-04 and NUT-05), plus `status` and `last_online`. The cache is the
|
||||
* point: an offline mint still has its last known configuration, and the copy says
|
||||
* "had" rather than "has" so nobody reads a stale flag as a live one.
|
||||
*
|
||||
* Two branches, chosen by `type` and never mixed. Cashu gets the six states below,
|
||||
* every one of them read out of a `/v1/info` this site fetched itself. Fedimint gets
|
||||
* two, because a federation publishes no capability list to read: a real check said the
|
||||
* guardians are down, or nothing has ever confirmed the federation at all. There is no
|
||||
* Fedimint equivalent of "melt only" and this file does not invent one.
|
||||
*/
|
||||
import type { MintInfo, MintStatus } from './types.js';
|
||||
import { nutEntry } from './nuts.js';
|
||||
@@ -73,12 +79,16 @@ function isLightningMethod(method: unknown): boolean {
|
||||
export type MintWarningSeverity = 'critical' | 'warning';
|
||||
|
||||
export type MintWarningKind =
|
||||
// Cashu
|
||||
| 'gone' // offline 30 days or more, or never reached at all
|
||||
| 'melt-disabled' // sats can get in but not out
|
||||
| 'frozen' // neither in nor out
|
||||
| 'offline-long' // offline 7 to 30 days
|
||||
| 'melt-only' // minting off, melting still works
|
||||
| 'offline'; // offline under 7 days
|
||||
| 'offline' // offline under 7 days
|
||||
// Fedimint
|
||||
| 'fedimint-offline' // a real check reported the guardians down
|
||||
| 'never-confirmed'; // announced on Nostr, and nothing has ever confirmed it
|
||||
|
||||
export interface MintWarning {
|
||||
kind: MintWarningKind;
|
||||
@@ -108,8 +118,18 @@ export interface MintWarning {
|
||||
export type WarningStrings = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export interface MintWarningInput {
|
||||
/**
|
||||
* Which ecosystem this is. Absent reads as `cashu`, so every existing caller keeps
|
||||
* exactly the behaviour it had; a `fedimint` row takes the separate branch below,
|
||||
* which shares the severity machinery and none of the NUT reasoning.
|
||||
*/
|
||||
type?: string;
|
||||
status: MintStatus | string;
|
||||
last_online: number | null;
|
||||
/** Fedimint: `created_at` of the newest announcement, for "announced {date}". */
|
||||
announced_at?: number | null;
|
||||
/** Fedimint: used only to decide whether reviews are the page's one sign of life. */
|
||||
last_review_at?: number | null;
|
||||
/** Cached `/v1/info`, as `GET /api/mints/:host` returns it. */
|
||||
info?: MintInfo | null;
|
||||
/**
|
||||
@@ -183,6 +203,21 @@ export const WARNING_COPY_EN: Record<string, string> = {
|
||||
'offline.body': 'Details were last confirmed then. You can still write a review.',
|
||||
'offline.meta': 'Offline since {date}',
|
||||
|
||||
'fedimintOffline.lead': 'Reported offline.',
|
||||
'fedimintOffline.body.since':
|
||||
'The last check that reached this federation was on {date} ({days} ago). Its guardians have not answered since. Treat funds held there as at risk, and do not join it with new funds.',
|
||||
'fedimintOffline.body.never':
|
||||
'No check of this federation has ever reached it, and the most recent one failed. Treat funds held there as at risk, and do not join it with new funds.',
|
||||
'fedimintOffline.meta.since': 'Reported offline since {date}',
|
||||
'fedimintOffline.meta.never': 'Reported offline, never once reached',
|
||||
|
||||
'neverConfirmed.lead': 'Announced, never confirmed.',
|
||||
'neverConfirmed.body.reviewed':
|
||||
'This federation was announced on Nostr on {date}, {days} ago, and nothing has confirmed since then that it is running. The reviews below are the only sign of life on this page; everything else came from the announcement.',
|
||||
'neverConfirmed.body.quiet':
|
||||
'This federation was announced on Nostr on {date}, {days} ago, nothing has confirmed since then that it is running, and nobody has reviewed it recently. Everything below came from the announcement.',
|
||||
'neverConfirmed.meta': 'Announced {date}, never confirmed',
|
||||
|
||||
'also.meltDisabled': 'Before going offline it had also disabled withdrawals.',
|
||||
'also.mintDisabled': 'Before going offline it had also disabled new minting.',
|
||||
'also.offline': 'It has also been offline since {date} ({days}).',
|
||||
@@ -213,8 +248,23 @@ const englishStrings: WarningStrings = (key, vars) => {
|
||||
/** Severity order, highest first. Index 0 of the returned list is the one to show. */
|
||||
const ORDER: MintWarningKind[] = [
|
||||
'gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline',
|
||||
// Appended rather than interleaved. The Fedimint kinds never share a list with the
|
||||
// Cashu ones (the two branches are exclusive), so their position relative to those
|
||||
// is arbitrary — and appending leaves every existing rank exactly where it was.
|
||||
'fedimint-offline', 'never-confirmed',
|
||||
];
|
||||
|
||||
/**
|
||||
* How stale an unconfirmed announcement has to be before it is worth saying so.
|
||||
*
|
||||
* A federation announced last week that nothing has checked is not news: nothing has
|
||||
* had the chance. A month of silence is.
|
||||
*/
|
||||
export const NEVER_CONFIRMED_AFTER_S = 30 * 24 * 60 * 60;
|
||||
|
||||
/** Reviews inside this window count as the page having a pulse. Matches `reviews_90d`. */
|
||||
const RECENT_REVIEW_S = 90 * 24 * 60 * 60;
|
||||
|
||||
const defaultDate = (unix: number): string =>
|
||||
new Date(unix * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
@@ -232,6 +282,88 @@ const defaultMonth = (unix: number): string =>
|
||||
export function getMintWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
if (mint.type === 'fedimint') return fedimintWarnings(mint, options);
|
||||
return cashuWarnings(mint, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be said about a federation, which is much less than about a mint.
|
||||
*
|
||||
* A federation publishes no capability list, so there is no NUT-04/NUT-05 equivalent to
|
||||
* read and nothing here invents one: the melt-only, withdrawals-disabled and frozen
|
||||
* banners have no Fedimint counterpart and never will until federations publish
|
||||
* something a check can actually read. That leaves two honest things to say.
|
||||
*
|
||||
* The first is that a check reported the guardians down — a real result, from
|
||||
* `status_source`, never inferred from the absence of an announcement. The second is
|
||||
* the soft one: this federation has been sitting on Nostr for a month or more and
|
||||
* nothing has ever confirmed it is running, so everything on its page came from a
|
||||
* stranger's announcement rather than from the federation itself.
|
||||
*
|
||||
* Note what is deliberately missing: a federation that no check reached is `announced`,
|
||||
* not `offline`, and gets the soft banner rather than the "likely gone" one. Not
|
||||
* knowing is not the same as knowing it is dead.
|
||||
*/
|
||||
function fedimintWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const date = options.formatDate ?? defaultDate;
|
||||
const s = options.strings ?? englishStrings;
|
||||
|
||||
const days = (since: number): string =>
|
||||
s('dayCount', { n: Math.max(0, Math.floor((now - since) / 86400)) });
|
||||
|
||||
if (mint.status === 'offline') {
|
||||
const since = mint.last_online;
|
||||
return [
|
||||
{
|
||||
kind: 'fedimint-offline',
|
||||
severity: 'critical',
|
||||
lead: s('fedimintOffline.lead'),
|
||||
body:
|
||||
since !== null
|
||||
? s('fedimintOffline.body.since', { date: date(since), days: days(since) })
|
||||
: s('fedimintOffline.body.never'),
|
||||
meta:
|
||||
since !== null
|
||||
? s('fedimintOffline.meta.since', { date: date(since) })
|
||||
: s('fedimintOffline.meta.never'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Anything a check has confirmed up needs no banner, and a fresh announcement no
|
||||
// check has got to yet has not earned one either.
|
||||
if (mint.status !== 'announced') return [];
|
||||
|
||||
const announced = mint.announced_at ?? mint.first_seen ?? null;
|
||||
if (announced === null || now - announced < NEVER_CONFIRMED_AFTER_S) return [];
|
||||
|
||||
const reviewed =
|
||||
mint.last_review_at !== null &&
|
||||
mint.last_review_at !== undefined &&
|
||||
now - mint.last_review_at <= RECENT_REVIEW_S;
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'never-confirmed',
|
||||
severity: 'warning',
|
||||
lead: s('neverConfirmed.lead'),
|
||||
body: s(`neverConfirmed.body.${reviewed ? 'reviewed' : 'quiet'}`, {
|
||||
date: date(announced),
|
||||
days: days(announced),
|
||||
}),
|
||||
meta: s('neverConfirmed.meta', { date: date(announced) }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function cashuWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const date = options.formatDate ?? defaultDate;
|
||||
|
||||
Reference in New Issue
Block a user