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 <noreply@anthropic.com>
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 <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Copilot
2026-09-07 22:35:53 +02:00
committed by GitHub
parent 77329b9a59
commit 3868e088b4
17 changed files with 4544 additions and 1 deletions

29
NIP.md Normal file
View File

@@ -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/`.

View File

@@ -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`

View File

@@ -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<AuditEntry['status'], string> = {
ok: 'OK',
failed: 'Failed',
cancelled: 'Cancelled',
};
const STATUS_TONE: Record<AuditEntry['status'], string> = {
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 (
<Section
title="Session audit log"
description="Every operation this window performed, newest last. Kept in memory only; cleared when the window closes."
actions={
audit.length > 0 ? (
<div className="flex gap-1.5">
<Button size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs" onClick={copy}>
{copied ? (
<Check className="size-3.5 text-success" aria-hidden />
) : (
<Copy className="size-3.5" aria-hidden />
)}
{copied ? 'Copied' : 'Copy'}
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={onClear}>
Clear
</Button>
</div>
) : undefined
}
>
{audit.length === 0 ? (
<ListEmpty title="No operations yet" hint="Operations you run against this relay will appear here." />
) : (
<ol className="divide-y divide-border border-t border-border" aria-live="polite">
{[...audit].reverse().map((entry) => (
<li key={entry.id} className="flex items-baseline gap-2 px-3 py-1.5 text-xs">
<span className="shrink-0 tabular-nums text-muted-foreground">
{new Date(entry.at).toLocaleTimeString()}
</span>
<span className={cn('shrink-0 font-medium', STATUS_TONE[entry.status])}>
{STATUS_LABEL[entry.status]}
</span>
<span className="min-w-0 flex-1">
<span className="font-mono">{entry.method}</span>{' '}
<span className="text-muted-foreground"> {entry.target}</span>
{entry.detail && (
<span className="block truncate text-muted-foreground" title={entry.detail}>
{entry.detail}
</span>
)}
</span>
</li>
))}
</ol>
)}
</Section>
);
}

View File

@@ -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<AuditEntry, 'id' | 'at'>) => 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<PendingTypedConfirm | undefined>(undefined);
return (
<Section
title="Relay-specific extensions"
description="This relay advertises operations beyond the NIP-86 standard. They are specific to this relay software, may be irreversible, and are never run without typed confirmation."
>
<ul className="divide-y divide-border border-t border-dashed border-border">
{session.extensions.map((method) => (
<li key={method} className="flex items-center gap-3 px-3 py-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-dashed border-border bg-muted/40">
<Puzzle className="size-4 text-muted-foreground" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs font-medium">{method}</p>
<p className="text-xs text-muted-foreground">
Not part of NIP-86 see this relays documentation for what it does and what it
destroys.
</p>
</div>
<button
type="button"
onClick={() =>
setPending({
title: `Run ${method}?`,
method,
phrase: method,
effect:
'This console sends the operation without parameters. The relay decides the exact behavior; broad or destructive effects (such as purging events) cannot be ruled out.',
run: async () => {
try {
await mutation.mutateAsync({ method, params: [] });
toast({ title: `${method} completed` });
} catch (error) {
toast({
title: `${method} failed`,
description: error instanceof Error ? error.message : undefined,
variant: 'destructive',
});
throw error;
}
},
})
}
className="shrink-0 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
Run
</button>
</li>
))}
</ul>
<TypedConfirmAction pending={pending} onClose={() => setPending(undefined)} />
</Section>
);
}

View File

@@ -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<AuditEntry, 'id' | 'at'>) => 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<NostrEvent | null>({
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<Decision, (id: string, canReverse: boolean) => Omit<PendingConfirm, 'run'>> = {
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 (
<li className="space-y-2 px-3 py-2">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<IdText value={entry.id} />
{entry.reason && (
<p className="truncate text-xs text-muted-foreground" title={entry.reason}>
Flagged: {entry.reason}
</p>
)}
</div>
{(canAllow || canBan) && (
<div className="flex shrink-0 gap-1.5">
{canAllow && (
<RowAction label="Allow" pending={pending} onClick={() => onDecide('allowevent', entry.id)} />
)}
{canBan && (
<RowAction
label="Ban"
destructive
pending={pending}
onClick={() => onDecide('banevent', entry.id)}
/>
)}
</div>
)}
</div>
{/* Review context: the event itself, straight from the managed relay. */}
{event.isLoading ? (
<p className="text-xs text-muted-foreground">Loading the event for review</p>
) : event.data ? (
<blockquote className="rounded-md border border-border bg-muted/40 px-2.5 py-1.5">
<p className="text-xs text-muted-foreground">
{displayName(event.data.pubkey, author.data?.metadata)} · kind {event.data.kind} ·{' '}
{relativeTime(event.data.created_at)}
</p>
<p className="mt-0.5 line-clamp-3 whitespace-pre-wrap break-words text-sm">
{event.data.content}
</p>
</blockquote>
) : (
<p className="text-xs text-muted-foreground">
The event itself is not available from this relay it may already be deleted. You can
still ban the ID to keep it out.
</p>
)}
</li>
);
}
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<PendingConfirm | undefined>(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 (
<Section
title="Event moderation"
description="Events the relay flagged for review, and events it already bans. Review the content from the managed relay before deciding."
actions={
canBan ? (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setBanOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Ban by ID
</Button>
) : undefined
}
>
{hasQueue && (
<>
<SectionToolbar
search={queueFilter.search}
onSearch={queueFilter.setSearch}
searchLabel="Filter the queue"
count={queueFilter.filtered?.length}
onRefresh={() => queue.refetch()}
refreshing={queue.isFetching}
/>
{queue.isLoading ? (
<ListSkeleton />
) : queue.isError ? (
<ListError error={queue.error} onRetry={() => queue.refetch()} />
) : !queueFilter.filtered || queueFilter.filtered.length === 0 ? (
<ListEmpty
title={queueFilter.search ? 'No matches' : 'Nothing needs moderation'}
hint={
queueFilter.search
? 'Nothing in the queue matches the filter.'
: 'The relays moderation queue is empty.'
}
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{queueFilter.filtered.map((entry) => (
<ModerationRow
key={entry.id}
session={session}
entry={entry}
canAllow={canAllow}
canBan={canBan}
pending={mutation.isPending}
onDecide={decide}
/>
))}
</ul>
)}
</>
)}
{hasBanned && (
<div className={hasQueue ? 'mt-3' : undefined}>
<p className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Banned events
</p>
<SectionToolbar
search={bannedFilter.search}
onSearch={bannedFilter.setSearch}
searchLabel="Filter banned events"
count={bannedFilter.filtered?.length}
onRefresh={() => banned.refetch()}
refreshing={banned.isFetching}
/>
{banned.isLoading ? (
<ListSkeleton rows={2} />
) : banned.isError ? (
<ListError error={banned.error} onRetry={() => banned.refetch()} />
) : !bannedFilter.filtered || bannedFilter.filtered.length === 0 ? (
<ListEmpty
title={bannedFilter.search ? 'No matches' : 'No banned events'}
hint={
bannedFilter.search
? 'Nothing on the ban list matches the filter.'
: 'The relay reports no banned events.'
}
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{bannedFilter.filtered.map((entry) => (
<li key={entry.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<IdText value={entry.id} />
{entry.reason && (
<p className="truncate text-xs text-muted-foreground" title={entry.reason}>
Reason: {entry.reason}
</p>
)}
</div>
{canAllow ? (
<RowAction
label="Unban"
pending={mutation.isPending}
onClick={() =>
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'),
})
}
/>
) : (
<Badge variant="outline" className="text-[10px]">read-only</Badge>
)}
</li>
))}
</ul>
)}
</div>
)}
<BanEventDialog
open={banOpen}
onOpenChange={setBanOpen}
pending={mutation.isPending}
onSubmit={async (value, reason) => {
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;
}
}}
/>
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
</Section>
);
}

View File

