From 3868e088b4efa1705378896d57c1f8bc818cf04b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:35:53 +0200 Subject: [PATCH] Add NIP-86 relay management console (Relay Admin app) (#73) * Initial plan * Add NIP-86 protocol client library with tests Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> * Add NIP-86 session hook and relay-admin app UI Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> * Add integration tests, NIP.md, and apps.md docs for Relay Admin Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> * Address review feedback on Relay Admin app - sanitizeIconUrl() now only allows http:// for local relay hostnames (localhost/127.0.0.1/::1/*.local), matching its docstring and error message instead of accepting arbitrary http:// URLs. - Scope the discovery-time cache clear to the previous session's own relay URL instead of removing every ['nip86'] query, so connecting in one Relay Admin window no longer disrupts other open windows. - Use the AllowedPubkey type (not BannedPubkey) when mapping allowed- pubkey entries, since the structural overlap today made a real type mismatch invisible. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 --- NIP.md | 29 + docs/apps.md | 18 + src/apps/relay-admin/AuditSection.tsx | 108 ++++ src/apps/relay-admin/ExtensionsSection.tsx | 86 +++ src/apps/relay-admin/ModerationSection.tsx | 338 ++++++++++ src/apps/relay-admin/PolicySections.tsx | 713 +++++++++++++++++++++ src/apps/relay-admin/RelaySection.tsx | 245 +++++++ src/apps/relay-admin/RolesSection.tsx | 487 ++++++++++++++ src/apps/relay-admin/dialogs.tsx | 343 ++++++++++ src/apps/relay-admin/index.tsx | 367 +++++++++++ src/apps/relay-admin/shared.tsx | 178 +++++ src/apps/relay-admin/useFilter.ts | 15 + src/hooks/useNip86.test.tsx | 214 +++++++ src/hooks/useNip86.ts | 418 ++++++++++++ src/lib/nip86.test.ts | 327 ++++++++++ src/lib/nip86.ts | 646 +++++++++++++++++++ src/os/registry.ts | 13 +- 17 files changed, 4544 insertions(+), 1 deletion(-) create mode 100644 NIP.md create mode 100644 src/apps/relay-admin/AuditSection.tsx create mode 100644 src/apps/relay-admin/ExtensionsSection.tsx create mode 100644 src/apps/relay-admin/ModerationSection.tsx create mode 100644 src/apps/relay-admin/PolicySections.tsx create mode 100644 src/apps/relay-admin/RelaySection.tsx create mode 100644 src/apps/relay-admin/RolesSection.tsx create mode 100644 src/apps/relay-admin/dialogs.tsx create mode 100644 src/apps/relay-admin/index.tsx create mode 100644 src/apps/relay-admin/shared.tsx create mode 100644 src/apps/relay-admin/useFilter.ts create mode 100644 src/hooks/useNip86.test.tsx create mode 100644 src/hooks/useNip86.ts create mode 100644 src/lib/nip86.test.ts create mode 100644 src/lib/nip86.ts diff --git a/NIP.md b/NIP.md new file mode 100644 index 0000000..b52b94d --- /dev/null +++ b/NIP.md @@ -0,0 +1,29 @@ +# NIPs and custom schemas + +This project defines **no custom event kinds**. It implements and adopts the following +protocols: + +## Implemented NIPs + +- [NIP-86: Relay Management API](https://github.com/nostr-protocol/nips/blob/master/86.md) + (draft, optional) — the Relay Admin app (`src/apps/relay-admin/`) is a management client. + Requests are JSON-RPC-like POSTs over HTTP(S) on the relay's own URI with the + `application/nostr+json+rpc` content type, authorized with a NIP-98 event (kind 27235) + whose `u` tag is the relay URL and whose `payload` tag binds it to the request body. + The client treats `supportedmethods` as the source of truth and only calls methods the + relay advertised; advertised names outside the standard method list are treated as + relay-specific extensions and kept visually and semantically separate. No generic + event-purge/delete method is assumed — NIP-86 does not define one. +- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md) — used for + NIP-86 authorization (with the NIP-86-required `payload` tag) and for Blossom uploads. +- [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) + — read at connect time to show relay identity in Relay Admin. + +## Adopted third-party kinds + +- **Kind `777` ("Spell")** — a third-party draft NIP from the + [Grimoire](https://github.com/purrgrammer/grimoire) client, adopted as-is for interop. + See `docs/apps.md` ("Spells are a third-party kind") and `src/hooks/useSpells.ts`. + +Anything else (kinds 0, 1, 3, 5, 6, 16, 9802, 10002, 10003, 22242, 30023, 30311, 31337, +39701, …) follows the official NIPs as implemented in `src/hooks/` and `src/lib/`. diff --git a/docs/apps.md b/docs/apps.md index 5bf3981..7d554e2 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -102,6 +102,7 @@ export default function ExampleApp({ setTitle }: AppProps) { | Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat | | Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP | | Relays | `relays` | — | Connection state, subscription count, measured latency | +| Relay Admin | `relay-admin` | `relay?` | NIP-86 management console for relays you operate | | Settings | `settings` | — | Theme, relay list, Blossom servers, account, session | | About | `about` | — | What this is, the app list, the shortcuts | @@ -150,6 +151,23 @@ update, the same trap [follow lists](#follow-lists-are-a-whole-list-replacement) kind 3 replaces the entire contact list. The follow button therefore reads the current list back before writing, or the edit would silently drop everyone else. +### Relay Admin is capability-driven, not method-driven + +The Relay Admin app (`src/apps/relay-admin/`) implements [NIP-86](../NIP.md) — the draft, +optional relay-management API. Because implementations vary and the NIP is still a draft, +the console never assumes a method exists: it connects, authorizes with a NIP-98 event +(kind 27235, `u` + `payload` tags), calls `supportedmethods`, and renders only the +sections the relay advertised. Advertised names outside the standard list (e.g. a +relay-specific `purgeallevents` or `listroles`) land in a separate, clearly-labelled +extensions area that requires typed confirmation — they are never blended into the +standard surface, and the standard defines no generic event-purge/delete method. + +Destructive-but-reversible operations (bans, unbans, removing allowlist access, role +deletion) go through a confirmation dialog that states the target, the likely effect and +whether the relay offers a reverse operation. Every operation is written to a per-session +audit log (method, target, result, operator-safe error) that never contains the +authorization header or any key material; signing secrets stay inside the user's signer. + ### Relay latency is a real round trip A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE` diff --git a/src/apps/relay-admin/AuditSection.tsx b/src/apps/relay-admin/AuditSection.tsx new file mode 100644 index 0000000..078471a --- /dev/null +++ b/src/apps/relay-admin/AuditSection.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { AuditEntry } from '@/hooks/useNip86'; +import { ListEmpty, Section } from './shared'; + +const STATUS_LABEL: Record = { + ok: 'OK', + failed: 'Failed', + cancelled: 'Cancelled', +}; + +const STATUS_TONE: Record = { + ok: 'text-success', + failed: 'text-destructive', + cancelled: 'text-muted-foreground', +}; + +/** + * The session audit log: every management operation with its target, result + * and operator-safe error detail. Entries are kept in memory only (never + * persisted), capped, and contain no secrets — the "Copy" export is safe to + * paste into an incident report. + */ +export function AuditSection({ + audit, + onClear, +}: { + audit: AuditEntry[]; + onClear: () => void; +}) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + const lines = audit + .map((entry) => + [ + new Date(entry.at).toISOString(), + entry.status.toUpperCase().padEnd(9), + entry.method, + `→ ${entry.target}`, + entry.detail ? `(${entry.detail})` : '', + ] + .filter(Boolean) + .join(' '), + ) + .join('\n'); + try { + await navigator.clipboard.writeText(lines); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard may be unavailable (permissions); the log stays on screen. + } + }; + + return ( +
0 ? ( +
+ + +
+ ) : undefined + } + > + {audit.length === 0 ? ( + + ) : ( +
    + {[...audit].reverse().map((entry) => ( +
  1. + + {new Date(entry.at).toLocaleTimeString()} + + + {STATUS_LABEL[entry.status]} + + + {entry.method}{' '} + → {entry.target} + {entry.detail && ( + + {entry.detail} + + )} + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/apps/relay-admin/ExtensionsSection.tsx b/src/apps/relay-admin/ExtensionsSection.tsx new file mode 100644 index 0000000..28a995e --- /dev/null +++ b/src/apps/relay-admin/ExtensionsSection.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { Puzzle } from 'lucide-react'; + +import { useNip86Mutation, type AuditEntry, type Nip86Session } from '@/hooks/useNip86'; +import { useToast } from '@/hooks/useToast'; +import { TypedConfirmAction, type PendingTypedConfirm } from './dialogs'; +import { Section } from './shared'; + +type AuditFn = (entry: Omit) => void; + +/** + * Relay-specific extensions: advertised method names that are not part of the + * NIP-86 standard (e.g. a documented event-purge operation). They are kept + * visually and semantically separate from the core console, and every single + * one requires typed confirmation — nothing here is assumed to be reversible. + * + * The console deliberately does not guess parameters: an extension's argument + * list is defined by the relay, not by NIP-86, so these operations are + * triggered without params and the relay's own documentation has the final + * word on what they do. + */ +export function ExtensionsSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: AuditFn; +}) { + const { toast } = useToast(); + const mutation = useNip86Mutation(session, { onResult }); + const [pending, setPending] = useState(undefined); + + return ( +
+
    + {session.extensions.map((method) => ( +
  • + + + +
    +

    {method}

    +

    + Not part of NIP-86 — see this relay’s documentation for what it does and what it + destroys. +

    +
    + +
  • + ))} +
+ + setPending(undefined)} /> +
+ ); +} diff --git a/src/apps/relay-admin/ModerationSection.tsx b/src/apps/relay-admin/ModerationSection.tsx new file mode 100644 index 0000000..d169b85 --- /dev/null +++ b/src/apps/relay-admin/ModerationSection.tsx @@ -0,0 +1,338 @@ +import { useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { Plus } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useAuthor } from '@/hooks/useAuthor'; +import { + useBannedEvents, + useEventsNeedingModeration, + useNip86Mutation, + type AuditEntry, + type Nip86Session, +} from '@/hooks/useNip86'; +import { useToast } from '@/hooks/useToast'; +import { parseEventIdInput, relayWsUrl, type ModeratedEventRef } from '@/lib/nip86'; +import { displayName, relativeTime } from '@/lib/nostrUtils'; +import { BanEventDialog, ConfirmAction, type PendingConfirm } from './dialogs'; +import { useFilter } from './useFilter'; +import { + IdText, + ListEmpty, + ListError, + ListSkeleton, + RowAction, + Section, + SectionToolbar, +} from './shared'; + +type AuditFn = (entry: Omit) => void; + +/** + * Fetch the full event behind a moderation row straight from the managed + * relay, so the operator reviews the actual content before deciding. The + * event may be gone (already deleted, or never stored) — that is shown + * honestly instead of hiding the row. + */ +function useModeratedEvent(session: Nip86Session, id: string) { + const { nostr } = useNostr(); + const relay = relayWsUrl(session.url); + + return useQuery({ + queryKey: ['nip86', session.url, 'moderated-event', id], + staleTime: 60_000, + queryFn: async ({ signal }) => { + const [event] = await nostr.query([{ ids: [id] }], { + signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), + relays: [relay], + }); + return event ?? null; + }, + }); +} + +type Decision = 'allowevent' | 'banevent'; + +const DECISION_CONFIRM: Record Omit> = { + allowevent: (id) => ({ + title: 'Allow this event?', + target: id, + effect: 'The relay will keep and serve this event. It leaves the moderation queue.', + reversible: 'Reversible: you can ban the event later.', + actionLabel: 'Allow event', + }), + banevent: (id, canReverse) => ({ + title: 'Ban this event?', + target: id, + effect: + 'The relay will stop serving this event and may delete its stored copy. The event can still exist on other relays.', + reversible: canReverse ? 'Reversible: you can allow the event again.' : undefined, + actionLabel: 'Ban event', + }), +}; + +function ModerationRow({ + session, + entry, + canAllow, + canBan, + pending, + onDecide, +}: { + session: Nip86Session; + entry: ModeratedEventRef; + canAllow: boolean; + canBan: boolean; + pending: boolean; + onDecide: (decision: Decision, id: string) => void; +}) { + const event = useModeratedEvent(session, entry.id); + const author = useAuthor(event.data?.pubkey); + + return ( +
  • +
    +
    + + {entry.reason && ( +

    + Flagged: {entry.reason} +

    + )} +
    + {(canAllow || canBan) && ( +
    + {canAllow && ( + onDecide('allowevent', entry.id)} /> + )} + {canBan && ( + onDecide('banevent', entry.id)} + /> + )} +
    + )} +
    + + {/* Review context: the event itself, straight from the managed relay. */} + {event.isLoading ? ( +

    Loading the event for review…

    + ) : event.data ? ( +
    +

    + {displayName(event.data.pubkey, author.data?.metadata)} · kind {event.data.kind} ·{' '} + {relativeTime(event.data.created_at)} +

    +

    + {event.data.content} +

    +
    + ) : ( +

    + The event itself is not available from this relay — it may already be deleted. You can + still ban the ID to keep it out. +

    + )} +
  • + ); +} + +export function EventModerationSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: AuditFn; +}) { + const queue = useEventsNeedingModeration(session, { onResult }); + const banned = useBannedEvents(session, { onResult }); + const { toast } = useToast(); + const mutation = useNip86Mutation(session, { onResult }); + + const [banOpen, setBanOpen] = useState(false); + const [confirm, setConfirm] = useState(undefined); + const queueFilter = useFilter(queue.data, (entry) => [entry.id, entry.reason]); + const bannedFilter = useFilter(banned.data, (entry) => [entry.id, entry.reason]); + + const hasQueue = session.methods.includes('listeventsneedingmoderation'); + const hasBanned = session.methods.includes('listbannedevents'); + const canAllow = session.methods.includes('allowevent'); + const canBan = session.methods.includes('banevent'); + + const run = async (method: Decision, id: string, success: string) => { + try { + await mutation.mutateAsync({ + method, + params: [id], + refresh: ['listeventsneedingmoderation', 'listbannedevents'], + }); + toast({ title: success }); + } catch (error) { + toast({ + title: 'The relay refused the operation', + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + throw error; + } + }; + + const decide = (decision: Decision, id: string) => + setConfirm({ + ...DECISION_CONFIRM[decision](id, canAllow), + run: () => run(decision, id, decision === 'banevent' ? 'Event banned' : 'Event allowed'), + }); + + return ( +
    setBanOpen(true)}> + + Ban by ID + + ) : undefined + } + > + {hasQueue && ( + <> + queue.refetch()} + refreshing={queue.isFetching} + /> + {queue.isLoading ? ( + + ) : queue.isError ? ( + queue.refetch()} /> + ) : !queueFilter.filtered || queueFilter.filtered.length === 0 ? ( + + ) : ( +
      + {queueFilter.filtered.map((entry) => ( + + ))} +
    + )} + + )} + + {hasBanned && ( +
    +

    + Banned events +

    + banned.refetch()} + refreshing={banned.isFetching} + /> + {banned.isLoading ? ( + + ) : banned.isError ? ( + banned.refetch()} /> + ) : !bannedFilter.filtered || bannedFilter.filtered.length === 0 ? ( + + ) : ( +
      + {bannedFilter.filtered.map((entry) => ( +
    • +
      + + {entry.reason && ( +

      + Reason: {entry.reason} +

      + )} +
      + {canAllow ? ( + + setConfirm({ + title: 'Unban this event?', + target: entry.id, + effect: 'The relay will serve this event again if it still has a copy.', + reversible: 'Reversible: you can ban the event again.', + actionLabel: 'Unban', + run: () => run('allowevent', entry.id, 'Event unbanned'), + }) + } + /> + ) : ( + read-only + )} +
    • + ))} +
    + )} +
    + )} + + { + const parsed = parseEventIdInput(value); + if (typeof parsed !== 'string') { + throw new Error(parsed.error); + } + try { + await mutation.mutateAsync({ + method: 'banevent', + params: reason.trim() ? [parsed, reason.trim()] : [parsed], + refresh: ['listeventsneedingmoderation', 'listbannedevents'], + }); + toast({ title: 'Event banned' }); + } catch (error) { + toast({ + title: 'The relay refused the operation', + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + throw error; + } + }} + /> + setConfirm(undefined)} /> +
    + ); +} diff --git a/src/apps/relay-admin/PolicySections.tsx b/src/apps/relay-admin/PolicySections.tsx new file mode 100644 index 0000000..1f71367 --- /dev/null +++ b/src/apps/relay-admin/PolicySections.tsx @@ -0,0 +1,713 @@ +import { useState, type ReactNode } from 'react'; +import { Loader2, Plus } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + useAllowedKinds, + useAllowedPubkeys, + useBannedPubkeys, + useBlockedIps, + useNip86Mutation, + type Nip86Session, +} from '@/hooks/useNip86'; +import { useToast } from '@/hooks/useToast'; +import { + parseKindInput, + parsePubkeyInput, + validateIpInput, + validateReason, + type AllowedPubkey, + type BannedPubkey, + type BlockedIp, + type Nip86CoreMethod, +} from '@/lib/nip86'; +import { + ConfirmAction, + Field, + FormDialog, + type PendingConfirm, +} from './dialogs'; +import { useFilter } from './useFilter'; +import { + IdText, + ListEmpty, + ListError, + ListSkeleton, + RowAction, + Section, + SectionToolbar, +} from './shared'; + +/** + * The policy sections: banned/allowed pubkeys, blocked IPs and allowed kinds. + * Each section renders only when the relay advertised the matching list + * method (the app shell gates them), and mutations only reference methods the + * relay advertised — an advertised `listbannedpubkeys` without `banpubkey` + * yields a read-only list, never a silently attempted call. + */ + +interface AddFormState { + value: string; + reason: string; + error?: string; +} + +function usePolicyMutation( + session: Nip86Session, + onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void, +) { + const { toast } = useToast(); + const mutation = useNip86Mutation(session, { onResult }); + + const run = async (input: { + method: Nip86CoreMethod; + params: unknown[]; + refresh: Parameters[0]['refresh']; + success: string; + }) => { + try { + await mutation.mutateAsync({ method: input.method, params: input.params, refresh: input.refresh }); + toast({ title: input.success }); + } catch (error) { + toast({ + title: 'The relay refused the operation', + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + // The audit entry is written by the mutation hook either way; rethrow so + // the caller can keep its dialog open on failure. + throw error; + } + }; + + return { run, isPending: mutation.isPending }; +} + +function ReasonInput({ + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: (value: string) => void; +}) { + const error = validateReason(value); + return ( + + onChange(event.target.value)} + aria-invalid={Boolean(value && error)} + aria-describedby={value && error ? `${id}-error` : undefined} + className="text-sm" + /> + + ); +} + +function AddDialog({ + open, + onOpenChange, + title, + description, + idPrefix, + valueLabel, + valueHint, + valuePlaceholder, + validate, + withReason, + pending, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + idPrefix: string; + valueLabel: string; + valueHint: string; + valuePlaceholder: string; + validate: (value: string) => string | undefined; + withReason?: boolean; + pending: boolean; + onSubmit: (value: string, reason: string) => Promise; +}) { + const [form, setForm] = useState({ value: '', reason: '' }); + + const close = (next: boolean) => { + if (!next) setForm({ value: '', reason: '' }); + onOpenChange(next); + }; + + const submit = async () => { + const error = validate(form.value); + if (error) { + setForm((current) => ({ ...current, error })); + return; + } + await onSubmit(form.value.trim(), form.reason); + close(false); + }; + + return ( + + + + + } + > + + setForm((current) => ({ ...current, value: event.target.value, error: undefined }))} + onKeyDown={(event) => event.key === 'Enter' && submit()} + placeholder={valuePlaceholder} + aria-invalid={Boolean(form.error)} + aria-describedby={form.error ? `${idPrefix}-value-error` : undefined} + className="font-mono text-xs" + autoFocus + /> + + {withReason !== false && ( + setForm((current) => ({ ...current, reason }))} + /> + )} + + ); +} + +function PolicyRow({ + id, + reason, + reasonLabel, + actions, +}: { + id: string; + reason?: string; + reasonLabel: string; + actions: ReactNode; +}) { + return ( +
  • +
    + + {reason ? ( +

    + {reasonLabel}: {reason} +

    + ) : null} +
    + {actions} +
  • + ); +} + +/* -------------------------------------------------------------------------- + * Banned pubkeys (public-relay blocklist side) + * ------------------------------------------------------------------------ */ + +export function BannedPubkeysSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void; +}) { + const list = useBannedPubkeys(session, { onResult }); + const { run, isPending } = usePolicyMutation(session, onResult); + const [addOpen, setAddOpen] = useState(false); + const [confirm, setConfirm] = useState(undefined); + const { search, setSearch, filtered } = useFilter(list.data, (entry) => [entry.pubkey, entry.reason]); + + const canBan = session.methods.includes('banpubkey'); + const canUnban = session.methods.includes('unbanpubkey'); + + return ( +
    setAddOpen(true)}> + + Ban + + ) : undefined + } + > + list.refetch()} + refreshing={list.isFetching} + /> + {list.isLoading ? ( + + ) : list.isError ? ( + list.refetch()} /> + ) : !filtered || filtered.length === 0 ? ( + + ) : ( +
      + {filtered.map((entry: BannedPubkey) => ( + + setConfirm({ + title: 'Unban this pubkey?', + target: entry.pubkey, + effect: + 'The relay will accept and serve this key’s events again, subject to its other rules.', + reversible: 'Reversible: you can ban the key again.', + actionLabel: 'Unban', + run: () => + run({ + method: 'unbanpubkey', + params: [entry.pubkey], + refresh: ['listbannedpubkeys'], + success: 'Pubkey unbanned', + }), + }) + } + /> + ) : ( + read-only + ) + } + /> + ))} +
    + )} + + { + const parsed = parsePubkeyInput(value); + return typeof parsed === 'string' ? undefined : parsed.error; + }} + onSubmit={async (value, reason) => { + const parsed = parsePubkeyInput(value); + if (typeof parsed !== 'string') return; + await run({ + method: 'banpubkey', + params: reason.trim() ? [parsed, reason.trim()] : [parsed], + refresh: ['listbannedpubkeys'], + success: 'Pubkey banned', + }); + }} + /> + setConfirm(undefined)} /> +
    + ); +} + +/* -------------------------------------------------------------------------- + * Allowed pubkeys (private-relay allowlist side) + * ------------------------------------------------------------------------ */ + +export function AllowedPubkeysSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void; +}) { + const list = useAllowedPubkeys(session, { onResult }); + const { run, isPending } = usePolicyMutation(session, onResult); + const [addOpen, setAddOpen] = useState(false); + const [confirm, setConfirm] = useState(undefined); + const { search, setSearch, filtered } = useFilter(list.data, (entry) => [entry.pubkey, entry.reason]); + + const canAllow = session.methods.includes('allowpubkey'); + const canUnallow = session.methods.includes('unallowpubkey'); + + return ( +
    setAddOpen(true)}> + + Allow + + ) : undefined + } + > + list.refetch()} + refreshing={list.isFetching} + /> + {list.isLoading ? ( + + ) : list.isError ? ( + list.refetch()} /> + ) : !filtered || filtered.length === 0 ? ( + + ) : ( +
      + {filtered.map((entry: AllowedPubkey) => ( + + setConfirm({ + title: 'Remove this pubkey from the allowlist?', + target: entry.pubkey, + effect: + 'If this relay enforces its allowlist, the key immediately loses access. Check the relay’s documentation for its exact enforcement semantics.', + reversible: 'Reversible: you can allow the key again.', + actionLabel: 'Remove access', + run: () => + run({ + method: 'unallowpubkey', + params: [entry.pubkey], + refresh: ['listallowedpubkeys'], + success: 'Pubkey removed from the allowlist', + }), + }) + } + /> + ) : ( + read-only + ) + } + /> + ))} +
    + )} + + { + const parsed = parsePubkeyInput(value); + return typeof parsed === 'string' ? undefined : parsed.error; + }} + onSubmit={async (value, reason) => { + const parsed = parsePubkeyInput(value); + if (typeof parsed !== 'string') return; + await run({ + method: 'allowpubkey', + params: reason.trim() ? [parsed, reason.trim()] : [parsed], + refresh: ['listallowedpubkeys'], + success: 'Pubkey allowed', + }); + }} + /> + setConfirm(undefined)} /> +
    + ); +} + +/* -------------------------------------------------------------------------- + * Blocked IPs + * ------------------------------------------------------------------------ */ + +export function BlockedIpsSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void; +}) { + const list = useBlockedIps(session, { onResult }); + const { run, isPending } = usePolicyMutation(session, onResult); + const [addOpen, setAddOpen] = useState(false); + const [confirm, setConfirm] = useState(undefined); + const { search, setSearch, filtered } = useFilter(list.data, (entry) => [entry.ip, entry.reason]); + + const canBlock = session.methods.includes('blockip'); + const canUnblock = session.methods.includes('unblockip'); + + return ( +
    setAddOpen(true)}> + + Block + + ) : undefined + } + > + list.refetch()} + refreshing={list.isFetching} + /> + {list.isLoading ? ( + + ) : list.isError ? ( + list.refetch()} /> + ) : !filtered || filtered.length === 0 ? ( + + ) : ( +
      + {filtered.map((entry: BlockedIp) => ( + + setConfirm({ + title: 'Unblock this address?', + target: entry.ip, + effect: 'The relay will accept connections from this address again.', + reversible: 'Reversible: you can block the address again.', + actionLabel: 'Unblock', + run: () => + run({ + method: 'unblockip', + params: [entry.ip], + refresh: ['listblockedips'], + success: 'Address unblocked', + }), + }) + } + /> + ) : ( + read-only + ) + } + /> + ))} +
    + )} + + validateIpInput(value)} + onSubmit={async (value, reason) => { + await run({ + method: 'blockip', + params: reason.trim() ? [value, reason.trim()] : [value], + refresh: ['listblockedips'], + success: 'Address blocked', + }); + }} + /> + setConfirm(undefined)} /> +
    + ); +} + +/* -------------------------------------------------------------------------- + * Allowed kinds + * ------------------------------------------------------------------------ */ + +export function AllowedKindsSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void; +}) { + const list = useAllowedKinds(session, { onResult }); + const { run, isPending } = usePolicyMutation(session, onResult); + const [addOpen, setAddOpen] = useState(false); + const [confirm, setConfirm] = useState(undefined); + const { search, setSearch, filtered } = useFilter(list.data?.map((kind) => ({ kind })), (entry) => [ + String(entry.kind), + ]); + + const canAllow = session.methods.includes('allowkind'); + const canDisallow = session.methods.includes('disallowkind'); + + return ( +
    setAddOpen(true)}> + + Allow kind + + ) : undefined + } + > + list.refetch()} + refreshing={list.isFetching} + /> + {list.isLoading ? ( + + ) : list.isError ? ( + list.refetch()} /> + ) : !filtered || filtered.length === 0 ? ( + + ) : ( +
      + {filtered.map(({ kind }) => ( + + setConfirm({ + title: `Disallow kind ${kind}?`, + target: `kind ${kind}`, + effect: + 'If this relay enforces its kind allowlist, it will stop accepting and serving events of this kind.', + reversible: 'Reversible: you can allow the kind again.', + actionLabel: 'Disallow kind', + run: () => + run({ + method: 'disallowkind', + params: [kind], + refresh: ['listallowedkinds'], + success: `Kind ${kind} disallowed`, + }), + }) + } + /> + ) : ( + read-only + ) + } + /> + ))} +
    + )} + + { + const parsed = parseKindInput(value); + return typeof parsed === 'number' ? undefined : parsed.error; + }} + onSubmit={async (value) => { + const parsed = parseKindInput(value); + if (typeof parsed !== 'number') return; + await run({ + method: 'allowkind', + params: [parsed], + refresh: ['listallowedkinds'], + success: `Kind ${parsed} allowed`, + }); + }} + /> + setConfirm(undefined)} /> +
    + ); +} diff --git a/src/apps/relay-admin/RelaySection.tsx b/src/apps/relay-admin/RelaySection.tsx new file mode 100644 index 0000000..1a40eae --- /dev/null +++ b/src/apps/relay-admin/RelaySection.tsx @@ -0,0 +1,245 @@ +import { useState } from 'react'; +import { Globe, Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + useNip86Mutation, + type AuditEntry, + type Nip86Session, +} from '@/hooks/useNip86'; +import { useToast } from '@/hooks/useToast'; +import { sanitizeUrl } from '@/lib/nostrUtils'; +import { sanitizeIconUrl as validateIconUrl } from '@/lib/nip86'; +import { Field, FormDialog } from './dialogs'; +import { Section } from './shared'; + +type AuditFn = (entry: Omit) => void; + +type PresentationMethod = 'changerelayname' | 'changerelaydescription' | 'changerelayicon'; + +const FIELDS: Record< + PresentationMethod, + { title: string; label: string; description: string; placeholder: string; multiline?: boolean } +> = { + changerelayname: { + title: 'Change relay name', + label: 'Relay name', + description: 'The name the relay publishes in its NIP-11 information document.', + placeholder: 'My relay', + }, + changerelaydescription: { + title: 'Change relay description', + label: 'Description', + description: 'The description the relay publishes in its NIP-11 information document.', + placeholder: 'What this relay is for, who may use it, …', + multiline: true, + }, + changerelayicon: { + title: 'Change relay icon', + label: 'Icon URL', + description: 'An https:// image URL the relay publishes as its icon.', + placeholder: 'https://example.com/icon.png', + }, +}; + +/** + * Relay presentation: name, description and icon as published in the relay's + * NIP-11 document. The current values come from the discovery-time NIP-11 + * fetch and are shown read-only; each change previews before it is sent. The + * list re-reads the document on demand rather than trusting the mutation. + */ +export function RelayPresentationSection({ + session, + onResult, +}: { + session: Nip86Session; + onResult: AuditFn; +}) { + const { toast } = useToast(); + const mutation = useNip86Mutation(session, { onResult }); + const [editing, setEditing] = useState(undefined); + const [value, setValue] = useState(''); + const [error, setError] = useState(undefined); + const [openFor, setOpenFor] = useState(undefined); + + const available = (Object.keys(FIELDS) as PresentationMethod[]).filter((method) => + session.methods.includes(method), + ); + + const current = (method: PresentationMethod): string | undefined => + method === 'changerelayname' + ? session.info?.name + : method === 'changerelaydescription' + ? session.info?.description + : session.info?.icon; + + // Render-phase reset when a different field is opened. + if (editing !== openFor) { + setOpenFor(editing); + setValue(editing ? (current(editing) ?? '') : ''); + setError(undefined); + } + + const iconPreview = editing === 'changerelayicon' ? sanitizeUrl(value.trim()) : undefined; + const iconValid = + editing === 'changerelayicon' + ? typeof validateIconUrl(value) === 'string' + ? undefined + : (validateIconUrl(value) as { error: string }).error + : undefined; + + const submit = async () => { + if (!editing) return; + const trimmed = value.trim(); + if (!trimmed) { + setError('Enter a value.'); + return; + } + if (editing === 'changerelayicon') { + const result = validateIconUrl(trimmed); + if (typeof result !== 'string') { + setError(result.error); + return; + } + } + try { + await mutation.mutateAsync({ method: editing, params: [trimmed] }); + toast({ title: `${FIELDS[editing].label} updated` }); + setEditing(undefined); + } catch (cause) { + toast({ + title: 'The relay refused the change', + description: cause instanceof Error ? cause.message : undefined, + variant: 'destructive', + }); + } + }; + + return ( +
    +
      + {available.map((method) => { + const field = FIELDS[method]; + const existing = current(method); + return ( +
    • +
      +

      {field.label}

      + {existing ? ( + method === 'changerelayicon' ? ( + + + + {existing} + + + ) : ( +

      + {existing} +

      + ) + ) : ( +

      not set

      + )} +
      + +
    • + ); + })} +
    + + !open && setEditing(undefined)} + title={editing ? FIELDS[editing].title : ''} + description={editing ? FIELDS[editing].description : undefined} + footer={ + <> + + + + } + > + + {editing === 'changerelaydescription' ? ( +