mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
29
NIP.md
Normal file
29
NIP.md
Normal 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/`.
|
||||
18
docs/apps.md
18
docs/apps.md
@@ -102,6 +102,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
| Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat |
|
||||
| Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP |
|
||||
| Relays | `relays` | — | Connection state, subscription count, measured latency |
|
||||
| Relay Admin | `relay-admin` | `relay?` | NIP-86 management console for relays you operate |
|
||||
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
|
||||
| About | `about` | — | What this is, the app list, the shortcuts |
|
||||
|
||||
@@ -150,6 +151,23 @@ update, the same trap [follow lists](#follow-lists-are-a-whole-list-replacement)
|
||||
kind 3 replaces the entire contact list. The follow button therefore reads the current
|
||||
list back before writing, or the edit would silently drop everyone else.
|
||||
|
||||
### Relay Admin is capability-driven, not method-driven
|
||||
|
||||
The Relay Admin app (`src/apps/relay-admin/`) implements [NIP-86](../NIP.md) — the draft,
|
||||
optional relay-management API. Because implementations vary and the NIP is still a draft,
|
||||
the console never assumes a method exists: it connects, authorizes with a NIP-98 event
|
||||
(kind 27235, `u` + `payload` tags), calls `supportedmethods`, and renders only the
|
||||
sections the relay advertised. Advertised names outside the standard list (e.g. a
|
||||
relay-specific `purgeallevents` or `listroles`) land in a separate, clearly-labelled
|
||||
extensions area that requires typed confirmation — they are never blended into the
|
||||
standard surface, and the standard defines no generic event-purge/delete method.
|
||||
|
||||
Destructive-but-reversible operations (bans, unbans, removing allowlist access, role
|
||||
deletion) go through a confirmation dialog that states the target, the likely effect and
|
||||
whether the relay offers a reverse operation. Every operation is written to a per-session
|
||||
audit log (method, target, result, operator-safe error) that never contains the
|
||||
authorization header or any key material; signing secrets stay inside the user's signer.
|
||||
|
||||
### Relay latency is a real round trip
|
||||
|
||||
A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE`
|
||||
|
||||
108
src/apps/relay-admin/AuditSection.tsx
Normal file
108
src/apps/relay-admin/AuditSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/apps/relay-admin/ExtensionsSection.tsx
Normal file
86
src/apps/relay-admin/ExtensionsSection.tsx
Normal 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 relay’s 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>
|
||||
);
|
||||
}
|
||||
338
src/apps/relay-admin/ModerationSection.tsx
Normal file
338
src/apps/relay-admin/ModerationSection.tsx
Normal 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 relay’s 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>
|
||||
);
|
||||
}
|
||||
713
src/apps/relay-admin/PolicySections.tsx
Normal file
713
src/apps/relay-admin/PolicySections.tsx
Normal 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 key’s events again, subject to its other rules.',
|
||||
reversible: 'Reversible: you can ban the key again.',
|
||||
actionLabel: 'Unban',
|
||||
run: () =>
|
||||
run({
|
||||
method: 'unbanpubkey',
|
||||
params: [entry.pubkey],
|
||||
refresh: ['listbannedpubkeys'],
|
||||
success: 'Pubkey unbanned',
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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 relay’s allowlist. Whether an allowlist restricts everyone else is decided by the relay’s 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 relay’s documentation for its exact enforcement semantics.',
|
||||
reversible: 'Reversible: you can allow the key again.',
|
||||
actionLabel: 'Remove access',
|
||||
run: () =>
|
||||
run({
|
||||
method: 'unallowpubkey',
|
||||
params: [entry.pubkey],
|
||||
refresh: ['listallowedpubkeys'],
|
||||
success: 'Pubkey removed from the allowlist',
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">read-only</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AddDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Allow pubkey"
|
||||
description="Add a key to this relay’s 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 relay’s 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 relay’s allowlist. Whether kinds outside the list are rejected is the relay’s 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 relay’s allowlist (0–65535)."
|
||||
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>
|
||||
);
|
||||
}
|
||||
245
src/apps/relay-admin/RelaySection.tsx
Normal file
245
src/apps/relay-admin/RelaySection.tsx
Normal 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 relay’s 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
487
src/apps/relay-admin/RolesSection.tsx
Normal file
487
src/apps/relay-admin/RolesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
343
src/apps/relay-admin/dialogs.tsx
Normal file
343
src/apps/relay-admin/dialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
src/apps/relay-admin/index.tsx
Normal file
367
src/apps/relay-admin/index.tsx
Normal 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 relay’s 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 relay’s 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>
|
||||
);
|
||||
}
|
||||
178
src/apps/relay-admin/shared.tsx
Normal file
178
src/apps/relay-admin/shared.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/apps/relay-admin/useFilter.ts
Normal file
15
src/apps/relay-admin/useFilter.ts
Normal 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
214
src/hooks/useNip86.test.tsx
Normal 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
418
src/hooks/useNip86.ts
Normal 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
327
src/lib/nip86.test.ts
Normal 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
646
src/lib/nip86.ts
Normal 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 (0–65535 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 ?? ''];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user