From 66f86db7829ba31b73e690a811be0c70bf2c6f0e Mon Sep 17 00:00:00 2001 From: highperfocused Date: Thu, 11 Jun 2026 09:58:06 +0200 Subject: [PATCH] Add NIP-86 relay management console --- src/AppRouter.tsx | 4 +- src/components/navigation/AppSidebar.tsx | 7 +- src/lib/nip86.test.ts | 29 +++ src/lib/nip86.ts | 123 +++++++++ src/pages/RelayManagement.tsx | 309 +++++++++++++++++++++++ 5 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 src/lib/nip86.test.ts create mode 100644 src/lib/nip86.ts create mode 100644 src/pages/RelayManagement.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index e3bb12e..56f06ff 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -6,6 +6,7 @@ import { Explore } from "./pages/Explore"; import { Dashboard } from "./pages/Dashboard"; import { DashboardEvents } from "./pages/DashboardEvents"; import { DashboardExport } from "./pages/DashboardExport"; +import { RelayManagement } from "./pages/RelayManagement"; import { NIP19Page } from "./pages/NIP19Page"; import { Terms } from "./pages/Terms"; import { Privacy } from "./pages/Privacy"; @@ -21,6 +22,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} @@ -31,4 +33,4 @@ export function AppRouter() { ); } -export default AppRouter; \ No newline at end of file +export default AppRouter; diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 79481d4..df986cf 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Home, LayoutDashboard, FileText, Download } from 'lucide-react'; +import { Home, LayoutDashboard, FileText, Download, RadioTower } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { Sidebar, @@ -34,6 +34,11 @@ const navigationItems = [ url: '/dashboard/export', icon: Download, }, + { + title: 'Relay Management', + url: '/dashboard/relays', + icon: RadioTower, + }, ]; export function AppSidebar() { diff --git a/src/lib/nip86.test.ts b/src/lib/nip86.test.ts new file mode 100644 index 0000000..a9932e5 --- /dev/null +++ b/src/lib/nip86.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isHex64, normalizeRelayManagementUrl } from './nip86'; + +describe('normalizeRelayManagementUrl', () => { + it('adds secure relay protocols to bare hosts', () => { + expect(normalizeRelayManagementUrl('relay.example.com')).toEqual({ + websocket: 'wss://relay.example.com/', + http: 'https://relay.example.com/', + }); + }); + + it('preserves paths while converting websocket URLs for HTTP management', () => { + expect(normalizeRelayManagementUrl('ws://localhost:7777/nostr')).toEqual({ + websocket: 'ws://localhost:7777/nostr', + http: 'http://localhost:7777/nostr', + }); + }); + + it('rejects unsupported protocols', () => { + expect(() => normalizeRelayManagementUrl('ftp://relay.example.com')).toThrow(); + }); +}); + +describe('isHex64', () => { + it('accepts Nostr pubkeys and event ids in hex form', () => { + expect(isHex64('a'.repeat(64))).toBe(true); + expect(isHex64('npub1nothex')).toBe(false); + }); +}); diff --git a/src/lib/nip86.ts b/src/lib/nip86.ts new file mode 100644 index 0000000..790353c --- /dev/null +++ b/src/lib/nip86.ts @@ -0,0 +1,123 @@ +import type { NUser } from '@nostrify/react/login'; + +export const NIP86_METHODS = [ + 'supportedmethods', + 'banpubkey', + 'unbanpubkey', + 'listbannedpubkeys', + 'allowpubkey', + 'unallowpubkey', + 'listallowedpubkeys', + 'listeventsneedingmoderation', + 'allowevent', + 'banevent', + 'listbannedevents', + 'changerelayname', + 'changerelaydescription', + 'changerelayicon', + 'allowkind', + 'disallowkind', + 'listallowedkinds', + 'blockip', + 'unblockip', + 'listblockedips', +] as const; + +export type Nip86Method = (typeof NIP86_METHODS)[number]; + +interface Nip86Response { + result?: T; + error?: string; +} + +export interface RelayManagementUrls { + websocket: string; + http: string; +} + +export function normalizeRelayManagementUrl(value: string): RelayManagementUrls { + const input = value.trim(); + if (!input) throw new Error('Enter a relay URL.'); + + const withProtocol = /^[a-z]+:\/\//i.test(input) ? input : `wss://${input}`; + const url = new URL(withProtocol); + + if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol)) { + throw new Error('Relay URLs must use ws, wss, http, or https.'); + } + + url.hash = ''; + const websocket = new URL(url); + websocket.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol; + + const http = new URL(url); + http.protocol = url.protocol === 'ws:' ? 'http:' : url.protocol === 'wss:' ? 'https:' : url.protocol; + + return { websocket: websocket.toString(), http: http.toString() }; +} + +export function isHex64(value: string): boolean { + return /^[0-9a-f]{64}$/i.test(value.trim()); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function callNip86( + user: NUser, + relayUrl: string, + method: Nip86Method, + params: unknown[] = [], +): Promise { + const { http } = normalizeRelayManagementUrl(relayUrl); + const body = JSON.stringify({ method, params }); + const payload = await sha256Hex(body); + const event = await user.signer.signEvent({ + kind: 27235, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', http], + ['method', 'POST'], + ['payload', payload], + ], + }); + + let response: Response; + try { + response = await fetch(http, { + method: 'POST', + headers: { + Accept: 'application/nostr+json+rpc, application/json', + Authorization: `Nostr ${btoa(JSON.stringify(event))}`, + 'Content-Type': 'application/nostr+json+rpc', + }, + body, + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + if (error instanceof DOMException && error.name === 'TimeoutError') { + throw new Error('The relay management endpoint timed out.'); + } + throw new Error('Could not reach the management endpoint. The relay may not support NIP-86 or browser CORS requests.'); + } + + if (response.status === 401) throw new Error('The relay rejected the NIP-98 authorization.'); + if (!response.ok) throw new Error(`Relay returned HTTP ${response.status} ${response.statusText}.`); + + let data: Nip86Response; + try { + data = await response.json() as Nip86Response; + } catch { + throw new Error('The relay returned an invalid NIP-86 response.'); + } + + if (data.error) throw new Error(data.error); + if (!Object.prototype.hasOwnProperty.call(data, 'result')) { + throw new Error('The relay response did not include a result.'); + } + + return data.result as T; +} diff --git a/src/pages/RelayManagement.tsx b/src/pages/RelayManagement.tsx new file mode 100644 index 0000000..0c0e024 --- /dev/null +++ b/src/pages/RelayManagement.tsx @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Activity, + Ban, + Check, + CircleAlert, + FileWarning, + Globe2, + KeyRound, + Loader2, + Network, + RefreshCw, + Save, + ServerCog, + ShieldX, + Tags, + Wifi, + X, +} from 'lucide-react'; +import { AppSidebar } from '@/components/navigation/AppSidebar'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { callNip86, isHex64, type Nip86Method, normalizeRelayManagementUrl } from '@/lib/nip86'; + +interface ReasonEntry { reason?: string } +interface PubkeyEntry extends ReasonEntry { pubkey: string } +interface EventEntry extends ReasonEntry { id: string } +interface IpEntry extends ReasonEntry { ip: string } + +const STORAGE_KEY = 'nostr:nip86-relay'; + +function MethodBadge({ method, supported }: { method: Nip86Method; supported: Set }) { + const available = supported.has(method); + return ( + + {available ? : } + {method} + + ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function EntryList({ + entries, + value, + actionLabel, + onAction, + disabled, +}: { + entries: T[]; + value: (entry: T) => string; + actionLabel: string; + onAction: (entry: T) => void; + disabled?: boolean; +}) { + if (!entries.length) return No entries returned by the relay.; + return ( +
+ {entries.map((entry) => { + const entryValue = value(entry); + return ( +
+
+

{entryValue}

+

{entry.reason || 'No reason provided'}

+
+ +
+ ); + })} +
+ ); +} + +export function RelayManagement() { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const [relayInput, setRelayInput] = useState(() => localStorage.getItem(STORAGE_KEY) || 'wss://'); + const [relayUrl, setRelayUrl] = useState(''); + const [supportedMethods, setSupportedMethods] = useState([]); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(''); + const [pubkey, setPubkey] = useState(''); + const [pubkeyReason, setPubkeyReason] = useState(''); + const [pubkeyAction, setPubkeyAction] = useState<'banpubkey' | 'allowpubkey'>('banpubkey'); + const [bannedPubkeys, setBannedPubkeys] = useState([]); + const [allowedPubkeys, setAllowedPubkeys] = useState([]); + const [eventId, setEventId] = useState(''); + const [eventReason, setEventReason] = useState(''); + const [eventAction, setEventAction] = useState<'banevent' | 'allowevent'>('banevent'); + const [moderationEvents, setModerationEvents] = useState([]); + const [bannedEvents, setBannedEvents] = useState([]); + const [kind, setKind] = useState(''); + const [allowedKinds, setAllowedKinds] = useState([]); + const [ip, setIp] = useState(''); + const [ipReason, setIpReason] = useState(''); + const [blockedIps, setBlockedIps] = useState([]); + const [relayName, setRelayName] = useState(''); + const [relayDescription, setRelayDescription] = useState(''); + const [relayIcon, setRelayIcon] = useState(''); + + const supported = useMemo(() => new Set(supportedMethods), [supportedMethods]); + + const invoke = useCallback(async (method: Nip86Method, params: unknown[] = []): Promise => { + if (!user) throw new Error('Log in with a Nostr account first.'); + if (!relayUrl) throw new Error('Connect to a relay first.'); + setBusy(method); + setError(''); + try { + return await callNip86(user, relayUrl, method, params); + } catch (caught) { + const message = caught instanceof Error ? caught.message : 'Relay management request failed.'; + setError(message); + throw caught; + } finally { + setBusy(''); + } + }, [relayUrl, user]); + + const refreshPubkeys = useCallback(async () => { + const requests: Promise[] = []; + if (supported.has('listbannedpubkeys')) requests.push(invoke('listbannedpubkeys').then(setBannedPubkeys)); + if (supported.has('listallowedpubkeys')) requests.push(invoke('listallowedpubkeys').then(setAllowedPubkeys)); + await Promise.all(requests); + }, [invoke, supported]); + + const refreshEvents = useCallback(async () => { + const requests: Promise[] = []; + if (supported.has('listeventsneedingmoderation')) requests.push(invoke('listeventsneedingmoderation').then(setModerationEvents)); + if (supported.has('listbannedevents')) requests.push(invoke('listbannedevents').then(setBannedEvents)); + await Promise.all(requests); + }, [invoke, supported]); + + const refreshKinds = useCallback(async () => { + if (supported.has('listallowedkinds')) setAllowedKinds(await invoke('listallowedkinds')); + }, [invoke, supported]); + + const refreshIps = useCallback(async () => { + if (supported.has('listblockedips')) setBlockedIps(await invoke('listblockedips')); + }, [invoke, supported]); + + const connect = async () => { + if (!user) return; + try { + const normalized = normalizeRelayManagementUrl(relayInput); + setBusy('supportedmethods'); + setError(''); + const methods = await callNip86(user, normalized.websocket, 'supportedmethods'); + setRelayUrl(normalized.websocket); + setSupportedMethods(methods); + localStorage.setItem(STORAGE_KEY, normalized.websocket); + toast({ title: 'Relay connected', description: `${methods.length} management methods detected.` }); + } catch (caught) { + setRelayUrl(''); + setSupportedMethods([]); + setError(caught instanceof Error ? caught.message : 'Could not connect to the relay.'); + } finally { + setBusy(''); + } + }; + + useEffect(() => { + setBannedPubkeys([]); + setAllowedPubkeys([]); + setModerationEvents([]); + setBannedEvents([]); + setAllowedKinds([]); + setBlockedIps([]); + }, [relayUrl]); + + const runAction = async (method: Nip86Method, params: unknown[], success: string, refresh?: () => Promise) => { + try { + await invoke(method, params); + toast({ title: success }); + await refresh?.(); + } catch { + // The shared request handler exposes the relay error in the page alert. + } + }; + + const submitPubkey = async () => { + if (!isHex64(pubkey)) return setError('Pubkeys must be 64-character hexadecimal values.'); + await runAction(pubkeyAction, [pubkey.trim(), pubkeyReason.trim()], pubkeyAction === 'banpubkey' ? 'Pubkey banned' : 'Pubkey allowed', refreshPubkeys); + setPubkey(''); + setPubkeyReason(''); + }; + + const submitEvent = async () => { + if (!isHex64(eventId)) return setError('Event IDs must be 64-character hexadecimal values.'); + await runAction(eventAction, [eventId.trim(), eventReason.trim()], eventAction === 'banevent' ? 'Event banned' : 'Event allowed', refreshEvents); + setEventId(''); + setEventReason(''); + }; + + const connectedHost = relayUrl ? new URL(relayUrl).host : ''; + + return ( + +
+ +
+
+ +

Relay Management

+ {relayUrl && {connectedHost}} +
+ +
+
+
+
+
+
NIP-86 control plane
+

Operate your relay from one console.

+

Inspect supported methods, moderate events, manage access rules, and update relay settings through signed NIP-98 requests.

+
+
+ +
+ setRelayInput(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && void connect()} placeholder="wss://relay.example.com" className="border-white/15 bg-black/40 font-mono text-zinc-100 placeholder:text-zinc-600" /> + +
+

The WebSocket URL is converted to HTTP(S) for NIP-86 requests.

+
+
+
+ + {!user && Nostr login requiredUse the account control in the sidebar to sign NIP-98 authorization events.} + {error && Management request failed{error}} + + {!relayUrl ? ( +

Connect a NIP-86 relay

The console first calls supportedmethods and only enables controls advertised by that relay.

+ ) : ( + <> +
+ {[ + { label: 'Methods', value: supportedMethods.length, icon: Activity }, + { label: 'Banned pubkeys', value: bannedPubkeys.length, icon: ShieldX }, + { label: 'Moderation queue', value: moderationEvents.length, icon: FileWarning }, + { label: 'Allowed kinds', value: allowedKinds.length, icon: Tags }, + ].map((stat) =>

{stat.label}

{stat.value}

)} +
+ + + + AccessEventsKindsNetworkSettings + + + + Pubkey ruleBan a key or add it to the relay allowlist. +
+
setPubkey(event.target.value)} placeholder="64-character public key" />
+
setPubkeyReason(event.target.value)} placeholder="Optional operator note" />
+ +
+
Access listsCurrent relay-level pubkey controls.
Banned ({bannedPubkeys.length})Allowed ({allowedPubkeys.length}) entry.pubkey} actionLabel="Unban" disabled={!supported.has('unbanpubkey')} onAction={(entry) => void runAction('unbanpubkey', [entry.pubkey], 'Pubkey unbanned', refreshPubkeys)} /> entry.pubkey} actionLabel="Remove" disabled={!supported.has('unallowpubkey')} onAction={(entry) => void runAction('unallowpubkey', [entry.pubkey], 'Pubkey removed from allowlist', refreshPubkeys)} />
+
+ + + Event decisionApply a moderation decision by event ID. +
+
setEventId(event.target.value)} placeholder="64-character event ID" />
+
setEventReason(event.target.value)} placeholder="Optional operator note" />
+ +
+
ModerationPending and banned relay events.
Pending ({moderationEvents.length})Banned ({bannedEvents.length}) entry.id} actionLabel="Allow" disabled={!supported.has('allowevent')} onAction={(entry) => void runAction('allowevent', [entry.id, 'Approved in relay console'], 'Event allowed', refreshEvents)} /> entry.id} actionLabel="Allow" disabled={!supported.has('allowevent')} onAction={(entry) => void runAction('allowevent', [entry.id, 'Reinstated in relay console'], 'Event allowed', refreshEvents)} />
+
+ +
Allowed event kindsManage kind-level admission policy.
setKind(event.target.value)} placeholder="e.g. 1" />
{allowedKinds.length ? allowedKinds.sort((a, b) => a - b).map((item) => {item}) : Refresh to load allowed kinds.}
+ +
Blocked IP addressesMaintain relay network deny rules.
setIp(event.target.value)} placeholder="203.0.113.42" />
setIpReason(event.target.value)} placeholder="Optional operator note" />
entry.ip} actionLabel="Unblock" disabled={!supported.has('unblockip')} onAction={(entry) => void runAction('unblockip', [entry.ip], 'IP address unblocked', refreshIps)} />
+ + + Relay identityEach field is saved with its own NIP-86 method. +
setRelayName(event.target.value)} placeholder="My relay" />
+