diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index fdcde5c..95e7575 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -7,6 +7,7 @@ import { MODERATION_LABEL_HIDDEN, } from '@/lib/types'; import { addLocalHidden } from '@/lib/localModeration'; +import { banEventOnRelay } from '@/lib/relayAdmin'; import type { NostrEvent } from '@nostrify/nostrify'; /** @@ -143,6 +144,25 @@ export function useDeleteEntity() { labelError, ); }); + + // REAL takedown on our own relay (NIP-86 `banevent`). Because the admin + // is the relay owner/moderator, goskatespots.nostr1.com honors this and + // actually removes the event from its database — unlike a cross-author + // kind:5 request. Also background/best-effort so it never blocks the UI; + // this may prompt one more signer approval (NIP-98 auth event). + if (user.signer) { + void banEventOnRelay({ + signer: user.signer, + eventId: event.id, + reason: reason?.trim() || 'Removed by SkateSpots admin', + }).catch((banError) => { + console.warn( + '[useDeleteEntity] relay banevent failed (content is hidden in-app ' + + 'regardless; you can retry from the relay moderator portal):', + banError, + ); + }); + } } } } diff --git a/src/lib/relayAdmin.ts b/src/lib/relayAdmin.ts new file mode 100644 index 0000000..9f925ce --- /dev/null +++ b/src/lib/relayAdmin.ts @@ -0,0 +1,153 @@ +/** + * 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, + }); +}