Files
BelgianBitcoinEmbassy/frontend/lib/nostr.ts
T
bbeandCursor 495289232b feat: blog header images, npub display, and meetups load more
Show longform image tags on posts, use shortened npubs for author names,
and paginate the homepage meetups section with responsive load more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 04:36:10 +02:00

669 lines
22 KiB
TypeScript

import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
import { nip19 } from "nostr-tools";
import { readCache, writeCache } from "./browserCache";
// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs:
// these survive reloads, where the whole point is to avoid re-querying relays.
// Profiles and immutable events change rarely; addressable (replaceable)
// long-form events use a shorter window so edits are picked up reasonably soon.
const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours
const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable)
const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable)
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "npub") return decoded.data as string;
if (decoded.type === "nprofile") return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Relays that specialize in (or broadly aggregate) kind:0 profile metadata.
// Included alongside the site relays so profiles are found even when a user's
// metadata was never published to the site's configured relay set.
const PROFILE_METADATA_RELAYS = [
"wss://purplepag.es",
"wss://relay.nostr.band",
];
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: any): Promise<any>;
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>;
};
}
}
export type BunkerSignerInterface = {
getPublicKey(): Promise<string>;
signEvent(event: any): Promise<any>;
close(): Promise<void>;
};
export function hasNostrExtension(): boolean {
return typeof window !== "undefined" && !!window.nostr;
}
export async function getPublicKey(): Promise<string> {
if (!window.nostr) throw new Error("No Nostr extension found");
return window.nostr.getPublicKey();
}
export async function signEvent(event: any): Promise<any> {
if (!window.nostr) throw new Error("No Nostr extension found");
return window.nostr.signEvent(event);
}
export function createAuthEvent(pubkey: string, challenge: string) {
return {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", ""],
["challenge", challenge],
],
content: "",
pubkey,
};
}
export function shortenPubkey(pubkey: string): string {
if (!pubkey) return "";
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
// Shortened npub for display when no profile is available. Accepts a hex pubkey
// or an existing npub/nprofile and always renders bech32 (never raw hex).
export function shortenNpub(pubkeyOrBech32: string): string {
if (!pubkeyOrBech32) return "";
try {
const npub = pubkeyOrBech32.startsWith("npub")
? pubkeyOrBech32
: nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32);
return shortenPubkey(npub);
} catch {
return shortenPubkey(pubkeyOrBech32);
}
}
export interface NostrProfile {
name?: string;
picture?: string;
about?: string;
nip05?: string;
displayName?: string;
}
const FALLBACK_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
];
// Only secure WebSocket (wss://) relays may be dialed: the site is served over
// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed
// content and must never be connected to. Relay hints reach us from untrusted
// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter
// at every connection point.
export function isSecureRelay(url: unknown): url is string {
return typeof url === "string" && /^wss:\/\//i.test(url.trim());
}
export function filterSecureRelays(urls: Iterable<string>): string[] {
const out: string[] = [];
for (const url of urls) {
if (isSecureRelay(url)) out.push(url.trim());
}
return [...new Set(out)];
}
// How long a single relay query may run before we give up on it. Relays go
// dead or stall mid-subscription without ever sending EOSE, which is what makes
// blog loads occasionally hang; bounding every query keeps the page responsive.
const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait`
const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup
// A single shared read pool for the whole app. Reusing one pool keeps relay
// websockets warm and—crucially—avoids opening several simultaneous
// connections to the same relays (author profile + article body + NIP-65 all at
// once on a blog open), which relays throttle/drop and which caused the
// intermittent "couldn't fetch" failure on first load. Never closed: the pool
// manages and reuses its own connections for the SPA's lifetime.
let _readPool: any = null;
async function getReadPool(): Promise<any> {
if (!_readPool) {
const { SimplePool } = await import("nostr-tools/pool");
_readPool = new SimplePool();
}
return _readPool;
}
// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a
// hard ceiling around relay queries so a connection that never opens (or never
// closes) can't block rendering.
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms);
promise.then(
(v) => { clearTimeout(timer); resolve(v); },
() => { clearTimeout(timer); resolve(fallback); },
);
});
}
// A single bounded query across `relays` (which are dialed in parallel by the
// pool). Returns null on timeout, empty relay set, or error.
async function getEventBounded(
pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise<any> },
relays: string[],
filter: any,
): Promise<any | null> {
if (relays.length === 0) return null;
return withTimeout(
pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }),
RELAY_HARD_TIMEOUT,
null,
);
}
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
export async function getSiteRelays(): Promise<string[]> {
if (_siteRelaysCache && Date.now() - _siteRelaysCache.fetchedAt < SITE_RELAY_TTL) {
return _siteRelaysCache.relays;
}
try {
const res = await fetch("/api/relays/public");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : [];
if (secure.length > 0) {
_siteRelaysCache = { relays: secure, fetchedAt: Date.now() };
return secure;
}
} catch {}
return FALLBACK_RELAYS;
}
export interface Nip65RelayList {
write: string[];
read: string[];
all: string[];
}
const _nip65Cache = new Map<string, { data: Nip65RelayList; fetchedAt: number }>();
const NIP65_TTL = 10 * 60 * 1000; // 10 minutes
export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayList> {
const cached = _nip65Cache.get(pubkey);
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
const siteRelays = await getSiteRelays();
const pool = await getReadPool();
try {
const event = await getEventBounded(pool, siteRelays, {
kinds: [10002],
authors: [pubkey],
});
const result: Nip65RelayList = { write: [], read: [], all: [] };
if (!event) {
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
return result;
}
for (const tag of event.tags) {
if (tag[0] !== "r" || !tag[1]) continue;
const url = tag[1];
if (!isSecureRelay(url)) continue;
const marker = tag[2];
result.all.push(url);
if (marker === "write") {
result.write.push(url);
} else if (marker === "read") {
result.read.push(url);
} else {
result.write.push(url);
result.read.push(url);
}
}
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
return result;
} catch {
return { write: [], read: [], all: [] };
}
}
let _userRelaysCache: { relays: { url: string; read: boolean; write: boolean }[]; fetchedAt: number } | null = null;
const USER_RELAY_TTL = 2 * 60 * 1000; // 2 minutes
export async function getUserStoredRelays(): Promise<{ url: string; read: boolean; write: boolean }[]> {
if (_userRelaysCache && Date.now() - _userRelaysCache.fetchedAt < USER_RELAY_TTL) {
return _userRelaysCache.relays;
}
try {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
if (!token) return [];
const res = await fetch("/api/user-relays", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const data = await res.json();
if (Array.isArray(data)) {
const secure = data.filter((r) => isSecureRelay(r?.url));
_userRelaysCache = { relays: secure, fetchedAt: Date.now() };
return secure;
}
} catch {}
return [];
}
export function clearUserRelaysCache(): void {
_userRelaysCache = null;
}
export async function publishEvent(signedEvent: any): Promise<void> {
const { SimplePool } = await import("nostr-tools/pool");
const siteRelays = await getSiteRelays();
const allRelays = new Set<string>(siteRelays);
// Merge user's stored write relays
try {
const stored = await getUserStoredRelays();
stored
.filter((r) => r.write)
.forEach((r) => allRelays.add(r.url));
} catch {}
// Merge NIP-65 write relays for the event author
if (signedEvent.pubkey) {
try {
const nip65 = await fetchNip65RelayList(signedEvent.pubkey);
nip65.write.forEach((url) => allRelays.add(url));
} catch {}
}
// Merge extension relays
try {
if (window.nostr?.getRelays) {
const ext = await window.nostr.getRelays();
Object.entries(ext)
.filter(([, p]) => (p as any).write)
.forEach(([url]) => allRelays.add(url));
}
} catch {}
const relayUrls = filterSecureRelays(allRelays);
const pool = new SimplePool();
try {
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
} finally {
pool.close(relayUrls);
}
}
export async function fetchNostrProfile(
pubkey: string,
relayUrls?: string[]
): Promise<NostrProfile> {
const hex = toHexPubkey(pubkey);
if (!hex) return {};
const cached = readProfileCache(hex, Date.now());
if (cached) return cached;
const filter = { kinds: [0], authors: [hex] };
// Overlap the NIP-65 lookup with the first query rather than waiting on it.
const nip65Promise = fetchNip65RelayList(hex).catch(
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
);
const pool = await getReadPool();
const tried = new Set<string>();
try {
// Phase 1: provided relays (or site relays) + profile aggregators.
const phase1 = filterSecureRelays([
...(relayUrls || await getSiteRelays()),
...PROFILE_METADATA_RELAYS,
]);
phase1.forEach((u) => tried.add(u));
let event = await getEventBounded(pool, phase1, filter);
// Phase 2: author's NIP-65 write relays if not found yet.
if (!event?.content) {
const nip65 = await nip65Promise;
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
}
if (!event?.content) return {};
const profile = parseProfileContent(event.content);
writeProfileCache(hex, profile, Date.now());
return profile;
} catch {
return {};
}
}
function parseProfileContent(content: string): NostrProfile {
const meta = JSON.parse(content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
}
const _profileCache = new Map<string, { profile: NostrProfile; fetchedAt: number; empty: boolean }>();
const PROFILE_TTL = 5 * 60 * 1000; // 5 minutes for resolved profiles
const PROFILE_EMPTY_TTL = 30 * 1000; // retry misses sooner
function isProfileEmpty(p: NostrProfile): boolean {
return !p.name && !p.displayName && !p.picture && !p.about && !p.nip05;
}
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
const cached = _profileCache.get(pubkey);
if (cached) {
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
if (now - cached.fetchedAt < ttl) return cached.profile;
}
// Fall back to the persistent browser cache (survives reloads/navigation).
// Only resolved profiles are persisted, so a hit here is always non-empty.
const persisted = readCache<NostrProfile>(`profile:${pubkey}`, PROFILE_PERSIST_TTL);
if (persisted) {
_profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false });
return persisted;
}
return null;
}
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
const empty = isProfileEmpty(profile);
_profileCache.set(pubkey, { profile, fetchedAt: now, empty });
// Persist resolved profiles only; skip empties so a reload can retry the miss.
if (!empty) writeCache(`profile:${pubkey}`, profile);
}
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
// single SimplePool and one query, instead of opening a pool (plus a NIP-65
// lookup pool) per pubkey. Querying many profiles individually opens dozens of
// simultaneous websocket connections to the same relays, which get
// throttled/dropped and return empty results. Pubkeys are normalized to hex
// (relays reject npub/nprofile in `authors`).
export async function fetchNostrProfiles(
pubkeys: string[],
relayUrls?: string[]
): Promise<Record<string, NostrProfile>> {
const result: Record<string, NostrProfile> = {};
const now = Date.now();
// Map each requested key to its hex form. Skip cached and un-decodable keys.
const hexByKey = new Map<string, string>();
for (const pk of pubkeys) {
const cached = readProfileCache(pk, now);
if (cached) {
result[pk] = cached;
continue;
}
const hex = toHexPubkey(pk);
if (!hex) {
result[pk] = {};
continue;
}
hexByKey.set(pk, hex);
}
if (hexByKey.size === 0) return result;
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]);
const pool = await getReadPool();
try {
const authors = [...new Set(hexByKey.values())];
const events = await pool.querySync(
urls,
{ kinds: [0], authors },
{ maxWait: 6000 }
);
// Keep only the most recent kind:0 event per hex author.
const latest = new Map<string, { created_at: number; content: string }>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) {
latest.set(event.pubkey, { created_at: event.created_at, content: event.content });
}
}
for (const [key, hex] of hexByKey) {
const ev = latest.get(hex);
let profile: NostrProfile = {};
if (ev?.content) {
try {
profile = parseProfileContent(ev.content);
} catch {
profile = {};
}
}
result[key] = profile;
writeProfileCache(key, profile, now);
}
return result;
} catch {
for (const key of hexByKey.keys()) {
if (!(key in result)) result[key] = {};
}
return result;
}
}
// DataLoader-style batching for single-pubkey requests. Component instances
// (e.g. <NostrAvatar />) each ask for one pubkey; this coalesces all requests
// made within a short window into a single batched relay query.
let _batchQueue = new Set<string>();
let _batchResolvers = new Map<string, Array<(p: NostrProfile) => void>>();
let _batchTimer: ReturnType<typeof setTimeout> | null = null;
const BATCH_WINDOW_MS = 60;
async function flushProfileBatch(): Promise<void> {
const pubkeys = [..._batchQueue];
const resolvers = _batchResolvers;
_batchQueue = new Set();
_batchResolvers = new Map();
_batchTimer = null;
let profiles: Record<string, NostrProfile> = {};
try {
profiles = await fetchNostrProfiles(pubkeys);
} catch {
profiles = {};
}
for (const pk of pubkeys) {
const profile = profiles[pk] ?? {};
resolvers.get(pk)?.forEach((resolve) => resolve(profile));
}
}
// Resolve a single pubkey's profile, batching concurrent calls and reusing the
// shared profile cache.
export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
const cached = readProfileCache(pubkey, Date.now());
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
const existing = _batchResolvers.get(pubkey);
if (existing) {
existing.push(resolve);
} else {
_batchResolvers.set(pubkey, [resolve]);
}
_batchQueue.add(pubkey);
if (_batchTimer === null) {
_batchTimer = setTimeout(() => void flushProfileBatch(), BATCH_WINDOW_MS);
}
});
}
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
const cacheKey = `event:${eventId}`;
const cached = readCache<any>(cacheKey, EVENT_PERSIST_TTL);
if (cached) return cached;
const siteRelays = await getSiteRelays();
const pool = await getReadPool();
try {
const event = await getEventBounded(pool, siteRelays, { ids: [eventId] });
if (event) writeCache(cacheKey, event);
return event || null;
} catch {
return null;
}
}
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
const cacheKey = `longform:${naddrStr}`;
const cached = readCache<any>(cacheKey, LONGFORM_PERSIST_TTL);
if (cached) return cached;
const { nip19 } = await import("nostr-tools");
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
try {
const result = nip19.decode(naddrStr);
if (result.type !== "naddr") return null;
decoded = result.data;
} catch {
return null;
}
const siteRelays = await getSiteRelays();
const naddrRelays = filterSecureRelays(decoded.relays || []);
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
"#d": [decoded.identifier],
};
// Kick off the author's NIP-65 lookup immediately so it overlaps the first
// query instead of adding a serial round-trip before it.
const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch(
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
);
const pool = await getReadPool();
const tried = new Set<string>();
// Phase 1: naddr relay hints + site relays, all dialed in parallel. The
// hints usually point at the author's own relay, so this is the fast path.
const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]);
phase1.forEach((u) => tried.add(u));
let event = await getEventBounded(pool, phase1, filter);
// Phase 2: fall back to the author's NIP-65 write relays only if needed.
if (!event) {
const nip65 = await nip65Promise;
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
}
if (event) writeCache(cacheKey, event);
return event || null;
}
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
// event id to its underlying Nostr event by querying relays. Lets the blog page
// render a long-form note straight from a shared link even when it was never
// indexed by the backend. Returns null if it can't be decoded or found.
export async function resolveEventFromRelays(identifier: string): Promise<any | null> {
const trimmed = identifier.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return fetchEventFromRelays(trimmed.toLowerCase());
}
let decoded;
try {
decoded = nip19.decode(trimmed);
} catch {
return null;
}
switch (decoded.type) {
case "naddr":
return fetchLongformFromRelays(trimmed);
case "nevent":
return fetchEventFromRelays((decoded.data as { id: string }).id);
case "note":
return fetchEventFromRelays(decoded.data as string);
default:
return null;
}
}
// NIP-46: External signer (bunker:// URI)
export async function createBunkerSigner(
input: string
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
const { BunkerSigner, parseBunkerInput } = await import("nostr-tools/nip46");
const bp = await parseBunkerInput(input);
if (!bp) throw new Error("Invalid bunker URI or NIP-05 identifier");
const clientSecretKey = generateSecretKey();
const signer = BunkerSigner.fromBunker(clientSecretKey, bp);
await signer.connect();
const pubkey = await signer.getPublicKey();
return { signer, pubkey };
}
// NIP-46: Generate a nostrconnect:// URI for QR display
export async function generateNostrConnectSetup(
relayUrls?: string[]
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2);
const { createNostrConnectURI } = await import("nostr-tools/nip46");
const clientSecretKey = generateSecretKey();
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
const secretBytes = crypto.getRandomValues(new Uint8Array(8));
const secret = Array.from(secretBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const uri = createNostrConnectURI({
clientPubkey,
relays: relayUrls,
secret,
name: "Belgian Bitcoin Embassy",
});
return { uri, clientSecretKey };
}
// NIP-46: Wait for a remote signer to connect via nostrconnect:// URI
export async function waitForNostrConnectSigner(
clientSecretKey: Uint8Array,
uri: string,
signal?: AbortSignal
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
const { BunkerSigner } = await import("nostr-tools/nip46");
const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, signal);
const pubkey = await signer.getPublicKey();
return { signer, pubkey };
}