first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
/**
* 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;
/** Profile metadata. */
export const KIND_PROFILE = 0;
/**
* 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);
}
/**
* 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.
*/
function sanitizeText(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 = sanitizeText(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`.
*/
function sanitizePicture(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: sanitizeText(meta['name'], MAX_PROFILE_NAME),
display_name:
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
picture: sanitizePicture(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';