@@ -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';
|
||||
Reference in New Issue
Block a user