SkateSpots/src/lib/relayAdmin.ts
SkateSpots 6407310213 feat(relay): cut over community relay to self-hosted Newlay
Point SKATE_RELAY, the default relay pools, the DM/CVM/zap relay arrays, the cvm-server + push-service defaults, and the archive default at wss://relay.skate-spots.org, keeping the legacy hosted relay as a fallback during the client-update window. Restored 115/115 archived events; the relay runs behind Caddy with TLS. Docs + AGENTS.md updated.
2026-10-12 12:38:13 +07:00

155 lines
4.8 KiB
TypeScript

/**
* NIP-86 Relay Management API client (with NIP-98 HTTP auth).
*
* The SkateSpots community relay (relay.skate-spots.org) is our own Newlay
* instance (self-hosted since 2026-10-12); Newlay advertises NIP-86. 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. (NIP-86 admin rights must be configured relay-side; until then a
* call 401s and the admin UI falls back to NIP-32 label hiding.)
*
* 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,
});
}