/** * NIP-86 Relay Management API client (with NIP-98 HTTP auth). * * The SkateSpots community relay (goskatespots.nostr1.com) runs on relay.tools, * which implements the NIP-86 management API. As the relay OWNER (or an assigned * moderator), an admin can genuinely remove an event from the relay's database — * not merely publish a best-effort kind:5 deletion request that other relays may * ignore. `banevent` is the method that does this. * * Auth is a NIP-98 kind:27235 event signed by the caller (via their Amber / * NIP-07 / nsec signer). The relay checks the signer's pubkey against its owner * / moderator list, so only genuine admins can act. * * Docs: NIP-86 (relay management), NIP-98 (HTTP auth). */ import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; import { SKATE_RELAY } from '@/lib/types'; import type { NostrSigner } from '@nostrify/nostrify'; /** wss:// → https:// (the management API lives on the same URI as the socket). */ function relayHttpUrl(wss: string): string { return wss.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://'); } /** Base64-encode a UTF-8 string (browser-safe). */ function base64(json: string): string { // btoa needs a binary string; encode UTF-8 first. const bytes = new TextEncoder().encode(json); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin); } interface Nip86Response { result?: T; error?: string; } /** * Perform a single NIP-86 JSON-RPC call against a relay's HTTP endpoint, * authenticated with a freshly-signed NIP-98 event. * * @throws if the network request fails, auth is rejected (401), or the relay * returns an `error` field. */ export async function relayManagementCall(opts: { signer: NostrSigner; method: string; params: unknown[]; relayWss?: string; signal?: AbortSignal; }): Promise { const { signer, method, params, relayWss = SKATE_RELAY, signal } = opts; const url = relayHttpUrl(relayWss); const body = JSON.stringify({ method, params }); // NIP-98: kind 27235, tags u + method (+ payload hash, required by NIP-86). const payloadHash = bytesToHex(sha256(new TextEncoder().encode(body))); const authEvent = await signer.signEvent({ kind: 27235, content: '', created_at: Math.floor(Date.now() / 1000), tags: [ ['u', url], ['method', 'POST'], ['payload', payloadHash], ], }); const doFetch = (target: string) => fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/nostr+json+rpc', Authorization: `Nostr ${base64(JSON.stringify(authEvent))}`, }, body, signal, }); let res: Response; try { res = await doFetch(url); } catch { // Some WebView/browser contexts block the cross-origin POST. Retry once via // the configured CORS proxy (auth header + body are preserved). const proxied = `https://proxy.shakespeare.diy/?url=${encodeURIComponent(url)}`; res = await doFetch(proxied); } if (res.status === 401) { throw new Error( 'The relay rejected the request. Your account may not be an owner or ' + 'moderator of this relay.', ); } if (!res.ok) { throw new Error(`Relay management call failed (HTTP ${res.status}).`); } const json = (await res.json()) as Nip86Response; if (json.error) throw new Error(json.error); return json.result as T; } /** * Ask the community relay which NIP-86 methods it supports. Useful to gate the * admin UI (and to confirm the relay speaks NIP-86 at all). Returns [] on any * failure so callers can degrade gracefully. */ export async function getRelaySupportedMethods( signer: NostrSigner, relayWss: string = SKATE_RELAY, signal?: AbortSignal, ): Promise { try { const methods = await relayManagementCall({ signer, method: 'supportedmethods', params: [], relayWss, signal, }); return Array.isArray(methods) ? methods : []; } catch { return []; } } /** * Remove an event from the community relay's database (NIP-86 `banevent`). * This is a REAL takedown on a relay we control — unlike a NIP-09 kind:5 * request, the relay honors it because the caller is an owner/moderator. */ export async function banEventOnRelay(opts: { signer: NostrSigner; eventId: string; reason?: string; relayWss?: string; signal?: AbortSignal; }): Promise { const { signer, eventId, reason, relayWss, signal } = opts; await relayManagementCall({ signer, method: 'banevent', params: reason ? [eventId, reason] : [eventId], relayWss, signal, }); }