Introduce lnurl as a first-class mint type with probe/announcement fields and shared helpers the API and web can both rely on. Co-authored-by: Cursor <cursoragent@cursor.com>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
/**
|
|
* 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<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
|
|
* 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: <url>" 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:<pubkey>:<d>`, 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<string, unknown>;
|
|
try {
|
|
const parsed: unknown = JSON.parse(content);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { pubkey, found: false };
|
|
}
|
|
meta = parsed as Record<string, unknown>;
|
|
} 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';
|