Add UserRelay API and dashboard relays tab with NIP-65 import, store post naddr for Nostr articles, and ship Prisma migrations for board tables, user relays, and post metadata. Co-authored-by: Cursor <cursoragent@cursor.com>
343 lines
9.6 KiB
TypeScript
343 lines
9.6 KiB
TypeScript
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
|
|
|
|
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)}`;
|
|
}
|
|
|
|
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",
|
|
];
|
|
|
|
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();
|
|
if (Array.isArray(data.relays) && data.relays.length > 0) {
|
|
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
|
|
return data.relays;
|
|
}
|
|
} 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 { SimplePool } = await import("nostr-tools/pool");
|
|
const siteRelays = await getSiteRelays();
|
|
const pool = new SimplePool();
|
|
|
|
try {
|
|
const event = await pool.get(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];
|
|
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: [] };
|
|
} finally {
|
|
pool.close(siteRelays);
|
|
}
|
|
}
|
|
|
|
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)) {
|
|
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
|
|
return data;
|
|
}
|
|
} 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 = [...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 { SimplePool } = await import("nostr-tools/pool");
|
|
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
|
|
|
|
try {
|
|
const nip65 = await fetchNip65RelayList(pubkey);
|
|
nip65.write.forEach((url) => allRelays.add(url));
|
|
} catch {}
|
|
|
|
const urls = [...allRelays];
|
|
const pool = new SimplePool();
|
|
|
|
try {
|
|
const event = await pool.get(urls, {
|
|
kinds: [0],
|
|
authors: [pubkey],
|
|
});
|
|
|
|
if (!event?.content) return {};
|
|
|
|
const meta = JSON.parse(event.content);
|
|
return {
|
|
name: meta.name || meta.display_name,
|
|
displayName: meta.display_name,
|
|
picture: meta.picture,
|
|
about: meta.about,
|
|
nip05: meta.nip05,
|
|
};
|
|
} catch {
|
|
return {};
|
|
} finally {
|
|
pool.close(urls);
|
|
}
|
|
}
|
|
|
|
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
|
const { SimplePool } = await import("nostr-tools/pool");
|
|
const siteRelays = await getSiteRelays();
|
|
const pool = new SimplePool();
|
|
|
|
try {
|
|
const event = await pool.get(siteRelays, { ids: [eventId] });
|
|
return event || null;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
pool.close(siteRelays);
|
|
}
|
|
}
|
|
|
|
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
|
const { SimplePool } = await import("nostr-tools/pool");
|
|
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 = decoded.relays || [];
|
|
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
|
|
|
|
try {
|
|
const nip65 = await fetchNip65RelayList(decoded.pubkey);
|
|
nip65.write.forEach((url) => allRelays.add(url));
|
|
} catch {}
|
|
|
|
const relayUrls = [...allRelays];
|
|
const filter = {
|
|
kinds: [decoded.kind],
|
|
authors: [decoded.pubkey],
|
|
"#d": [decoded.identifier],
|
|
};
|
|
|
|
const pool = new SimplePool();
|
|
try {
|
|
const event = await pool.get(relayUrls, filter);
|
|
return event || null;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
pool.close(relayUrls);
|
|
}
|
|
}
|
|
|
|
// 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 };
|
|
}
|