@@ -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<typeof mutation.mutateAsync>[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 (
<Field id={id} label="Reason (optional)" error={value ? error : undefined} hint="Stored by the relay and shown to other operators.">
<Input
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
aria-invalid={Boolean(value && error)}
aria-describedby={value && error ? `${id}-error` : undefined}
className="text-sm"
/>
</Field>
);
}
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<void>;
}) {
const [form, setForm] = useState<AddFormState>({ 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 (
<FormDialog open={open} onOpenChange={close} title={title} description={description}
footer={
<>
<Button variant="outline" onClick={() => close(false)} disabled={pending}>
Cancel
</Button>
<Button
onClick={submit}
disabled={pending || !form.value.trim() || Boolean(validateReason(form.reason))}
>
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{title}
</Button>
</>
}
>
<Field
id={`${idPrefix}-value`}
label={valueLabel}
error={form.error}
hint={form.error ? undefined : valueHint}
>
<Input
id={`${idPrefix}-value`}
value={form.value}
onChange={(event) => 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
/>
</Field>
{withReason !== false && (
<ReasonInput
id={`${idPrefix}-reason`}
value={form.reason}
onChange={(reason) => setForm((current) => ({ ...current, reason }))}
/>
)}
</FormDialog>
);
}
function PolicyRow({
id,
reason,
reasonLabel,
actions,
}: {
id: string;
reason?: string;
reasonLabel: string;
actions: ReactNode;
}) {
return (
<li className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<IdText value={id} />
{reason ? (
<p className="truncate text-xs text-muted-foreground" title={reason}>
{reasonLabel}: {reason}
</p>
) : null}
</div>
{actions}
</li>
);
}
/* --------------------------------------------------------------------------
* 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<PendingConfirm | undefined>(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 (
<Section
title="Banned pubkeys"
description="Keys this relay refuses to serve or accept events from. A blocklist is reactive — it is not an exhaustive statement of who may use the relay."
actions={
canBan ? (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Ban
</Button>
) : undefined
}
>
<SectionToolbar
search={search}
onSearch={setSearch}
searchLabel="Filter by pubkey or reason"
count={filtered?.length}
onRefresh={() => list.refetch()}
refreshing={list.isFetching}
/>
{list.isLoading ? (
<ListSkeleton />
) : list.isError ? (
<ListError error={list.error} onRetry={() => list.refetch()} />
) : !filtered || filtered.length === 0 ? (
<ListEmpty
title={search ? 'No matches' : 'No banned pubkeys'}
hint={search ? 'Nothing on this list matches the filter.' : 'The relay reports an empty ban list.'}
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{filtered.map((entry: BannedPubkey) => (
<PolicyRow
key={entry.pubkey}
id={entry.pubkey}
reason={entry.reason}
reasonLabel="Reason"
actions={
canUnban ? (
<RowAction
label="Unban"
pending={isPending}
onClick={() =>
setConfirm({
title: 'Unban this pubkey?',
target: entry.pubkey,
effect:
'The relay will accept and serve this keys 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',
}),
})
}
/>
) : (
<Badge variant="outline" className="text-[10px]">read-only</Badge>
)
}
/>
))}
</ul>
)}
<AddDialog
open={addOpen}
onOpenChange={setAddOpen}
title="Ban pubkey"
description="Banning a key stops this relay from accepting or serving its events. You can reverse it with Unban."
idPrefix="ban-pubkey"
valueLabel="Public key"
valueHint="64 hex characters, or an npub1… / nprofile1… identifier."
valuePlaceholder="npub1…"
pending={isPending}
validate={(value) => {
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',
});
}}
/>
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
</Section>
);
}
/* --------------------------------------------------------------------------
* 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<PendingConfirm | undefined>(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 (
<Section
title="Allowed pubkeys"
description="Keys on this relays allowlist. Whether an allowlist restricts everyone else is decided by the relays own enforcement — an allowlist alone does not make a relay private."
actions={
canAllow ? (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Allow
</Button>
) : undefined
}
>
<SectionToolbar
search={search}
onSearch={setSearch}
searchLabel="Filter by pubkey or reason"
count={filtered?.length}
onRefresh={() => list.refetch()}
refreshing={list.isFetching}
/>
{list.isLoading ? (
<ListSkeleton />
) : list.isError ? (
<ListError error={list.error} onRetry={() => list.refetch()} />
) : !filtered || filtered.length === 0 ? (
<ListEmpty
title={search ? 'No matches' : 'No allowed pubkeys'}
hint={
search
? 'Nothing on this list matches the filter.'
: 'The relay reports an empty allowlist. Depending on its configuration, that may mean no restriction at all.'
}
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{filtered.map((entry: AllowedPubkey) => (
<PolicyRow
key={entry.pubkey}
id={entry.pubkey}
reason={entry.reason}
reasonLabel="Note"
actions={
canUnallow ? (
<RowAction
label="Remove"
destructive
pending={isPending}
onClick={() =>
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 relays 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',
}),
})
}
/>
) : (
<Badge variant="outline" className="text-[10px]">read-only</Badge>
)
}
/>
))}
</ul>
)}
<AddDialog
open={addOpen}
onOpenChange={setAddOpen}
title="Allow pubkey"
description="Add a key to this relays allowlist. The relay decides what being allowed means."
idPrefix="allow-pubkey"
valueLabel="Public key"
valueHint="64 hex characters, or an npub1… / nprofile1… identifier."
valuePlaceholder="npub1…"
pending={isPending}
validate={(value) => {
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',
});
}}
/>
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
</Section>
);
}
/* --------------------------------------------------------------------------
* 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<PendingConfirm | undefined>(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 (
<Section
title="Blocked IPs"
description="Addresses or CIDR ranges the relay refuses connections from. Whether ranges are accepted depends on the relay — check its documentation."
actions={
canBlock ? (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Block
</Button>
) : undefined
}
>
<SectionToolbar
search={search}
onSearch={setSearch}
searchLabel="Filter by address or reason"
count={filtered?.length}
onRefresh={() => list.refetch()}
refreshing={list.isFetching}
/>
{list.isLoading ? (
<ListSkeleton />
) : list.isError ? (
<ListError error={list.error} onRetry={() => list.refetch()} />
) : !filtered || filtered.length === 0 ? (
<ListEmpty
title={search ? 'No matches' : 'No blocked IPs'}
hint={search ? 'Nothing on this list matches the filter.' : 'The relay reports no blocked addresses.'}
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{filtered.map((entry: BlockedIp) => (
<PolicyRow
key={entry.ip}
id={entry.ip}
reason={entry.reason}
reasonLabel="Reason"
actions={
canUnblock ? (
<RowAction
label="Unblock"
pending={isPending}
onClick={() =>
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',
}),
})
}
/>
) : (
<Badge variant="outline" className="text-[10px]">read-only</Badge>
)
}
/>
))}
</ul>
)}
<AddDialog
open={addOpen}
onOpenChange={setAddOpen}
title="Block IP"
description="Block one address or a whole range. Everyone behind the address — including innocent users sharing it — loses access."
idPrefix="block-ip"
valueLabel="IP address or CIDR range"
valueHint="e.g. 203.0.113.7 or 203.0.113.0/24. Check the relays documentation for what it accepts."
valuePlaceholder="203.0.113.7"
pending={isPending}
validate={(value) => validateIpInput(value)}
onSubmit={async (value, reason) => {
await run({
method: 'blockip',
params: reason.trim() ? [value, reason.trim()] : [value],
refresh: ['listblockedips'],
success: 'Address blocked',
});
}}
/>
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
</Section>
);
}
/* --------------------------------------------------------------------------
* 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<PendingConfirm | undefined>(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 (
<Section
title="Allowed kinds"
description="Event kinds on this relays allowlist. Whether kinds outside the list are rejected is the relays own decision — some relays treat this list as advisory."
actions={
canAllow ? (
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Allow kind
</Button>
) : undefined
}
>
<SectionToolbar
search={search}
onSearch={setSearch}
searchLabel="Filter by kind number"
count={filtered?.length}
onRefresh={() => list.refetch()}
refreshing={list.isFetching}
/>
{list.isLoading ? (
<ListSkeleton />
) : list.isError ? (
<ListError error={list.error} onRetry={() => list.refetch()} />
) : !filtered || filtered.length === 0 ? (
<ListEmpty
title={search ? 'No matches' : 'No allowed kinds'}
hint={
search
? 'Nothing on this list matches the filter.'
: 'The relay reports an empty kind list — depending on its configuration, that may mean all kinds are accepted.'
}
/>
) : (
<ul className="divide-y divide-border border-t border-border">
{filtered.map(({ kind }) => (
<PolicyRow
key={kind}
id={`kind ${kind}`}
reason={undefined}
reasonLabel=""
actions={
canDisallow ? (
<RowAction
label="Disallow"
destructive
pending={isPending}
onClick={() =>
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`,
}),
})
}
/>
) : (
<Badge variant="outline" className="text-[10px]">read-only</Badge>
)
}
/>
))}
</ul>
)}
<AddDialog
open={addOpen}
onOpenChange={setAddOpen}
title="Allow kind"
description="Add an event kind to this relays allowlist (065535)."
idPrefix="allow-kind"
valueLabel="Kind number"
valueHint="e.g. 1 for short text notes, 30023 for long-form articles."
valuePlaceholder="1"
withReason={false}
pending={isPending}
validate={(value) => {
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`,
});
}}
/>
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
</Section>
);
}

View File

@@ -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<AuditEntry, 'id' | 'at'>) => 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<PresentationMethod | undefined>(undefined);
const [value, setValue] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const [openFor, setOpenFor] = useState<PresentationMethod | undefined>(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 (
<Section
title="Relay presentation"
description="Name, description and icon as published in the relays NIP-11 information document. Only the fields this relay advertises can be changed."
>
<ul className="divide-y divide-border border-t border-border">
{available.map((method) => {
const field = FIELDS[method];
const existing = current(method);
return (
<li key={method} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{field.label}</p>
{existing ? (
method === 'changerelayicon' ? (
<span className="mt-1 flex items-center gap-2">
<IconPreview url={existing} />
<span className="truncate font-mono text-xs text-muted-foreground" title={existing}>
{existing}
</span>
</span>
) : (
<p className="truncate text-xs text-muted-foreground" title={existing}>
{existing}
</p>
)
) : (
<p className="text-xs italic text-muted-foreground">not set</p>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
onClick={() => setEditing(method)}
>
Change
</Button>
</li>
);
})}
</ul>
<FormDialog
open={Boolean(editing)}
onOpenChange={(open) => !open && setEditing(undefined)}
title={editing ? FIELDS[editing].title : ''}
description={editing ? FIELDS[editing].description : undefined}
footer={
<>
<Button variant="outline" onClick={() => setEditing(undefined)} disabled={mutation.isPending}>
Cancel
</Button>
<Button onClick={submit} disabled={mutation.isPending || !value.trim() || Boolean(iconValid)}>
{mutation.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Save
</Button>
</>
}
>
<Field
id="presentation-value"
label={editing ? FIELDS[editing].label : ''}
error={error ?? iconValid}
>
{editing === 'changerelaydescription' ? (
<Textarea
id="presentation-value"
value={value}
onChange={(event) => {
setValue(event.target.value);
setError(undefined);
}}
placeholder={FIELDS[editing].placeholder}
aria-invalid={Boolean(error)}
aria-describedby={error ? 'presentation-value-error' : undefined}
autoFocus
/>
) : (
<Input
id="presentation-value"
value={value}
onChange={(event) => {
setValue(event.target.value);
setError(undefined);
}}
onKeyDown={(event) => event.key === 'Enter' && submit()}
placeholder={editing ? FIELDS[editing].placeholder : undefined}
aria-invalid={Boolean(error ?? iconValid)}
aria-describedby={error ?? iconValid ? 'presentation-value-error' : undefined}
className={editing === 'changerelayicon' ? 'font-mono text-xs' : undefined}
autoFocus
/>
)}
</Field>
{editing === 'changerelayicon' && (
<div className="flex items-center gap-3 rounded-lg border border-dashed border-border p-3">
{iconPreview ? (
<IconPreview url={iconPreview} large />
) : (
<span className="flex size-10 items-center justify-center rounded-md bg-muted">
<Globe className="size-5 text-muted-foreground" aria-hidden />
</span>
)}
<p className="text-xs text-muted-foreground">
{iconPreview ? 'Preview of the new icon.' : 'Enter a valid https:// URL to preview the icon.'}
</p>
</div>
)}
</FormDialog>
</Section>
);
}
function IconPreview({ url, large }: { url: string; large?: boolean }) {
const safe = sanitizeUrl(url);
if (!safe) return null;
return (
<img
src={safe}
alt=""
className={large ? 'size-10 rounded-md object-cover' : 'size-6 rounded-md object-cover'}
loading="lazy"
/>
);
}

View File

@@ -0,0 +1,487 @@
import { useState } from 'react';
import { Loader2, Pencil, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
useNip86Mutation,
useRelayRoles,
type AuditEntry,
type Nip86Session,
} from '@/hooks/useNip86';
import { useToast } from '@/hooks/useToast';
import {
parsePubkeyInput,
parseRoles,
validateRoleColor,
validateRoleId,
type Nip86Role,
} from '@/lib/nip86';
import { ConfirmAction, Field, FormDialog, type PendingConfirm } from './dialogs';
import { ListEmpty, ListSkeleton, RowAction, Section } from './shared';
type AuditFn = (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
/**
* Roles. The mutation methods (createrole/editrole/deleterole/assignrole/
* unassignrole) are standard NIP-86, but no standard "list roles" method
* exists — roles are only listed when the relay advertises the common
* `listroles` extension, and that is labelled as relay-specific.
*/
interface RoleForm {
id: string;
label: string;
description: string;
color: string;
order: string;
}
const EMPTY_ROLE: RoleForm = { id: '', label: '', description: '', color: '', order: '0' };
function RoleDialog({
open,
onOpenChange,
editing,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When set, the dialog edits this role (id immutable) instead of creating. */
editing: Nip86Role | undefined;
pending: boolean;
onSubmit: (form: RoleForm) => Promise<void>;
}) {
const [form, setForm] = useState<RoleForm>(EMPTY_ROLE);
const [error, setError] = useState<string | undefined>(undefined);
const [openFor, setOpenFor] = useState<Nip86Role | 'new' | undefined>(undefined);
// Render-phase reset whenever the dialog targets a different role.
const target = open ? (editing ?? ('new' as const)) : undefined;
if (target !== openFor) {
setOpenFor(target);
setError(undefined);
setForm(
editing
? {
id: editing.id,
label: editing.label ?? '',
description: editing.description ?? '',
color: editing.color ?? '',
order: String(editing.order ?? 0),
}
: EMPTY_ROLE,
);
}
const idError = editing ? undefined : validateRoleId(form.id);
const colorError = validateRoleColor(form.color);
const orderValid = /^\d+$/.test(form.order.trim());
const submit = async () => {
if (!editing && idError) {
setError(idError);
return;
}
if (!orderValid) {
setError('Order must be a whole number.');
return;
}
if (colorError) {
setError(colorError);
return;
}
await onSubmit(form);
onOpenChange(false);
};
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title={editing ? `Edit role ${editing.id}` : 'Create role'}
description="Roles are relay-side groupings (e.g. moderators). What a role grants is defined by the relay."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button
onClick={submit}
disabled={
pending ||
!form.id.trim() ||
!form.label.trim() ||
Boolean(form.id && idError) ||
Boolean(colorError) ||
!orderValid
}
>
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{editing ? 'Save role' : 'Create role'}
</Button>
</>
}
>
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
)}
<Field id="role-id" label="Role ID" hint={editing ? 'The ID cannot be changed.' : 'Lowercase slug, e.g. moderator.'}>
<Input
id="role-id"
value={form.id}
onChange={(event) => setForm((current) => ({ ...current, id: event.target.value }))}
disabled={Boolean(editing)}
className="font-mono text-xs"
autoFocus={!editing}
/>
</Field>
<Field id="role-label" label="Label">
<Input
id="role-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder="Moderator"
autoFocus={Boolean(editing)}
/>
</Field>
<Field id="role-description" label="Description (optional)">
<Input
id="role-description"
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field id="role-color" label="Color (optional)" hint="#8b5cf6">
<Input
id="role-color"
value={form.color}
onChange={(event) => setForm((current) => ({ ...current, color: event.target.value }))}
aria-invalid={Boolean(colorError)}
className="font-mono text-xs"
/>
</Field>
<Field id="role-order" label="Order">
<Input
id="role-order"
value={form.order}
onChange={(event) => setForm((current) => ({ ...current, order: event.target.value }))}
aria-invalid={!orderValid}
inputMode="numeric"
className="font-mono text-xs"
/>
</Field>
</div>
</FormDialog>
);
}
function AssignDialog({
role,
onOpenChange,
pending,
onSubmit,
}: {
role: Nip86Role | undefined;
onOpenChange: (open: boolean) => void;
pending: boolean;
onSubmit: (pubkey: string) => Promise<void>;
}) {
const [value, setValue] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const close = (next: boolean) => {
if (!next) {
setValue('');
setError(undefined);
}
onOpenChange(next);
};
const submit = async () => {
const parsed = parsePubkeyInput(value);
if (typeof parsed !== 'string') {
setError(parsed.error);
return;
}
await onSubmit(parsed);
close(false);
};
return (
<FormDialog
open={Boolean(role)}
onOpenChange={close}
title={`Assign role ${role?.id ?? ''}`}
description="Give a pubkey this role. What members of the role may do is enforced by the relay."
footer={
<>
<Button variant="outline" onClick={() => close(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={submit} disabled={pending || !value.trim()}>
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Assign role
</Button>
</>
}
>
<Field
id="assign-pubkey"
label="Public key"
error={error}
hint={error ? undefined : '64 hex characters, or an npub1… / nprofile1… identifier.'}
>
<Input
id="assign-pubkey"
value={value}
onChange={(event) => {
setValue(event.target.value);
setError(undefined);
}}
onKeyDown={(event) => event.key === 'Enter' && submit()}
placeholder="npub1…"
aria-invalid={Boolean(error)}
aria-describedby={error ? 'assign-pubkey-error' : undefined}
className="font-mono text-xs"
autoFocus
/>
</Field>
</FormDialog>
);
}
export function RolesSection({
session,
onResult,
}: {
session: Nip86Session;
onResult: AuditFn;
}) {
const { toast } = useToast();
const mutation = useNip86Mutation(session, { onResult });
const rolesQuery = useRelayRoles(session, { onResult });
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Nip86Role | undefined>(undefined);
const [assigning, setAssigning] = useState<Nip86Role | undefined>(undefined);
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
const [unassignKey, setUnassignKey] = useState('');
const canCreate = session.methods.includes('createrole');
const canEdit = session.methods.includes('editrole');
const canDelete = session.methods.includes('deleterole');
const canAssign = session.methods.includes('assignrole');
const canUnassign = session.methods.includes('unassignrole');
const roles = session.canListRoles ? parseRoles(rolesQuery.data) : [];
const run = async (
method: 'createrole' | 'editrole' | 'deleterole' | 'assignrole' | 'unassignrole',
params: unknown[],
success: string,
) => {
try {
await mutation.mutateAsync({
method,
params,
refresh: session.canListRoles ? ['listroles'] : [],
});
toast({ title: success });
} catch (error) {
toast({
title: 'The relay refused the operation',
description: error instanceof Error ? error.message : undefined,
variant: 'destructive',
});
throw error;
}
};
return (
<Section
title="Roles"
description="Relay-side groupings such as moderators. Deleting or reassigning roles changes what people can do — treat both as sensitive."
actions={
canCreate ? (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
onClick={() => {
setEditing(undefined);
setDialogOpen(true);
}}
>
<Plus className="size-3.5" aria-hidden />
New role
</Button>
) : undefined
}
>
{session.canListRoles ? (
<>
<p className="px-3 pb-2 text-xs text-muted-foreground">
The role list below comes from <span className="font-mono">listroles</span>, a
relay-specific extension it is not standard NIP-86.
</p>
{rolesQuery.isLoading ? (
<ListSkeleton rows={2} />
) : rolesQuery.isError ? (
<ListEmpty title="Could not load roles" hint={rolesQuery.error.message} />
) : roles.length === 0 ? (
<ListEmpty title="No roles" hint="The relay reports no roles yet." />
) : (
<ul className="divide-y divide-border border-t border-border">
{roles.map((role) => (
<li key={role.id} className="flex items-center gap-3 px-3 py-2">
<span
className="size-3 shrink-0 rounded-full border border-border"
style={
// Role colors come from the relay; only valid hex values
// may reach a style attribute.
role.color && validateRoleColor(role.color) === undefined && role.color
? { backgroundColor: role.color }
: undefined
}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{role.label || role.id}{' '}
<span className="font-mono text-xs font-normal text-muted-foreground">
{role.id}
</span>
</p>
{role.description && (
<p className="truncate text-xs text-muted-foreground" title={role.description}>
{role.description}
</p>
)}
</div>
{canAssign && (
<RowAction label="Assign" pending={mutation.isPending} onClick={() => setAssigning(role)} />
)}
{canEdit && (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={`Edit role ${role.id}`}
onClick={() => {
setEditing(role);
setDialogOpen(true);
}}
>
<Pencil className="size-3.5" aria-hidden />
</Button>
)}
{canDelete && (
<RowAction
label="Delete"
destructive
pending={mutation.isPending}
onClick={() =>
setConfirm({
title: `Delete role ${role.id}?`,
target: `role ${role.id}`,
effect:
'The role stops existing. What happens to its current members depends on the relay.',
reversible: 'Not directly reversible: you would have to recreate the role and reassign its members.',
actionLabel: 'Delete role',
run: () => run('deleterole', [role.id], `Role ${role.id} deleted`),
})
}
/>
)}
</li>
))}
</ul>
)}
</>
) : (
<ListEmpty
title="Roles cannot be listed"
hint="This relay does not advertise a way to list roles (listroles is not standard NIP-86). If you know a role's ID — from the relay's documentation — you can still manage it below."
/>
)}
{canUnassign && (
<div className="px-3 pt-3">
<Label htmlFor="unassign-form" className="text-xs">
Remove a role from a pubkey
</Label>
<form
id="unassign-form"
className="mt-1.5 flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const roleId = (new FormData(event.currentTarget).get('role') ?? '').toString().trim();
const parsed = parsePubkeyInput(unassignKey);
if (typeof parsed !== 'string' || !roleId) return;
setConfirm({
title: `Remove role ${roleId}?`,
target: `role ${roleId} for pubkey ${parsed.slice(0, 16)}`,
effect: 'The key loses whatever this role grants on the relay.',
reversible: 'Reversible: you can assign the role again.',
actionLabel: 'Remove role',
run: () => run('unassignrole', [parsed, roleId], 'Role removed'),
});
}}
>
<Input
name="role"
placeholder="role id"
aria-label="Role ID to remove"
className="h-8 w-32 font-mono text-xs"
required
/>
<Input
value={unassignKey}
onChange={(event) => setUnassignKey(event.target.value)}
placeholder="npub1…"
aria-label="Public key to remove the role from"
className="h-8 min-w-40 flex-1 font-mono text-xs"
required
/>
<Button type="submit" variant="outline" size="sm" className="h-8 text-xs" disabled={mutation.isPending}>
Remove
</Button>
</form>
</div>
)}
<RoleDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editing={editing}
pending={mutation.isPending}
onSubmit={(form) =>
run(
editing ? 'editrole' : 'createrole',
[
form.id.trim(),
form.label.trim(),
form.description.trim(),
form.color.trim(),
Number(form.order.trim()),
],
editing ? `Role ${form.id} updated` : `Role ${form.id} created`,
)
}
/>
<AssignDialog
role={assigning}
onOpenChange={(open) => !open && setAssigning(undefined)}
pending={mutation.isPending}
onSubmit={(pubkey) => run('assignrole', [pubkey, assigning?.id ?? ''], `Role ${assigning?.id} assigned`)}
/>
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
</Section>
);
}

