Add real relay-level takedown (NIP-86 banevent) for admin deletes

The community relay goskatespots.nostr1.com runs on relay.tools, which
implements the NIP-86 Relay Management API. As the relay owner/moderator,
an admin can genuinely remove an event from the relay's database via
`banevent` — unlike a NIP-09 kind:5 request that most relays ignore for
non-authors.

- New src/lib/relayAdmin.ts: NIP-86 JSON-RPC client with NIP-98 HTTP auth
  (kind:27235 signed by the user's signer, payload hash included per
  NIP-86). Includes banEventOnRelay + getRelaySupportedMethods, with a
  CORS-proxy fallback for WebView contexts.
- useDeleteEntity admin path now also fires banEventOnRelay in the
  background (best-effort, never blocks the spinner), so admin takedowns
  actually erase the event from our own relay in addition to the kind:5
  request, optimistic local hide, and cross-device kind:1985 label.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 23:07:14 +07:00
parent 79e8376f58
commit c3fc033b37
2 changed files with 173 additions and 0 deletions

View file

@ -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,
);
});
}
}
}
}

153
src/lib/relayAdmin.ts Normal file
View file

@ -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<T> {
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<T = unknown>(opts: {
signer: NostrSigner;
method: string;
params: unknown[];
relayWss?: string;
signal?: AbortSignal;
}): Promise<T> {
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<T>;
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<string[]> {
try {
const methods = await relayManagementCall<string[]>({
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<void> {
const { signer, eventId, reason, relayWss, signal } = opts;
await relayManagementCall({
signer,
method: 'banevent',
params: reason ? [eventId, reason] : [eventId],
relayWss,
signal,
});
}