Add NIP-86 session hook and relay-admin app UI

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:46:18 +00:00
committed by GitHub
parent 38290bdaf2
commit e4026379bb
12 changed files with 3305 additions and 1 deletions

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,712 @@
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 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: BannedPubkey) => (
<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 };
}

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

@@ -0,0 +1,414 @@
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);
setSession({
url,
info,
methods: core,
extensions,
canListRoles: methodsResult.includes('listroles'),
});
// A different relay must never show the previous relay's policy lists.
queryClient.removeQueries({ queryKey: ['nip86'] });
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],
);
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 };

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',