View File

@@ -0,0 +1,343 @@
import { useState, type ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
/**
* Safety dialogs. Two tiers:
*
* - `ConfirmAction` for destructive-but-reversible operations (bans, unbans,
* unallowing access). It states the target, the likely effect, and whether
* the relay offers a reverse operation.
* - `TypedConfirmAction` for relay-specific extensions, which are treated as
* potentially irreversible: the operator must type a phrase, and the dialog
* says plainly that no undo is known.
*
* Cancelling a dialog never sends anything; the copy avoids implying a
* cancelled request rolled back relay-side work.
*/
export interface PendingConfirm {
title: string;
target: string;
effect: string;
reversible?: string;
actionLabel: string;
run: () => Promise<void>;
}
export function ConfirmAction({
pending,
onClose,
}: {
pending: PendingConfirm | undefined;
onClose: () => void;
}) {
const [busy, setBusy] = useState(false);
const confirm = async () => {
if (!pending) return;
setBusy(true);
try {
await pending.run();
onClose();
} finally {
setBusy(false);
}
};
return (
<AlertDialog
open={Boolean(pending)}
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{pending?.title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-left">
<span className="block">
Target: <span className="break-all font-mono text-xs">{pending?.target}</span>
</span>
<span className="block">{pending?.effect}</span>
<span className="block">
{pending?.reversible ?? 'The relay offers no operation that reverses this.'}
</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={confirm} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{pending?.actionLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export interface PendingTypedConfirm {
title: string;
method: string;
/** The exact phrase the operator must type, e.g. the method name. */
phrase: string;
effect: string;
run: () => Promise<void>;
}
export function TypedConfirmAction({
pending,
onClose,
}: {
pending: PendingTypedConfirm | undefined;
onClose: () => void;
}) {
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const [confirmedFor, setConfirmedFor] = useState<PendingTypedConfirm | undefined>(undefined);
// Reset the typed phrase whenever a *different* operation is opened. This is
// a render-phase reset (not an effect) so it passes the hooks lint rules.
if (pending !== confirmedFor) {
setConfirmedFor(pending);
setTyped('');
setBusy(false);
}
const matches = pending ? typed.trim() === pending.phrase : false;
const confirm = async () => {
if (!pending || !matches) return;
setBusy(true);
try {
await pending.run();
onClose();
} finally {
setBusy(false);
}
};
return (
<AlertDialog
open={Boolean(pending)}
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<TriangleAlert className="size-5 text-destructive" aria-hidden />
{pending?.title}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-left">
<span className="block">
<span className="font-mono text-xs">{pending?.method}</span> is not part of the
NIP-86 standard. It is specific to this relay implementation, and its exact effect
is defined by the relay not by this console.
</span>
<span className="block">{pending?.effect}</span>
<span className="block font-medium text-destructive">
There is no standard way to reverse this operation.
</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5">
<Label htmlFor="typed-confirm" className="text-xs">
Type <span className="font-mono font-semibold">{pending?.phrase}</span> to confirm
</Label>
<Input
id="typed-confirm"
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={pending?.phrase}
autoComplete="off"
className="font-mono text-xs"
aria-invalid={typed.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={confirm} disabled={!matches || busy}>
{busy && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Run {pending?.method}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/**
* Banning an event by ID is destructive, so the flow is a dedicated dialog
* with its own validation, not a generic form.
*/
export function BanEventDialog({
open,
onOpenChange,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
pending: boolean;
onSubmit: (value: string, reason: string) => Promise<void>;
}) {
const [id, setId] = useState('');
const [reason, setReason] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const close = (next: boolean) => {
if (!next) {
setId('');
setReason('');
setError(undefined);
}
onOpenChange(next);
};
const submit = async () => {
// Local validation happens here in the dialog; the caller re-parses.
const trimmed = id.trim();
if (!trimmed) {
setError('Enter an event ID.');
return;
}
await onSubmit(trimmed, reason);
close(false);
};
return (
<FormDialog
open={open}
onOpenChange={close}
title="Ban event by ID"
description="The relay will stop serving this event and may delete its stored copy. The event can still exist on other relays."
footer={
<>
<Button variant="outline" onClick={() => close(false)} disabled={pending}>
Cancel
</Button>
<Button variant="destructive" onClick={submit} disabled={pending || !id.trim()}>
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
Ban event
</Button>
</>
}
>
<Field id="ban-event-id" label="Event ID" error={error} hint={error ? undefined : '64 hex characters, or a note1… / nevent1… identifier.'}>
<Input
id="ban-event-id"
value={id}
onChange={(event) => {
setId(event.target.value);
setError(undefined);
}}
onKeyDown={(event) => event.key === 'Enter' && submit()}
placeholder="note1…"
aria-invalid={Boolean(error)}
aria-describedby={error ? 'ban-event-id-error' : undefined}
className="font-mono text-xs"
autoFocus
/>
</Field>
<Field id="ban-event-reason" label="Reason (optional)" hint="Stored by the relay and shown to other operators.">
<Input
id="ban-event-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
className="text-sm"
/>
</Field>
</FormDialog>
);
}
/** Generic form dialog wrapper used by every "add / edit" flow. */
export function FormDialog({
open,
onOpenChange,
title,
description,
children,
footer,
wide,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
footer: ReactNode;
wide?: boolean;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('max-h-[90dvh] overflow-y-auto', wide && 'sm:max-w-xl')}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-3">{children}</div>
<DialogFooter>{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Validated text field: label, error wiring and hint in one place. */
export function Field({
id,
label,
error,
hint,
children,
}: {
id: string;
label: string;
error?: string;
hint?: string;
children: ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs">
{label}
</Label>
{children}
{error ? (
<p id={`${id}-error`} className="text-xs text-destructive" role="alert">
{error}
</p>
) : hint ? (
<p className="text-xs text-muted-foreground">{hint}</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,367 @@
import { useEffect } from 'react';
import {
CircleCheck,
KeyRound,
Loader2,
Plug,
ShieldCheck,
TriangleAlert,
Unplug,
} from 'lucide-react';
import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome';
import { LoginArea } from '@/components/auth/LoginArea';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
useNip86Connection,
usePolicyMode,
type Nip86Session,
type PolicyMode,
} from '@/hooks/useNip86';
import { Nip86Error, relayWsUrl } from '@/lib/nip86';
import { sanitizeUrl } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
import type { AppProps } from '@/os/types';
import { AuditSection } from './AuditSection';
import { EventModerationSection } from './ModerationSection';
import {
AllowedKindsSection,
AllowedPubkeysSection,
BannedPubkeysSection,
BlockedIpsSection,
} from './PolicySections';
import { RelayPresentationSection } from './RelaySection';
import { RolesSection } from './RolesSection';
import { ExtensionsSection } from './ExtensionsSection';
/**
* Relay Admin — a NIP-86 management console.
*
* NIP-86 is a draft, optional HTTP(S) API, so the entire console is driven by
* discovery: connect, authorize with NIP-98, call `supportedmethods`, and only
* render what the relay actually advertised. Nothing is attempted optimistically.
*/
export default function RelayAdminApp({ params, setTitle, setParams }: AppProps) {
const { user } = useCurrentUser();
const connection = useNip86Connection();
const { session, isConnecting, error } = connection;
// The connected relay is part of the window's deep link, so a reload (or
// the desktop ↔ mobile shell switch) restores the view. The audit log and
// discovery intentionally are not restored: reconnecting re-runs them.
useEffect(() => {
if (session && params.relay !== session.url) {
setParams({ relay: session.url });
}
}, [session, params.relay, setParams]);
useEffect(() => {
const name = session?.info?.name?.trim();
setTitle(session ? `Relay Admin — ${name || session.url.replace(/^https?:\/\//, '')}` : 'Relay Admin');
}, [session, setTitle]);
return (
<AppLayout>
<AppToolbar>
<ShieldCheck className="size-4 shrink-0 text-primary" aria-hidden />
<span className="min-w-0 truncate text-[13px] font-medium">
{session ? session.url.replace(/^https?:\/\//, '') : 'Relay Admin'}
</span>
{session && (
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 gap-1.5 px-2 text-xs"
onClick={connection.disconnect}
>
<Unplug className="size-3.5" aria-hidden />
Disconnect
</Button>
)}
</AppToolbar>
<AppBody>
{!session ? (
<ConnectView
initialUrl={params.relay}
connecting={isConnecting}
error={error}
signedIn={Boolean(user)}
onConnect={connection.connect}
/>
) : (
<Console session={session} connection={connection} />
)}
</AppBody>
</AppLayout>
);
}
/* --------------------------------------------------------------------------
* Connect view: URL normalization, auth state, discovery result.
* ------------------------------------------------------------------------ */
function ConnectView({
initialUrl,
connecting,
error,
signedIn,
onConnect,
}: {
initialUrl?: string;
connecting: boolean;
error: Nip86Error | undefined;
signedIn: boolean;
onConnect: (input: string) => Promise<void>;
}) {
return (
<div className="mx-auto flex h-full w-full max-w-md flex-col justify-center gap-4 p-6">
<div className="space-y-1 text-center">
<h2 className="text-lg font-semibold tracking-tight">Manage a relay</h2>
<p className="text-sm text-muted-foreground">
Connect to a relay that speaks NIP-86 the draft, optional relay management API with
the key that administers it.
</p>
</div>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
const value = (new FormData(event.currentTarget).get('relay') ?? '').toString();
void onConnect(value);
}}
>
<div className="space-y-1.5">
<Label htmlFor="relay-url">Relay URL</Label>
<Input
id="relay-url"
name="relay"
defaultValue={initialUrl}
placeholder="wss://relay.example.com"
className="font-mono text-xs"
autoFocus
required
/>
</div>
<div
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm',
signedIn ? 'border-border' : 'border-dashed border-border text-muted-foreground',
)}
role="status"
>
<KeyRound className="size-4 shrink-0" aria-hidden />
{signedIn ? (
<span>
Signed in requests will be authorized with your key via NIP-98. Your key never
leaves your signer.
</span>
) : (
<span className="flex-1">Sign in with the relay operator key to authorize requests.</span>
)}
{!signedIn && <LoginArea />}
</div>
{error && (
<div
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm"
role="alert"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
<div className="min-w-0">
<p className="font-medium text-destructive">{errorTitle(error)}</p>
<p className="text-muted-foreground">{error.message}</p>
</div>
</div>
)}
<Button type="submit" className="w-full gap-1.5" disabled={connecting || !signedIn}>
{connecting ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Plug className="size-4" aria-hidden />
)}
{connecting ? 'Discovering capabilities…' : 'Connect'}
</Button>
<p className="text-center text-xs text-muted-foreground">
Discovery asks the relay which management methods it supports and renders only those.
</p>
</form>
</div>
);
}
function errorTitle(error: Nip86Error): string {
switch (error.code) {
case 'invalid-url':
return 'Check the relay URL';
case 'unreachable':
return 'Relay unreachable';
case 'unauthorized':
return 'Not authorized';
case 'forbidden':
return 'Operation forbidden';
case 'malformed':
return 'No NIP-86 support';
case 'not-logged-in':
return 'Sign-in required';
default:
return 'Discovery failed';
}
}
/* --------------------------------------------------------------------------
* The console: identity, policy mode, capability summary, sections.
* ------------------------------------------------------------------------ */
const POLICY_MODE: Record<PolicyMode, { label: string; tone: string; explanation: string }> = {
blocklist: {
label: 'Blocklist-led',
tone: 'border-warning/50 bg-warning/10 text-warning-foreground',
explanation:
'This relay advertises ban lists (pubkeys, IPs, events): a reactive, public-relay operating model. A blocklist is never exhaustive — it is not a permission system.',
},
allowlist: {
label: 'Allowlist-led',
tone: 'border-primary/50 bg-primary/10 text-primary',
explanation:
'This relay advertises allowlists (pubkeys, kinds): a restrictive, private-relay operating model. Only the relays own enforcement decides what being on the list means — an allowlist alone does not make a relay private.',
},
unknown: {
label: 'Policy mode unknown',
tone: 'border-border bg-muted text-muted-foreground',
explanation:
'The advertised methods do not clearly favor a blocklist or allowlist model. Check the relays documentation for how it enforces policy.',
},
};
function Console({
session,
connection,
}: {
session: Nip86Session;
connection: ReturnType<typeof useNip86Connection>;
}) {
const mode = usePolicyMode(session.methods);
const modeInfo = POLICY_MODE[mode];
const icon = session.info?.icon ? sanitizeUrl(session.info.icon) : undefined;
const has = (method: string) => session.methods.includes(method);
const hasAny = (...methods: string[]) => methods.some((method) => has(method));
const rolesAdvertised = hasAny('createrole', 'editrole', 'deleterole', 'assignrole', 'unassignrole');
const presentationAdvertised = hasAny('changerelayname', 'changerelaydescription', 'changerelayicon');
// Standard methods that produce no console section (supportedmethods itself
// and `stat`, which predates NIP-86 and carries no policy data).
const SECTION_METHODS = new Set([
'listbannedpubkeys', 'banpubkey', 'unbanpubkey',
'listallowedpubkeys', 'allowpubkey', 'unallowpubkey',
'listblockedips', 'blockip', 'unblockip',
'listallowedkinds', 'allowkind', 'disallowkind',
'listeventsneedingmoderation', 'allowevent', 'banevent', 'listbannedevents',
'createrole', 'editrole', 'deleterole', 'assignrole', 'unassignrole',
'changerelayname', 'changerelaydescription', 'changerelayicon',
]);
const uncovered = session.methods.filter(
(method) => !SECTION_METHODS.has(method) && method !== 'supportedmethods' && method !== 'stat',
);
const anySection =
has('listbannedpubkeys') ||
has('listallowedpubkeys') ||
has('listblockedips') ||
has('listallowedkinds') ||
hasAny('listeventsneedingmoderation', 'listbannedevents') ||
rolesAdvertised ||
presentationAdvertised;
return (
<div className="mx-auto w-full max-w-3xl space-y-4 p-3 sm:p-4">
{/* Identity + capability summary */}
<section className="rounded-lg border border-border p-3">
<div className="flex items-start gap-3">
{icon ? (
<img src={icon} alt="" className="size-10 shrink-0 rounded-md object-cover" />
) : (
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
<ShieldCheck className="size-5 text-muted-foreground" aria-hidden />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{session.info?.name?.trim() || relayWsUrl(session.url).replace(/^wss?:\/\//, '')}
</p>
{session.info?.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{session.info.description}
</p>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{session.info?.software && (
<Badge variant="outline" className="font-mono text-[10px]">
{session.info.software.replace(/^https?:\/\//, '').split('/').pop()}
{session.info.version ? ` ${session.info.version}` : ''}
</Badge>
)}
<Badge variant="outline" className="gap-1 text-[10px]">
<CircleCheck className="size-3 text-success" aria-hidden />
NIP-98 authorized
</Badge>
<span className="tabular-nums">
{session.methods.length} standard · {session.extensions.length} extension
{session.extensions.length === 1 ? '' : 's'}
</span>
</div>
</div>
</div>
<Separator className="my-3" />
<div className={cn('rounded-md border px-2.5 py-2', modeInfo.tone)}>
<p className="text-xs font-semibold">{modeInfo.label}</p>
<p className="text-xs opacity-90">{modeInfo.explanation}</p>
</div>
{uncovered.length > 0 && (
<p className="mt-2 text-xs text-muted-foreground">
Advertised but without a console UI:{' '}
<span className="font-mono">{uncovered.join(', ')}</span> shown for completeness, not
called.
</p>
)}
</section>
{!anySection && session.extensions.length === 0 && (
<EmptyState
title="Nothing to manage"
hint="This relay speaks NIP-86 but advertises no manageable methods to your key — or only discovery itself. If you expected more, check that you are signed in with an operator key."
/>
)}
{/* Capability-driven sections — each renders only what was advertised. */}
{has('listbannedpubkeys') && <BannedPubkeysSection session={session} onResult={connection.record} />}
{has('listallowedpubkeys') && <AllowedPubkeysSection session={session} onResult={connection.record} />}
{has('listblockedips') && <BlockedIpsSection session={session} onResult={connection.record} />}
{has('listallowedkinds') && <AllowedKindsSection session={session} onResult={connection.record} />}
{hasAny('listeventsneedingmoderation', 'listbannedevents') && (
<EventModerationSection session={session} onResult={connection.record} />
)}
{rolesAdvertised && <RolesSection session={session} onResult={connection.record} />}
{presentationAdvertised && <RelayPresentationSection session={session} onResult={connection.record} />}
{session.extensions.length > 0 && (
<ExtensionsSection session={session} onResult={connection.record} />
)}
<AuditSection audit={connection.audit} onClear={connection.clearAudit} />
</div>
);
}

View File

@@ -0,0 +1,178 @@
import type { ReactNode } from 'react';
import { Loader2, RefreshCw, Search } from 'lucide-react';
import { AppSectionTitle, EmptyState } from '@/components/os/AppChrome';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Nip86Error } from '@/lib/nip86';
import type { Nip86Session } from '@/hooks/useNip86';
/**
* Shared building blocks for the management console. Every capability section
* is the same shape — a toolbar row (search, count, refresh, primary action),
* then loading skeletons, an error with retry, an honest empty state, or the
* list — so the shape lives here once.
*/
export interface ConsoleSectionProps {
session: Nip86Session;
}
export function Section({
title,
description,
actions,
children,
}: {
title: string;
description: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<section className="border-b border-border pb-4 last:border-b-0">
<div className="flex items-start justify-between gap-2 pr-3">
<AppSectionTitle>{title}</AppSectionTitle>
{actions}
</div>
<p className="px-3 pb-2 text-xs text-muted-foreground">{description}</p>
{children}
</section>
);
}
export function SectionToolbar({
search,
onSearch,
searchLabel,
count,
onRefresh,
refreshing,
children,
}: {
search?: string;
onSearch?: (value: string) => void;
searchLabel?: string;
count?: number;
onRefresh?: () => void;
refreshing?: boolean;
children?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-2 px-3 pb-2">
{onSearch && (
<div className="relative min-w-40 flex-1">
<Search
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={search ?? ''}
onChange={(event) => onSearch(event.target.value)}
placeholder={searchLabel ?? 'Filter'}
aria-label={searchLabel ?? 'Filter'}
className="h-8 pl-7 text-xs"
/>
</div>
)}
{count !== undefined && (
<span className="text-xs tabular-nums text-muted-foreground">
{count} {count === 1 ? 'entry' : 'entries'}
</span>
)}
{onRefresh && (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2 text-xs"
onClick={onRefresh}
disabled={refreshing}
aria-label="Refresh list"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} aria-hidden />
Refresh
</Button>
)}
{children}
</div>
);
}
export function ListSkeleton({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-2 px-3 py-1" aria-label="Loading">
{Array.from({ length: rows }).map((_, index) => (
<div key={index} className="flex items-center gap-3">
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-7 w-16" />
</div>
))}
</div>
);
}
export function ListError({ error, onRetry }: { error: Nip86Error; onRetry: () => void }) {
return (
<div className="px-3 pb-2">
<EmptyState
title="The request failed"
hint={error.message}
action={
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
}
/>
</div>
);
}
export function ListEmpty({ title, hint }: { title: string; hint?: string }) {
return (
<div className="px-3 pb-2">
<p className="rounded-lg border border-dashed border-border px-3 py-4 text-center text-sm text-muted-foreground">
<span className="block font-medium text-foreground">{title}</span>
{hint}
</p>
</div>
);
}
/** Row action button: consistent sizing, pending spinner, destructive option. */
export function RowAction({
label,
onClick,
pending,
destructive,
disabled,
}: {
label: string;
onClick: () => void;
pending?: boolean;
destructive?: boolean;
disabled?: boolean;
}) {
return (
<Button
variant={destructive ? 'destructive' : 'outline'}
size="sm"
className="h-7 shrink-0 px-2 text-xs"
onClick={onClick}
disabled={pending || disabled}
>
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{label}
</Button>
);
}
/** Mono identifier cell: full value in the tooltip, truncated on screen. */
export function IdText({ value, className }: { value: string; className?: string }) {
return (
<span title={value} className={cn('block min-w-0 truncate font-mono text-xs', className)}>
{value}
</span>
);
}

View File

@@ -0,0 +1,15 @@
import { useMemo, useState } from 'react';
/** Case-insensitive contains-filter over the visible fields of a row. */
export function useFilter<T>(items: T[] | undefined, fields: (item: T) => (string | undefined)[]) {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!items) return undefined;
const needle = search.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) =>
fields(item).some((field) => field?.toLowerCase().includes(needle)),
);
}, [items, search, fields]);
return { search, setSearch, filtered };
}

214
src/hooks/useNip86.test.tsx Normal file
View File

@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { TestApp } from '@/test/TestApp';
import { useLoginActions } from './useLoginActions';
import { useBannedPubkeys, useNip86Connection, useNip86Mutation, usePolicyMode } from './useNip86';
/**
* Integration tests for the NIP-86 session hook: discovery against a mocked
* relay endpoint, capability-gated lists, mutations, and the audit log. The
* network is stubbed at `fetch`; signing is real (nsec login + NSecSigner).
*/
const PUBKEY_A = 'a'.repeat(64);
function rpcResponse(result: unknown): Response {
return new Response(JSON.stringify({ result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
/** A minimal in-memory NIP-86 relay: only supports a pubkey ban list. */
function installMockRelay() {
const state = { banned: [{ pubkey: PUBKEY_A, reason: 'spam' }] };
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const method = typeof input === 'object' && 'method' in input ? input.method : (init?.method ?? 'GET');
if (method === 'GET' || url.endsWith('/') && !init?.body) {
return new Response(
JSON.stringify({ name: 'Test Relay', description: 'mock', software: 'mock', version: '1' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
const auth = new Headers(init?.headers).get('Authorization') ?? '';
if (!auth.startsWith('Nostr ')) {
return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 });
}
const { method: rpc, params = [] } = JSON.parse(String(init?.body));
switch (rpc) {
case 'supportedmethods':
return rpcResponse(['supportedmethods', 'banpubkey', 'unbanpubkey', 'listbannedpubkeys']);
case 'listbannedpubkeys':
return rpcResponse(state.banned);
case 'banpubkey':
state.banned = [...state.banned, { pubkey: String(params[0]), reason: params[1] }];
return rpcResponse(true);
case 'unbanpubkey':
state.banned = state.banned.filter((entry) => entry.pubkey !== params[0]);
return rpcResponse(true);
default:
return new Response(JSON.stringify({ error: `unknown method: ${rpc}` }), { status: 200 });
}
});
vi.stubGlobal('fetch', fetchMock);
return { fetchMock, state };
}
async function renderLoggedIn() {
const nsec = nip19.nsecEncode(generateSecretKey());
const rendered = renderHook(
() => ({
actions: useLoginActions(),
connection: useNip86Connection(),
}),
{ wrapper: TestApp },
);
// NostrLoginProvider renders null while it reads logins from storage.
await waitFor(() => expect(rendered.result.current).not.toBeNull());
act(() => rendered.result.current.actions.nsec(nsec));
// useCurrentUser derives the signer in an effect-driven chain; wait until
// the connection hook reports authorization is possible.
await waitFor(() => expect(rendered.result.current.connection.canAuthorize).toBe(true));
return rendered;
}
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('useNip86Connection', () => {
it('requires sign-in before connecting', async () => {
const fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useNip86Connection(), { wrapper: TestApp });
// NostrLoginProvider renders null while it reads logins from storage.
await waitFor(() => expect(result.current).not.toBeNull());
await act(async () => {
await result.current.connect('wss://relay.example.com');
});
expect(result.current.session).toBeUndefined();
expect(result.current.error?.code).toBe('not-logged-in');
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects malformed relay URLs without touching the network', async () => {
const fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal('fetch', fetchMock);
const { result } = await renderLoggedIn();
await act(async () => {
await result.current.connection.connect('ftp://nope');
});
expect(result.current.connection.error?.code).toBe('invalid-url');
expect(fetchMock).not.toHaveBeenCalled();
});
it('discovers capabilities, then serves capability-gated lists and mutations', async () => {
installMockRelay();
const { result } = await renderLoggedIn();
await act(async () => {
await result.current.connection.connect('mock.relay.test');
});
const session = result.current.connection.session;
expect(session).toBeTruthy();
expect(session?.url).toBe('https://mock.relay.test/');
expect(session?.info?.name).toBe('Test Relay');
expect(session?.methods).toContain('listbannedpubkeys');
expect(result.current.connection.error).toBeUndefined();
// The discovery itself is audited.
expect(result.current.connection.audit.some((entry) => entry.method === 'supportedmethods' && entry.status === 'ok')).toBe(true);
// List hook reads through the same connection.
const list = renderHook(
() => ({
list: useBannedPubkeys(result.current.connection.session, { onResult: result.current.connection.record }),
mutation: useNip86Mutation(result.current.connection.session, { onResult: result.current.connection.record }),
}),
{ wrapper: TestApp },
);
await waitFor(() => expect(list.result.current.list.isSuccess).toBe(true));
expect(list.result.current.list.data).toHaveLength(1);
// A mutation refreshes the list and writes an audit entry.
await act(async () => {
await list.result.current.mutation.mutateAsync({
method: 'banpubkey',
params: ['f'.repeat(64), 'test ban'],
refresh: ['listbannedpubkeys'],
});
});
await waitFor(() => expect(list.result.current.list.data).toHaveLength(2));
expect(
result.current.connection.audit.some((entry) => entry.method === 'banpubkey' && entry.status === 'ok'),
).toBe(true);
// The audit log must never contain an authorization header or key material.
for (const entry of result.current.connection.audit) {
expect(JSON.stringify(entry)).not.toContain('Nostr ');
}
// Unadvertised methods are refused locally, without a request.
const callsBefore = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
await act(async () => {
await expect(
list.result.current.mutation.mutateAsync({ method: 'blockip', params: ['203.0.113.1'] }),
).rejects.toMatchObject({ code: 'unsupported' });
});
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsBefore);
});
it('surfaces a 401 as an authorization failure with a safe message', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ error: 'nope' }), { status: 401 })),
);
const { result } = await renderLoggedIn();
await act(async () => {
await result.current.connection.connect('wss://relay.example.com');
});
expect(result.current.connection.session).toBeUndefined();
expect(result.current.connection.error?.code).toBe('unauthorized');
expect(result.current.connection.error?.message).not.toContain('Nostr ');
expect(result.current.connection.audit.some((entry) => entry.status === 'failed')).toBe(true);
});
});
describe('usePolicyMode', () => {
it('derives the mode from advertised lists, never from the endpoint name', () => {
const { result, rerender } = renderHook(({ methods }) => usePolicyMode(methods), {
initialProps: { methods: ['listbannedpubkeys', 'listblockedips'] },
});
expect(result.current).toBe('blocklist');
rerender({ methods: ['listallowedpubkeys', 'listallowedkinds'] });
expect(result.current).toBe('allowlist');
rerender({ methods: ['listbannedpubkeys', 'listallowedpubkeys'] });
expect(result.current).toBe('unknown');
rerender({ methods: ['supportedmethods'] });
expect(result.current).toBe('unknown');
});
});

418
src/hooks/useNip86.ts Normal file
View File

@@ -0,0 +1,418 @@
import { useCallback, useMemo, useState } from 'react';
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from '@tanstack/react-query';
import type { NostrSigner } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import {
fetchRelayInfo,
isBlockedIpList,
isEventRefList,
isKindList,
isPubkeyList,
isStringArray,
Nip86Error,
nip86Call,
nip86Params,
normalizeRelayHttpUrl,
partitionMethods,
type AllowedPubkey,
type BannedEvent,
type BannedPubkey,
type BlockedIp,
type ModeratedEventRef,
type Nip86CoreMethod,
type RelayInfo,
} from '@/lib/nip86';
/**
* Session state for the NIP-86 management console.
*
* A "session" is one connected relay: the normalized HTTP endpoint, the
* NIP-11 identity document (when the relay publishes one), and the method
* list the relay advertised for the *signed-in key*. Nothing here is
* persisted — and no key material ever enters this state. Reconnecting to
* the same relay re-runs discovery, so a rotated method list is picked up.
*/
export interface Nip86Session {
/** Normalized `https://` management endpoint. */
url: string;
/** NIP-11 relay information document, when available. */
info?: RelayInfo;
/** Standard NIP-86 methods the relay advertised (including `stat`). */
methods: string[];
/** Advertised names that are not standard NIP-86 — relay-specific extensions. */
extensions: string[];
/** Whether `listroles` (a common non-standard companion to the role methods) is advertised. */
canListRoles: boolean;
}
/** Which side of the blocklist/allowlist model the advertised data suggests. */
export type PolicyMode = 'blocklist' | 'allowlist' | 'unknown';
export function usePolicyMode(methods: string[]): PolicyMode {
return useMemo(() => {
const set = new Set(methods);
const blocklist =
set.has('listbannedpubkeys') || set.has('listblockedips') || set.has('listbannedevents');
const allowlist = set.has('listallowedpubkeys') || set.has('listallowedkinds');
if (allowlist && !blocklist) return 'allowlist';
if (blocklist && !allowlist) return 'blocklist';
// Both or neither: the method list alone cannot tell — the operator's
// relay documentation has to answer this.
return 'unknown';
}, [methods]);
}
/** One line in the session audit log. Never contains secrets or auth headers. */
export interface AuditEntry {
id: number;
/** Unix milliseconds. */
at: number;
method: string;
/** Operator-facing target description (pubkey, IP, role id, ...), not raw params. */
target: string;
status: 'ok' | 'failed' | 'cancelled';
detail?: string;
}
/** Human-readable target for the audit log and confirm dialogs. */
export function auditTarget(method: string, params: unknown[]): string {
const [first] = params;
switch (method) {
case 'banpubkey':
case 'unbanpubkey':
case 'allowpubkey':
case 'unallowpubkey':
return `pubkey ${String(first).slice(0, 16)}`;
case 'banevent':
case 'allowevent':
return `event ${String(first).slice(0, 16)}`;
case 'blockip':
case 'unblockip':
return `IP ${String(first)}`;
case 'allowkind':
case 'disallowkind':
return `kind ${String(first)}`;
case 'createrole':
case 'editrole':
case 'deleterole':
return `role ${String(first)}`;
case 'assignrole':
case 'unassignrole':
return `role ${String(params[1])} for pubkey ${String(first).slice(0, 16)}`;
case 'changerelayname':
return 'the relay name';
case 'changerelaydescription':
return 'the relay description';
case 'changerelayicon':
return 'the relay icon';
default:
return 'the relay';
}
}
export interface Nip86Connection {
session: Nip86Session | undefined;
connect: (input: string) => Promise<void>;
disconnect: () => void;
isConnecting: boolean;
error: Nip86Error | undefined;
/** True when a signer is available; discovery itself decides authorization. */
canAuthorize: boolean;
audit: AuditEntry[];
record: (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
clearAudit: () => void;
}
/**
* Connect/disconnect state plus the audit log. Kept deliberately separate from
* the query/mutation hooks below so the console shell and every section share
* one connection object (created once in the app root).
*/
export function useNip86Connection(): Nip86Connection {
const { user } = useCurrentUser();
const [session, setSession] = useState<Nip86Session | undefined>(undefined);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<Nip86Error | undefined>(undefined);
const [audit, setAudit] = useState<AuditEntry[]>([]);
const queryClient = useQueryClient();
const record = useCallback((entry: Omit<AuditEntry, 'id' | 'at'>) => {
setAudit((prev) => [...prev.slice(-99), { ...entry, id: (prev[prev.length - 1]?.id ?? 0) + 1, at: Date.now() }]);
}, []);
const clearAudit = useCallback(() => setAudit([]), []);
const disconnect = useCallback(() => {
setSession(undefined);
setError(undefined);
}, []);
const connect = useCallback(
async (input: string) => {
const url = normalizeRelayHttpUrl(input);
if (!url) {
setError(new Nip86Error('invalid-url', 'That is not a valid relay URL.'));
return;
}
const signer: NostrSigner | undefined = user?.signer;
if (!signer) {
setError(
new Nip86Error(
'not-logged-in',
'Sign in with the key that manages this relay, then connect again.',
),
);
return;
}
setIsConnecting(true);
setError(undefined);
try {
// Identity is nice-to-have and deliberately raced with discovery:
// plenty of relays answer NIP-86 but publish no NIP-11 document.
const [info, methodsResult] = await Promise.all([
fetchRelayInfo(url),
nip86Call<unknown>(url, { signer, method: 'supportedmethods' }),
]);
if (!isStringArray(methodsResult)) {
throw new Nip86Error(
'malformed',
'The relay answered supportedmethods with an unexpected shape.',
);
}
const { core, extensions } = partitionMethods(methodsResult);
// A different relay must never show the previous relay's policy lists
// — but query keys are already scoped by URL, so only this session's
// own prior relay (not every open Relay Admin window) needs clearing.
if (session && session.url !== url) {
queryClient.removeQueries({ queryKey: ['nip86', session.url] });
}
setSession({
url,
info,
methods: core,
extensions,
canListRoles: methodsResult.includes('listroles'),
});
record({
method: 'supportedmethods',
target: 'discovery',
status: 'ok',
detail: `${methodsResult.length} method${methodsResult.length === 1 ? '' : 's'} advertised`,
});
} catch (cause) {
setSession(undefined);
setError(
cause instanceof Nip86Error
? cause
: new Nip86Error('unreachable', 'The connection failed unexpectedly.'),
);
record({
method: 'supportedmethods',
target: 'discovery',
status: cause instanceof DOMException && cause.name === 'AbortError' ? 'cancelled' : 'failed',
detail: cause instanceof Nip86Error ? cause.message : 'Connection failed.',
});
} finally {
setIsConnecting(false);
}
},
[user, queryClient, record, session],
);
return {
session,
connect,
disconnect,
isConnecting,
error,
canAuthorize: Boolean(user),
audit,
record,
clearAudit,
};
}
/* --------------------------------------------------------------------------
* List queries and mutations, keyed by relay URL so two windows managing
* different relays never share cache entries.
* ------------------------------------------------------------------------ */
const STALE_TIME = 15_000;
function useSessionGuard(session: Nip86Session | undefined) {
const { user } = useCurrentUser();
const signer = user?.signer;
return { session, signer, ready: Boolean(session && signer) };
}
type ListMethod =
| 'listbannedpubkeys'
| 'listallowedpubkeys'
| 'listeventsneedingmoderation'
| 'listbannedevents'
| 'listallowedkinds'
| 'listblockedips'
| 'listroles';
const LIST_GUARDS = {
listbannedpubkeys: isPubkeyList,
listallowedpubkeys: isPubkeyList,
listeventsneedingmoderation: isEventRefList,
listbannedevents: isEventRefList,
listallowedkinds: isKindList,
listblockedips: isBlockedIpList,
} as const;
interface Nip86ListOptions {
/** Audit + cache only — mutations refresh their own list automatically. */
onResult?: (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
}
function useNip86List<T>(
session: Nip86Session | undefined,
method: ListMethod,
options?: Nip86ListOptions,
): UseQueryResult<T, Nip86Error> {
const { signer, ready } = useSessionGuard(session);
// `listroles` is a relay-specific extension, not standard NIP-86.
const advertised =
method === 'listroles' ? session?.canListRoles === true : session?.methods.includes(method) === true;
return useQuery<T, Nip86Error>({
queryKey: ['nip86', session?.url, method],
enabled: ready && advertised,
staleTime: STALE_TIME,
queryFn: async ({ signal }) => {
if (!session || !signer) throw new Nip86Error('not-logged-in', 'Sign in to manage this relay.');
let result: unknown;
try {
result = await nip86Call<unknown>(session.url, { signer, method, signal });
} catch (error) {
options?.onResult?.({
method,
target: 'list',
status: error instanceof DOMException && error.name === 'AbortError' ? 'cancelled' : 'failed',
detail: error instanceof Nip86Error ? error.message : 'Request failed.',
});
throw error instanceof Nip86Error
? error
: new Nip86Error('unreachable', 'The request failed unexpectedly.');
}
// `listroles` shapes vary by relay; parsing happens in the UI layer.
if (method !== 'listroles') {
const guard = LIST_GUARDS[method];
if (!guard(result)) {
const failure = {
method,
target: 'list',
status: 'failed' as const,
detail: 'The relay returned an unexpected list shape.',
};
options?.onResult?.(failure);
throw new Nip86Error('malformed', failure.detail);
}
}
options?.onResult?.({ method, target: 'list', status: 'ok' });
return result as T;
},
});
}
export function useBannedPubkeys(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<BannedPubkey[]>(session, 'listbannedpubkeys', opts);
}
export function useAllowedPubkeys(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<AllowedPubkey[]>(session, 'listallowedpubkeys', opts);
}
export function useEventsNeedingModeration(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<ModeratedEventRef[]>(session, 'listeventsneedingmoderation', opts);
}
export function useBannedEvents(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<BannedEvent[]>(session, 'listbannedevents', opts);
}
export function useAllowedKinds(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<number[]>(session, 'listallowedkinds', opts);
}
export function useBlockedIps(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<BlockedIp[]>(session, 'listblockedips', opts);
}
/** `listroles` is NOT part of NIP-86 — only called when explicitly advertised. */
export function useRelayRoles(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
return useNip86List<unknown>(session, 'listroles', opts);
}
export interface Nip86MutationInput {
method: Nip86CoreMethod | (string & {});
params: unknown[];
/** Lists to refresh after the mutation succeeds. */
refresh?: ListMethod[];
}
/**
* One mutation hook for every management call. The audit entry and the list
* refresh happen here, so a section can never forget either one.
*/
export function useNip86Mutation(
session: Nip86Session | undefined,
options?: Nip86ListOptions,
): UseMutationResult<unknown, Nip86Error, Nip86MutationInput> {
const { signer } = useSessionGuard(session);
const queryClient = useQueryClient();
return useMutation<unknown, Nip86Error, Nip86MutationInput>({
mutationFn: async ({ method, params }) => {
if (!session || !signer) throw new Nip86Error('not-logged-in', 'Sign in to manage this relay.');
const supported =
session.methods.includes(method) || session.extensions.includes(method);
if (!supported) {
throw new Nip86Error('unsupported', `${method} is not advertised by this relay.`);
}
try {
return await nip86Call<unknown>(session.url, { signer, method, params });
} catch (error) {
throw error instanceof Nip86Error
? error
: new Nip86Error('unreachable', 'The request failed unexpectedly.');
}
},
onSuccess: (_data, { method, params, refresh }) => {
options?.onResult?.({ method, target: auditTarget(method, params), status: 'ok' });
if (session && refresh) {
for (const list of refresh) {
queryClient.invalidateQueries({ queryKey: ['nip86', session.url, list] });
}
}
},
onError: (error, { method, params }) => {
options?.onResult?.({
method,
target: auditTarget(method, params),
status: 'failed',
detail: error.message,
});
},
});
}
/** Convenience: build params for a core method with the shared builder. */
export { nip86Params };

327
src/lib/nip86.test.ts Normal file
View File

@@ -0,0 +1,327 @@
import { describe, expect, it, vi } from 'vitest';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { NSecSigner } from '@nostrify/nostrify';
import type { NostrEvent } from '@nostrify/nostrify';
import {
classifyMethod,
isBlockedIpList,
isEventRefList,
isKindList,
isPubkeyList,
Nip86Error,
nip86Call,
nip86Params,
normalizeRelayHttpUrl,
parseEventIdInput,
parseKindInput,
parsePubkeyInput,
parseRoles,
partitionMethods,
relayWsUrl,
sanitizeIconUrl,
validateIpInput,
validateReason,
validateRoleColor,
validateRoleId,
} from './nip86';
const HEX_A = 'a'.repeat(64);
const HEX_B = 'b'.repeat(64);
describe('normalizeRelayHttpUrl', () => {
it('normalizes bare hosts and common schemes to https', () => {
expect(normalizeRelayHttpUrl('relay.example.com')).toBe('https://relay.example.com/');
expect(normalizeRelayHttpUrl('wss://relay.example.com')).toBe('https://relay.example.com/');
expect(normalizeRelayHttpUrl('https://relay.example.com/')).toBe('https://relay.example.com/');
expect(normalizeRelayHttpUrl(' relay.example.com ')).toBe('https://relay.example.com/');
});
it('keeps ports and paths', () => {
expect(normalizeRelayHttpUrl('wss://relay.example.com:8443/relay')).toBe(
'https://relay.example.com:8443/relay',
);
});
it('maps ws:// to http:// for local relays', () => {
expect(normalizeRelayHttpUrl('ws://localhost:7777')).toBe('http://localhost:7777/');
expect(normalizeRelayHttpUrl('http://localhost:7777')).toBe('http://localhost:7777/');
});
it('rejects garbage and non-web schemes', () => {
expect(normalizeRelayHttpUrl('')).toBeUndefined();
expect(normalizeRelayHttpUrl(' ')).toBeUndefined();
expect(normalizeRelayHttpUrl('ftp://relay.example.com')).toBeUndefined();
expect(normalizeRelayHttpUrl('javascript:alert(1)')).toBeUndefined();
});
it('derives the websocket URL for NIP-98 u tags', () => {
expect(relayWsUrl('https://relay.example.com/')).toBe('wss://relay.example.com/');
expect(relayWsUrl('http://localhost:7777/')).toBe('ws://localhost:7777/');
});
});
describe('capability model', () => {
it('classifies core, passthrough and extension methods', () => {
expect(classifyMethod('supportedmethods')).toBe('core');
expect(classifyMethod('banpubkey')).toBe('core');
expect(classifyMethod('changerelayicon')).toBe('core');
expect(classifyMethod('stat')).toBe('passthrough');
expect(classifyMethod('purgeallevents')).toBe('extension');
expect(classifyMethod('listroles')).toBe('extension');
});
it('partitions advertised methods, keeping extensions separate', () => {
const { core, extensions } = partitionMethods([
'supportedmethods',
'banpubkey',
'stat',
'purgeallevents',
]);
expect(core).toContain('banpubkey');
expect(core).toContain('stat');
expect(extensions).toEqual(['purgeallevents']);
});
});
describe('response validators', () => {
it('accepts well-shaped lists', () => {
expect(isPubkeyList([{ pubkey: HEX_A, reason: 'spam' }, { pubkey: HEX_B }])).toBe(true);
expect(isEventRefList([{ id: HEX_A }])).toBe(true);
expect(isBlockedIpList([{ ip: '203.0.113.7', reason: 'scanner' }])).toBe(true);
expect(isKindList([0, 1, 30023])).toBe(true);
});
it('rejects malformed lists', () => {
expect(isPubkeyList([{ pubkey: 3 }])).toBe(false);
expect(isPubkeyList([{ pubkey: HEX_A, reason: 42 }])).toBe(false);
expect(isEventRefList([{ id: HEX_A }, 'oops'])).toBe(false);
expect(isBlockedIpList([{ ip: null }])).toBe(false);
expect(isKindList([1, -1])).toBe(false);
expect(isKindList([1, 70000])).toBe(false);
expect(isKindList(['1'])).toBe(false);
expect(isPubkeyList('not-an-array')).toBe(false);
});
it('parses roles from both string and object shapes', () => {
expect(parseRoles(['admin', { id: 'mod', label: 'Moderator', order: 2 }])).toEqual([
{ id: 'admin' },
{ id: 'mod', label: 'Moderator', description: undefined, color: undefined, order: 2 },
]);
expect(parseRoles([{ name: 'legacy' }])).toEqual([
{ id: 'legacy', label: undefined, description: undefined, color: undefined, order: undefined },
]);
expect(parseRoles('nope')).toEqual([]);
expect(parseRoles([{}, { id: '' }, 42])).toEqual([]);
});
});
describe('input validation', () => {
it('accepts hex pubkeys and decodes npub/nprofile', () => {
expect(parsePubkeyInput(HEX_A.toUpperCase())).toBe(HEX_A);
const secret = generateSecretKey();
const pubkey = getPublicKey(secret);
expect(parsePubkeyInput(nip19.npubEncode(pubkey))).toBe(pubkey);
expect(parsePubkeyInput(nip19.nprofileEncode({ pubkey }))).toBe(pubkey);
});
it('rejects bad pubkeys', () => {
expect(parsePubkeyInput('')).toHaveProperty('error');
expect(parsePubkeyInput('1234')).toHaveProperty('error');
expect(parsePubkeyInput('npub1broken')).toHaveProperty('error');
});
it('accepts hex event ids and decodes note/nevent', () => {
expect(parseEventIdInput(HEX_B)).toBe(HEX_B);
expect(parseEventIdInput(nip19.noteEncode(HEX_B))).toBe(HEX_B);
expect(parseEventIdInput(nip19.neventEncode({ id: HEX_B }))).toBe(HEX_B);
expect(parseEventIdInput('zzzz')).toHaveProperty('error');
});
it('validates IPs and CIDR ranges', () => {
expect(validateIpInput('203.0.113.7')).toBeUndefined();
expect(validateIpInput('203.0.113.0/24')).toBeUndefined();
expect(validateIpInput('2001:db8::1')).toBeUndefined();
expect(validateIpInput('2001:db8::/32')).toBeUndefined();
expect(validateIpInput('')).toBeTruthy();
expect(validateIpInput('999.1.2.3')).toBeTruthy();
expect(validateIpInput('10.0.0.1/33')).toBeTruthy();
expect(validateIpInput('2001:db8::1/129')).toBeTruthy();
expect(validateIpInput('not an ip')).toBeTruthy();
});
it('validates kind numbers', () => {
expect(parseKindInput('1')).toBe(1);
expect(parseKindInput('30023')).toBe(30023);
expect(parseKindInput('0')).toBe(0);
expect(parseKindInput('')).toHaveProperty('error');
expect(parseKindInput('-1')).toHaveProperty('error');
expect(parseKindInput('65536')).toHaveProperty('error');
expect(parseKindInput('abc')).toHaveProperty('error');
});
it('validates role ids and colors', () => {
expect(validateRoleId('moderator')).toBeUndefined();
expect(validateRoleId('')).toBeTruthy();
expect(validateRoleId('two words')).toBeTruthy();
expect(validateRoleId('x'.repeat(65))).toBeTruthy();
expect(validateRoleColor('')).toBeUndefined();
expect(validateRoleColor('#8b5cf6')).toBeUndefined();
expect(validateRoleColor('#fff')).toBeUndefined();
expect(validateRoleColor('red')).toBeTruthy();
});
it('sanitizes icon URLs', () => {
expect(sanitizeIconUrl('https://example.com/icon.png')).toBe('https://example.com/icon.png');
expect(sanitizeIconUrl('javascript:alert(1)')).toHaveProperty('error');
expect(sanitizeIconUrl('data:image/png;base64,xx')).toHaveProperty('error');
expect(sanitizeIconUrl('')).toHaveProperty('error');
expect(sanitizeIconUrl('not a url')).toHaveProperty('error');
});
it('only allows http:// for local relays', () => {
expect(sanitizeIconUrl('http://localhost:4869/icon.png')).toBe('http://localhost:4869/icon.png');
expect(sanitizeIconUrl('http://127.0.0.1/icon.png')).toBe('http://127.0.0.1/icon.png');
expect(sanitizeIconUrl('http://relay.example.com/icon.png')).toHaveProperty('error');
});
it('caps reason length', () => {
expect(validateReason('spam')).toBeUndefined();
expect(validateReason('x'.repeat(501))).toBeTruthy();
});
});
describe('nip86Params', () => {
it('builds list params as empty arrays', () => {
expect(nip86Params('supportedmethods')).toEqual([]);
expect(nip86Params('listbannedpubkeys')).toEqual([]);
expect(nip86Params('listallowedkinds')).toEqual([]);
});
it('appends reasons only when present', () => {
expect(nip86Params('banpubkey', { pubkey: HEX_A, reason: ' spam ' })).toEqual([HEX_A, 'spam']);
expect(nip86Params('banpubkey', { pubkey: HEX_A, reason: ' ' })).toEqual([HEX_A]);
expect(nip86Params('unbanpubkey', { pubkey: HEX_A })).toEqual([HEX_A]);
});
it('never adds a reason to unblockip (NIP-86 defines none)', () => {
expect(nip86Params('unblockip', { ip: '203.0.113.7', reason: 'ignored' })).toEqual(['203.0.113.7']);
});
it('builds kind, role and presentation params', () => {
expect(nip86Params('allowkind', { kind: 4 })).toEqual([4]);
expect(nip86Params('createrole', { roleId: 'mod', role: { label: 'Mod', description: '', color: '#fff', order: 1 } })).toEqual([
'mod',
'Mod',
'',
'#fff',
1,
]);
expect(nip86Params('deleterole', { roleId: 'mod' })).toEqual(['mod']);
expect(nip86Params('assignrole', { pubkey: HEX_A, roleId: 'mod' })).toEqual([HEX_A, 'mod']);
expect(nip86Params('changerelayname', { text: 'My Relay' })).toEqual(['My Relay']);
});
});
describe('nip86Call', () => {
const url = 'https://relay.example.com/';
const signer = new NSecSigner(generateSecretKey());
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
it('POSTs JSON-RPC with a NIP-98 authorization header carrying u and payload tags', async () => {
const fetchFn = vi.fn<typeof fetch>(async () => jsonResponse({ result: ['supportedmethods'] }));
const result = await nip86Call<string[]>(url, { signer, method: 'supportedmethods', fetchFn });
expect(result).toEqual(['supportedmethods']);
expect(fetchFn).toHaveBeenCalledOnce();
const [requestUrl, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit];
expect(requestUrl).toBe(url);
expect(init.method).toBe('POST');
const headers = new Headers(init.headers);
expect(headers.get('Content-Type')).toBe('application/nostr+json+rpc');
const auth = headers.get('Authorization') ?? '';
expect(auth.startsWith('Nostr ')).toBe(true);
const { N64 } = await import('@nostrify/nostrify/utils');
const event: NostrEvent = N64.decodeEvent(auth.slice('Nostr '.length));
expect(event.kind).toBe(27235);
expect(event.tags.find(([name]) => name === 'u')?.[1]).toBe('wss://relay.example.com/');
expect(event.tags.find(([name]) => name === 'method')?.[1]).toBe('POST');
const body = init.body as string;
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
const expectedPayload = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
expect(event.tags.find(([name]) => name === 'payload')?.[1]).toBe(expectedPayload);
expect(JSON.parse(body)).toEqual({ method: 'supportedmethods', params: [] });
});
it('maps 401 and 403 to dedicated error codes', async () => {
const unauthorized = vi.fn<typeof fetch>(async () => jsonResponse({}, 401));
await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: unauthorized })).rejects.toMatchObject({
code: 'unauthorized',
status: 401,
});
const forbidden = vi.fn<typeof fetch>(async () => jsonResponse({}, 403));
await expect(nip86Call(url, { signer, method: 'banpubkey', params: [HEX_A], fetchFn: forbidden })).rejects.toMatchObject({
code: 'forbidden',
status: 403,
});
});
it('treats non-JSON success responses as malformed (relay likely has no management API)', async () => {
const html = vi.fn<typeof fetch>(
async () => new Response('<html>relay</html>', { status: 200, headers: { 'Content-Type': 'text/html' } }),
);
await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: html })).rejects.toMatchObject({
code: 'malformed',
});
});
it('surfaces relay error envelopes as rpc-error', async () => {
const failing = vi.fn<typeof fetch>(async () => jsonResponse({ error: 'not a manager' }));
await expect(nip86Call(url, { signer, method: 'banpubkey', params: [HEX_A], fetchFn: failing })).rejects.toMatchObject({
code: 'rpc-error',
message: expect.stringContaining('not a manager'),
});
});
it('maps network failures to unreachable', async () => {
const down = vi.fn<typeof fetch>(async () => {
throw new TypeError('fetch failed');
});
await expect(nip86Call(url, { signer, method: 'supportedmethods', fetchFn: down })).rejects.toMatchObject({
code: 'unreachable',
});
});
it('never puts secrets in error messages', async () => {
const refusing = {
getPublicKey: async () => HEX_A,
signEvent: async () => {
throw new Error('user rejected');
},
};
try {
await nip86Call(url, { signer: refusing, method: 'supportedmethods' });
expect.unreachable();
} catch (error) {
expect(error).toBeInstanceOf(Nip86Error);
expect((error as Nip86Error).code).toBe('signing-failed');
expect((error as Nip86Error).message).not.toContain('Nostr ');
}
});
});

646
src/lib/nip86.ts Normal file
View File

@@ -0,0 +1,646 @@
import { N64 } from '@nostrify/nostrify/utils';
import type { NostrSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
/**
* NIP-86 "Relay Management API" client plumbing.
*
* NIP-86 is a *draft, optional* JSON-RPC-like protocol over plain HTTP(S),
* served on the same URI as the relay's WebSocket endpoint — it is not a
* WebSocket command protocol. Every request is a POST with the content type
* `application/nostr+json+rpc`, authorized with a NIP-98 event (kind 27235)
* whose `u` tag is the relay URL and which — unlike base NIP-98 — MUST carry
* a `payload` tag binding it to the request body.
*
* Implementations vary widely because the NIP is still a draft, so nothing
* here assumes more than `supportedmethods`: every other method is only ever
* called after the relay advertised it.
*/
/** Every method name defined by the current NIP-86 draft. */
export const NIP86_CORE_METHODS = [
'supportedmethods',
'banpubkey',
'unbanpubkey',
'listbannedpubkeys',
'allowpubkey',
'unallowpubkey',
'listallowedpubkeys',
'listeventsneedingmoderation',
'allowevent',
'banevent',
'listbannedevents',
'listallowedkinds',
'allowkind',
'disallowkind',
'blockip',
'unblockip',
'listblockedips',
'createrole',
'editrole',
'deleterole',
'assignrole',
'unassignrole',
'changerelayname',
'changerelaydescription',
'changerelayicon',
] as const;
export type Nip86CoreMethod = (typeof NIP86_CORE_METHODS)[number];
const CORE_METHOD_SET = new Set<string>(NIP86_CORE_METHODS);
/**
* `stat` predates NIP-86 in several relay implementations and returns relay
* metadata rather than policy data, so it is *not* treated as a relay-specific
* management extension — it is a recognized non-management method and gets no
* console UI.
*/
const PASSTHROUGH_METHODS = new Set(['stat']);
/** What an advertised method name means to this client. */
export type Nip86MethodClass = 'core' | 'passthrough' | 'extension';
export function classifyMethod(name: string): Nip86MethodClass {
if (CORE_METHOD_SET.has(name)) return 'core';
if (PASSTHROUGH_METHODS.has(name)) return 'passthrough';
return 'extension';
}
/**
* Split an advertised method list into standard NIP-86 methods and
* relay-specific extensions. Unknown names are extensions and must be shown
* separately — never blended into the standard surface.
*/
export function partitionMethods(methods: string[]): { core: string[]; extensions: string[] } {
const core: string[] = [];
const extensions: string[] = [];
for (const name of methods) {
if (classifyMethod(name) === 'extension') extensions.push(name);
else core.push(name);
}
return { core, extensions };
}
/* --------------------------------------------------------------------------
* Request and error plumbing
* ------------------------------------------------------------------------ */
export const NIP86_CONTENT_TYPE = 'application/nostr+json+rpc';
/** How long a single management request may take before it is aborted. */
export const NIP86_TIMEOUT_MS = 10_000;
export type Nip86ErrorCode =
| 'invalid-url'
| 'unreachable'
| 'http-error'
| 'unauthorized'
| 'forbidden'
| 'malformed'
| 'rpc-error'
| 'signing-failed'
| 'not-logged-in'
| 'unsupported';
/**
* An operator-facing error. `message` is safe to show in the UI and to copy
* into the audit log: it never contains the request body, the authorization
* header, or any key material.
*/
export class Nip86Error extends Error {
readonly code: Nip86ErrorCode;
/** HTTP status when the failure came from an HTTP response. */
readonly status?: number;
constructor(code: Nip86ErrorCode, message: string, status?: number) {
super(message);
this.name = 'Nip86Error';
this.code = code;
this.status = status;
}
}
export function isNip86Error(error: unknown): error is Nip86Error {
return error instanceof Nip86Error;
}
/**
* Normalize operator input into the HTTP(S) endpoint NIP-86 speaks on.
*
* Accepts `relay.example.com`, `wss://` and `https://` forms; everything is
* canonicalized to `https://` (plain `ws://`/`http://` is kept only for local
* development relays). Returns `undefined` when the input cannot name a host.
*/
export function normalizeRelayHttpUrl(input: string): string | undefined {
const trimmed = input.trim();
if (!trimmed) return undefined;
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `wss://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
return undefined;
}
switch (parsed.protocol) {
case 'wss:':
parsed.protocol = 'https:';
break;
case 'ws:':
parsed.protocol = 'http:';
break;
case 'https:':
case 'http:':
break;
default:
return undefined;
}
if (!parsed.hostname) return undefined;
return parsed.href;
}
/** The WebSocket form of a normalized management endpoint, for NIP-98 `u` tags and NIP-11 fetches. */
export function relayWsUrl(httpUrl: string): string {
const parsed = new URL(httpUrl);
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
return parsed.href;
}
/**
* Sign a NIP-98 authorization header for one management request.
*
* The `u` tag must be the relay URL exactly as the relay knows it — the
* WebSocket form — and the `payload` tag the SHA-256 of the request body, as
* NIP-86 requires. The signed event never leaves this function except as a
* base64 token in the returned header value.
*/
export async function signNip86AuthHeader(
signer: NostrSigner,
httpUrl: string,
body: string,
): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
const payload = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
const event = await signer.signEvent({
kind: 27235,
content: '',
tags: [
['u', relayWsUrl(httpUrl)],
['method', 'POST'],
['payload', payload],
],
created_at: Math.floor(Date.now() / 1000),
});
return `Nostr ${N64.encodeEvent(event)}`;
}
export interface Nip86CallOptions {
signer: NostrSigner;
method: string;
params?: unknown[];
signal?: AbortSignal;
/** Injecting fetch keeps the client testable and the signing path honest. */
fetchFn?: typeof fetch;
}
/**
* Perform one NIP-86 management call. The response envelope is validated
* (`result` on success, `error` string on failure); anything else — including
* the HTML error pages many relays return for unknown content types — is a
* `malformed` error rather than a JSON exception.
*/
export async function nip86Call<T = unknown>(httpUrl: string, options: Nip86CallOptions): Promise<T> {
const { signer, method, params = [], signal, fetchFn = fetch } = options;
const body = JSON.stringify({ method, params });
let authorization: string;
try {
authorization = await signNip86AuthHeader(signer, httpUrl, body);
} catch (error) {
throw new Nip86Error(
'signing-failed',
`Your signer refused to authorize the request: ${error instanceof Error ? error.message : String(error)}`,
);
}
let response: Response;
try {
response = await fetchFn(httpUrl, {
method: 'POST',
headers: {
'Content-Type': NIP86_CONTENT_TYPE,
Authorization: authorization,
},
body,
signal: signal ?? AbortSignal.timeout(NIP86_TIMEOUT_MS),
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error;
throw new Nip86Error(
'unreachable',
error instanceof DOMException && error.name === 'TimeoutError'
? 'The relay did not answer in time.'
: 'The relay could not be reached. Check the URL and your connection.',
);
}
if (response.status === 401) {
throw new Nip86Error(
'unauthorized',
'The relay rejected the NIP-98 authorization (401). The signed-in key may not be a manager of this relay.',
401,
);
}
if (response.status === 403) {
throw new Nip86Error(
'forbidden',
'The relay authorized the request but forbids this operation for your key (403).',
403,
);
}
if (!response.ok) {
throw new Nip86Error('http-error', `The relay answered with HTTP ${response.status}.`, response.status);
}
let envelope: unknown;
try {
envelope = await response.json();
} catch {
throw new Nip86Error(
'malformed',
'The relay did not return a NIP-86 response. It may not speak the management API (or answered with a web page).',
response.status,
);
}
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
throw new Nip86Error('malformed', 'The relay returned an unexpected response shape.', response.status);
}
const { result, error } = envelope as { result?: unknown; error?: unknown };
if (error !== undefined && error !== null) {
const detail = typeof error === 'string' ? error : 'unknown relay error';
throw new Nip86Error('rpc-error', `The relay refused the operation: ${detail}`, response.status);
}
return result as T;
}
/* --------------------------------------------------------------------------
* NIP-11 relay information document (identity shown after connecting)
* ------------------------------------------------------------------------ */
export interface RelayInfo {
name?: string;
description?: string;
pubkey?: string;
contact?: string;
software?: string;
version?: string;
icon?: string;
}
/**
* Fetch the relay's NIP-11 information document. Returns `undefined` when the
* relay does not publish one — that says nothing about NIP-86 support, so it
* is never an error here.
*/
export async function fetchRelayInfo(
httpUrl: string,
opts?: { signal?: AbortSignal; fetchFn?: typeof fetch },
): Promise<RelayInfo | undefined> {
try {
const response = await (opts?.fetchFn ?? fetch)(httpUrl, {
headers: { Accept: 'application/nostr+json' },
signal: opts?.signal ?? AbortSignal.timeout(NIP86_TIMEOUT_MS),
});
if (!response.ok) return undefined;
const doc: unknown = await response.json();
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) return undefined;
const record = doc as Record<string, unknown>;
const pick = (key: keyof RelayInfo) =>
typeof record[key] === 'string' ? (record[key] as string) : undefined;
return {
name: pick('name'),
description: pick('description'),
pubkey: pick('pubkey'),
contact: pick('contact'),
software: pick('software'),
version: pick('version'),
icon: pick('icon'),
};
} catch {
return undefined;
}
}
/* --------------------------------------------------------------------------
* Response shapes (validated — relays are untrusted input)
* ------------------------------------------------------------------------ */
export interface ReasonedEntry {
reason?: string;
}
export interface BannedPubkey extends ReasonedEntry {
pubkey: string;
}
export interface AllowedPubkey extends ReasonedEntry {
pubkey: string;
}
export interface ModeratedEventRef extends ReasonedEntry {
id: string;
}
export interface BannedEvent extends ReasonedEntry {
id: string;
}
export interface BlockedIp extends ReasonedEntry {
ip: string;
}
export function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function isReasonedEntry(value: unknown): value is ReasonedEntry {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const reason = (value as ReasonedEntry).reason;
return reason === undefined || typeof reason === 'string';
}
export function isPubkeyList(value: unknown): value is BannedPubkey[] {
return (
Array.isArray(value) &&
value.every(
(item) => isReasonedEntry(item) && typeof (item as BannedPubkey).pubkey === 'string',
)
);
}
export function isEventRefList(value: unknown): value is ModeratedEventRef[] {
return (
Array.isArray(value) &&
value.every((item) => isReasonedEntry(item) && typeof (item as ModeratedEventRef).id === 'string')
);
}
export function isBlockedIpList(value: unknown): value is BlockedIp[] {
return (
Array.isArray(value) &&
value.every((item) => isReasonedEntry(item) && typeof (item as BlockedIp).ip === 'string')
);
}
export function isKindList(value: unknown): value is number[] {
return (
Array.isArray(value) &&
value.every((item) => Number.isInteger(item) && (item as number) >= 0 && (item as number) <= 65535)
);
}
/** A role as returned by role-listing extensions (`listroles` is not standard NIP-86). */
export interface Nip86Role {
id: string;
label?: string;
description?: string;
color?: string;
order?: number;
}
/** Tolerant parser for the various shapes relays give `listroles` results. */
export function parseRoles(value: unknown): Nip86Role[] {
if (!Array.isArray(value)) return [];
const roles: Nip86Role[] = [];
for (const item of value) {
if (typeof item === 'string' && item) {
roles.push({ id: item });
continue;
}
if (typeof item !== 'object' || item === null || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
const id = record.id ?? record.name;
if (typeof id !== 'string' || !id) continue;
roles.push({
id,
label: typeof record.label === 'string' ? record.label : undefined,
description: typeof record.description === 'string' ? record.description : undefined,
color: typeof record.color === 'string' ? record.color : undefined,
order: typeof record.order === 'number' ? record.order : undefined,
});
}
return roles;
}
/* --------------------------------------------------------------------------
* Input validation. Every validator returns an operator-safe message or
* undefined; nothing is signed or sent before its input passes.
* ------------------------------------------------------------------------ */
const HEX_64_RE = /^[0-9a-f]{64}$/i;
/**
* Accept a 32-byte hex pubkey, or decode an `npub1…`/`nprofile1…` into one.
* Returns the canonical (lowercase) hex string or an error message.
*/
export function parsePubkeyInput(input: string): string | { error: string } {
const value = input.trim();
if (!value) return { error: 'Enter a public key.' };
if (HEX_64_RE.test(value)) return value.toLowerCase();
if (/^npub1|^nprofile1/i.test(value)) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'npub') return decoded.data;
if (decoded.type === 'nprofile') return decoded.data.pubkey;
} catch {
// fall through to the error below
}
return { error: 'That NIP-19 identifier could not be decoded.' };
}
return { error: 'A public key is 64 hex characters, or an npub1… / nprofile1… identifier.' };
}
/** Validate a 32-byte hex event id (or `note1…`/`nevent1…`). */
export function parseEventIdInput(input: string): string | { error: string } {
const value = input.trim();
if (!value) return { error: 'Enter an event ID.' };
if (HEX_64_RE.test(value)) return value.toLowerCase();
if (/^note1|^nevent1/i.test(value)) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'note') return decoded.data;
if (decoded.type === 'nevent') return decoded.data.id;
} catch {
// fall through
}
return { error: 'That NIP-19 identifier could not be decoded.' };
}
return { error: 'An event ID is 64 hex characters, or a note1… / nevent1… identifier.' };
}
const IPV4_RE =
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
const IPV6_RE = /^[0-9a-f:]*:[0-9a-f:.]*$/i;
/**
* Validate an IP address or CIDR range. Exactly what a relay accepts varies
* by implementation, so this only rules out values that cannot be an address.
*/
export function validateIpInput(input: string): string | undefined {
const value = input.trim();
if (!value) return 'Enter an IP address or CIDR range.';
const [address, prefix, ...rest] = value.split('/');
if (rest.length > 0) return 'Use a single “/” for a CIDR range, e.g. 203.0.113.0/24.';
const isV4 = IPV4_RE.test(address);
const isV6 = !isV4 && address.includes(':') && IPV6_RE.test(address);
if (!isV4 && !isV6) return 'That is not a valid IPv4 or IPv6 address.';
if (prefix !== undefined) {
if (!/^\d+$/.test(prefix)) return 'The CIDR prefix must be a number.';
const size = Number(prefix);
const max = isV4 ? 32 : 128;
if (size < 0 || size > max) return `The CIDR prefix must be between 0 and ${max}.`;
}
return undefined;
}
/** Validate a Nostr kind number (065535 per NIP-01). */
export function parseKindInput(input: string): number | { error: string } {
const value = input.trim();
if (!/^\d+$/.test(value)) return { error: 'A kind is a whole number, e.g. 1 or 30023.' };
const kind = Number(value);
if (kind < 0 || kind > 65535) return { error: 'Kinds range from 0 to 65535.' };
return kind;
}
/** Role ids go into RPC params verbatim; keep them slug-shaped and short. */
export function validateRoleId(input: string): string | undefined {
const value = input.trim();
if (!value) return 'Enter a role ID.';
if (value.length > 64) return 'Role IDs must be 64 characters or fewer.';
if (!/^[\w-]+$/.test(value)) return 'Use letters, numbers, dashes and underscores only.';
return undefined;
}
/** Hex color for role presentation, `#rgb`/`#rrggbb`, or empty. */
export function validateRoleColor(input: string): string | undefined {
const value = input.trim();
if (!value) return undefined;
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)
? undefined
: 'Use a hex color like #8b5cf6, or leave it empty.';
}
/** Loopback/`.local` hostnames — the only ones http:// is trusted for below. */
function isLocalHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local');
}
/**
* Validate and sanitize a relay icon URL before it is shown or submitted.
* Only https (and http for local relays) URLs survive — anything else could
* smuggle script into the page when rendered as an image.
*/
export function sanitizeIconUrl(input: string): string | { error: string } {
const value = input.trim();
if (!value) return { error: 'Enter an icon URL.' };
try {
const parsed = new URL(value);
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocalHostname(parsed.hostname))) {
return { error: 'Only https:// icon URLs are allowed (http:// only for local relays).' };
}
return parsed.href;
} catch {
return { error: 'That is not a valid URL.' };
}
}
/** A management payload is never longer than a sentence; cap reasons defensively. */
export function validateReason(input: string): string | undefined {
return input.length <= 500 ? undefined : 'Keep the reason under 500 characters.';
}
/* --------------------------------------------------------------------------
* Parameter builders — the single place that knows each method's signature.
* Reasons are only ever appended when the operator typed one.
* ------------------------------------------------------------------------ */
export function withReason(value: string, reason: string): unknown[] {
const trimmed = reason.trim();
return trimmed ? [value, trimmed] : [value];
}
export function nip86Params(
method: Nip86CoreMethod,
input: {
pubkey?: string;
eventId?: string;
ip?: string;
kind?: number;
reason?: string;
roleId?: string;
role?: { label: string; description: string; color: string; order: number };
text?: string;
} = {},
): unknown[] {
const reason = input.reason?.trim() ?? '';
switch (method) {
case 'supportedmethods':
case 'listbannedpubkeys':
case 'listallowedpubkeys':
case 'listeventsneedingmoderation':
case 'listbannedevents':
case 'listallowedkinds':
case 'listblockedips':
return [];
case 'banpubkey':
case 'unbanpubkey':
case 'allowpubkey':
case 'unallowpubkey':
return withReason(input.pubkey ?? '', reason);
case 'allowevent':
case 'banevent':
return withReason(input.eventId ?? '', reason);
case 'blockip':
return withReason(input.ip ?? '', reason);
case 'unblockip':
return [input.ip ?? ''];
case 'allowkind':
case 'disallowkind':
return [input.kind ?? 0];
case 'createrole':
case 'editrole': {
const role = input.role ?? { label: '', description: '', color: '', order: 0 };
return [input.roleId ?? '', role.label, role.description, role.color, role.order];
}
case 'deleterole':
return [input.roleId ?? ''];
case 'assignrole':
case 'unassignrole':
return [input.pubkey ?? '', input.roleId ?? ''];
case 'changerelayname':
case 'changerelaydescription':
case 'changerelayicon':
return [input.text ?? ''];
}
}

View File

@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react';
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, ShieldCheck, Sparkles, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -129,6 +129,17 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 720, height: 480 },
minSize: { width: 380, height: 280 },
},
{
id: 'relay-admin',
title: 'Relay Admin',
description: 'NIP-86 management console for relays you operate',
icon: ShieldCheck,
category: 'system',
component: lazy(() => import('@/apps/relay-admin')),
defaultSize: { width: 720, height: 680 },
minSize: { width: 360, height: 320 },
requiresAuth: true,
},
{
id: 'settings',
title: 'Settings',