Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+89 -7
View File
@@ -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,
};