/** * NIP-87 constants and parse rules. * * Kinds, tags, relays and the rating encoding are taken verbatim from the old site * (see NOTES.md) so the new site sees exactly the same events. Do not "fix" these * to match a spec reading: what matters is what is actually on the relays. */ import type { Profile } from './types.js'; /** NIP-87 mint recommendation / review. */ 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; /** * LNURL mint announcement. * * Not in NIP-87: this site's own proposed extension to it, specified in * `docs/KIND-LNURL-MINT.md` and intended for a PR to nostr-protocol/nips. 38174 is the * next free slot in the family — checked against the kind index in the nips README and * against every issue and PR in that repository before it was claimed. */ export const KIND_LNURL_ANNOUNCEMENT = 38174; /** 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, lnurl: KIND_LNURL_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; /** 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)[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 * site published to snort/primal were invisible to it. Reading the union fixes that. * * Minus `relay.damus.io`, which was in both of the old lists and earned neither place: * it answered a kind 38000 query with nothing at all, and it was the slowest relay in * the pool doing it. A query closes when its slowest relay does, so a relay that holds * none of what is being asked for costs the whole site latency for no events. */ export const DEFAULT_RELAYS = [ 'wss://relay.cashumints.space', 'wss://nos.lol', 'wss://relay.azzamo.net', 'wss://relay.snort.social', 'wss://relay.primal.net', ] as const; /** Relay hint used in the `a` tag, matching the old publisher's RELAY_URLS[0]. */ export const PRIMARY_RELAY = 'wss://relay.cashumints.space'; /** * Relays queried for kind 0 metadata, on top of the review relays above. * * The review pool alone is not enough: `relay.cashumints.space` holds no kind 0 at * all, and `snort.social` and `primal.net` hold almost none, so a reviewer's name * often lives on a relay this site never asked. Measured against the 80 authors who * have reviewed mint.minibits.cash, hit rates were: * * nos.lol 29, relay.nostr.net 28, azzamo 24, damus 18, purplepag.es 15, * primal 5, snort 1, relay.cashumints.space 0 * * 29 is the ceiling: the other 51 keys have no kind 0 on any relay tested, and no * other events either, so they are single-purpose review keys and stay as npubs. * * damus is no longer in this pool, despite that 18. It came out of DEFAULT_RELAYS on * latency and this list is built from that one, and the trade was worth taking: it * answered a 25-author kind 0 batch in 1217ms where every other relay here answered * in under 150ms, so it alone set the whole batch's duration. The handful of names it * was the only source for now render as a short npub, which is the same fallback the * other 51 keys already use. * * `relay.nostr.band` is deliberately NOT here. It answered author-filtered kind 0 * queries with nothing at all (even for a single well known pubkey) and took the * full 7s to do it, which held the whole batch open until its timeout instead of * closing on EOSE in well under a second. * * Override with PUBLIC_PROFILE_RELAYS (browser) or PROFILE_RELAYS (build), comma * separated. */ export const PROFILE_RELAYS = [ ...DEFAULT_RELAYS, 'wss://purplepag.es', 'wss://relay.nostr.net', ] as const; /** Minimal structural view of a Nostr event, so this module needs no dependency. */ export interface NostrEventLike { id: string; pubkey: string; kind: number; created_at: number; content: string; tags: string[][]; } export function tagValue(tags: string[][], name: string): string | null { for (const t of tags) if (t[0] === name && t[1]) return t[1]; return null; } export function tagValues(tags: string[][], name: string): string[] { const out: string[] = []; for (const t of tags) if (t[0] === name && t[1]) out.push(t[1]); return out; } /** * Extract a rating from a review event. * * Order matches the old parser (utils/reviewHelpers.ts parseNIP87Review) with one * deliberate change: the old code defaulted to 5 when nothing parsed, which is why * every mint on the old site showed 5/5. Unparseable now returns null and is left * out of averages. */ export function parseRating(event: NostrEventLike): number | null { const tag = tagValue(event.tags, 'rating'); if (tag) { const n = Number.parseInt(tag, 10); if (n >= 1 && n <= 5) return n; // Some clients write a 0..1 fraction instead of 1..5 (NIP-87 suggests this). const f = Number.parseFloat(tag); if (Number.isFinite(f) && f >= 0 && f <= 1) return Math.max(1, Math.round(f * 5)); } // The old publisher wrote no rating tag at all, only a `[N/5]` content prefix. // This is how the great majority of real reviews encode their rating. const bracket = /^\s*\[([1-5])\/5\]/.exec(event.content); if (bracket?.[1]) return Number.parseInt(bracket[1], 10); const loose = /rating[:\s]*([1-5])|([1-5])\s*\/\s*5|([1-5])\s*star/i.exec(event.content); if (loose) { const raw = loose[1] ?? loose[2] ?? loose[3]; if (raw) { const n = Number.parseInt(raw, 10); if (n >= 1 && n <= 5) return n; } } return null; } /** Strip the `[N/5]` prefix and the "Reviewing: " footer the old site appended. */ /** * Shortest body that counts as a written review. * * Anything under this is a rating with a stray character attached, not something * someone wrote to be read. Written reviews render as cards; the rest collapse into * one summary line. The build time feed and the browser islands both apply this, so * it lives here rather than in either of them. */ export const WRITTEN_MIN_CHARS = 3; export function cleanReviewContent(content: string): string { return content .replace(/^\s*\[[1-5]\/5\]\s*/, '') .replace(/\n+\s*Reviewing:\s*https?:\/\/\S+\s*$/i, '') .trim(); } /** * Mint URLs referenced by an event, in tag priority order. * * `u` is where both the announcement (38172) and the review (38000) put the mint URL. * `a` is the addressable pointer `38172::`, which carries no URL, so it is * only useful for resolving via the mint's own pubkey (see `mintPubkeyRef`). */ export function mintUrlsFromEvent(event: NostrEventLike): string[] { return tagValues(event.tags, 'u'); } /** * The mint pubkey a review points at, from the `d` tag. The old publisher set * `d` to the pubkey from the mint's own `/v1/info`, so this resolves a review to a * mint even when the `u` tag is missing or misspelled. */ export function mintPubkeyRef(event: NostrEventLike): string | null { const d = tagValue(event.tags, 'd'); if (d && /^[0-9a-f]{64,66}$/i.test(d)) return d.toLowerCase(); const a = tagValue(event.tags, 'a'); if (a) { const parts = a.split(':'); if (parts[0] === String(KIND_MINT_ANNOUNCEMENT) && parts[1]) { const p = parts[1]; if (/^[0-9a-f]{64,66}$/i.test(p)) return p.toLowerCase(); } } return null; } /** True when the event's `k` tag marks it as being about a Cashu mint. */ 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); } /** True when the event's `k` tag marks it as being about an LNURL mint. */ export function isLnurlReview(event: NostrEventLike): boolean { return tagValue(event.tags, 'k') === String(KIND_LNURL_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. * * The old site published and queried these six variants (useReviews.ts), so a relay * filter that only asks for the canonical form misses most real reviews. Used by both * the indexer and the browser so the two always agree on what belongs to a mint. */ export function mintUrlSpellings(mintUrl: string): string[] { const bare = mintUrl.replace(/^https?:\/\//, '').replace(/\/+$/, ''); return [...new Set([ mintUrl, `${mintUrl}/`, bare, `https://${bare}`, `https://${bare}/`, `http://${bare}`, ])]; } /* ---------- kind 0 metadata ---------- */ /** Longest reviewer name rendered before it is cut, per the review card spec. */ export const MAX_PROFILE_NAME = 24; /** * Strip characters that have no business in a rendered name. * * 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. */ export function sanitizeDisplayText(value: unknown, max: number): string | undefined { if (typeof value !== 'string') return undefined; let out = ''; for (const ch of value) { const code = ch.codePointAt(0) ?? 0; if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue; if (code >= 0x200b && code <= 0x200f) continue; if (code >= 0x202a && code <= 0x202e) continue; if (code >= 0x2066 && code <= 0x2069) continue; if (code === 0xfeff) continue; out += ch; } const cleaned = out.replace(/\s+/g, ' ').trim(); if (!cleaned) return undefined; return cleaned.length > max ? `${cleaned.slice(0, max - 1)}...` : cleaned; } /** * A NIP-05 is `name@domain`, and only that. * * Some clients park other things in the field: one reviewer's kind 0 carries their * own npub there, which the card would otherwise render in violet as though it had * been verified against a domain. Anything not shaped like an address is dropped. */ function sanitizeNip05(value: unknown, max: number): string | undefined { 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. return text.startsWith('_@') ? text.slice(2) : text; } /** * Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a * relay has no business being written into an `img src`. */ 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; return url; } /** * Parse a kind 0 `content` string into the fields the site renders. * * Defensive by construction: relay content is arbitrary text written by anyone, so * unparseable JSON, wrong types and junk fields all degrade to "no profile" rather * than throwing. Used by the browser island and the build time home page fetch, so * both apply the same rules. */ export function parseProfileContent(pubkey: string, content: string): Profile { let meta: Record; try { const parsed: unknown = JSON.parse(content); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { pubkey, found: false }; } meta = parsed as Record; } catch { return { pubkey, found: false }; } const profile: Profile = { pubkey, name: sanitizeDisplayText(meta['name'], MAX_PROFILE_NAME), display_name: sanitizeDisplayText(meta['display_name'], MAX_PROFILE_NAME) ?? sanitizeDisplayText(meta['displayName'], MAX_PROFILE_NAME), picture: sanitizePictureUrl(meta['picture']), nip05: sanitizeNip05(meta['nip05'], 64), found: true, }; // A kind 0 carrying nothing we render is the same as no kind 0 at all: the card // keeps its npub rather than showing an empty name line. if (!profile.name && !profile.display_name && !profile.picture && !profile.nip05) { return { pubkey, found: false }; } return profile; } /** The name a review card shows, or null when there is nothing human to show. */ export function profileName(profile: Profile | null | undefined): string | null { if (!profile?.found) return null; return profile.display_name ?? profile.name ?? null; } /** * What a reviewer with no kind 0 is called. * * Most review keys on the network have published one event in their life, the review * itself, so there is no name to show and never will be. A wall of `npub1q8f…7x2v` * says nothing a reader can use; "Anon" says the same thing in a word. The full npub * is still one click away on the same control, and the avatar beside it is still * derived from the pubkey, so two anonymous reviewers remain distinguishable. */ export const ANON_REVIEWER = 'Anon';