feat: Add relay admin command with NIP-86 support

Implement `relay admin <url>` command for relay management using NIP-86
Relay Management API:

- Add NIP-98 HTTP Auth signing utility for API authentication
- Add NIP-86 client with all standard methods
- Add RelayAdminViewer component with accordion-based sections:
  - Metadata section (edit relay name, description, icon)
  - Moderation section (ban/allow pubkeys, event moderation queue)
  - Kind filtering section (allow/disallow event kinds)
  - IP blocking section (block/unblock IPs)
- Add batch operations with selection checkboxes
- Add confirmation dialogs for destructive actions
- Show relay metadata (NIP-11) for non-authenticated users
- Gate admin features behind account sign-in

The viewer dynamically shows sections based on relay's supported methods,
discovered via `supportedmethods` API call.
This commit is contained in:
Claude
2026-01-14 12:35:20 +00:00
parent 998944fdf7
commit 05221eb2e0
12 changed files with 2563 additions and 1 deletions

View File

@@ -0,0 +1,313 @@
/**
* Relay Admin Viewer
*
* NIP-86 Relay Management API interface.
* Shows relay metadata (NIP-11) and admin controls for supported methods.
*/
import { useState, useEffect, useCallback } from "react";
import {
Copy,
CopyCheck,
RefreshCw,
Shield,
Settings,
Filter,
Globe,
AlertCircle,
Loader2,
} from "lucide-react";
import { useRelayInfo } from "@/hooks/useRelayInfo";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { Button } from "./ui/button";
import { NIPBadge } from "./NIPBadge";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./ui/accordion";
import {
Nip86Client,
Nip86AuthError,
categoryHasMethods,
} from "@/lib/nip86-client";
import accountManager from "@/services/accounts";
import type { EventTemplate, NostrEvent } from "nostr-tools/core";
import { MetadataSection } from "./relay-admin/MetadataSection";
import { ModerationSection } from "./relay-admin/ModerationSection";
import { KindFilterSection } from "./relay-admin/KindFilterSection";
import { IpBlockingSection } from "./relay-admin/IpBlockingSection";
export interface RelayAdminViewerProps {
url: string;
}
export function RelayAdminViewer({ url }: RelayAdminViewerProps) {
const info = useRelayInfo(url);
const { copy, copied } = useCopy();
const { state } = useGrimoire();
const hasAccount = !!state.activeAccount?.pubkey;
// NIP-86 state
const [supportedMethods, setSupportedMethods] = useState<string[] | null>(
null,
);
const [methodsLoading, setMethodsLoading] = useState(false);
const [methodsError, setMethodsError] = useState<string | null>(null);
// Create signer function from active account
const getSigner = useCallback(() => {
const account = accountManager.active;
if (!account?.signer) return null;
return async (event: EventTemplate): Promise<NostrEvent> => {
const signed = await account.signer!.signEvent(event);
return signed as NostrEvent;
};
}, []);
// Create NIP-86 client
const getClient = useCallback(() => {
const signer = getSigner();
if (!signer) return null;
return new Nip86Client(url, signer);
}, [url, getSigner]);
// Fetch supported methods
const fetchSupportedMethods = useCallback(async () => {
const client = getClient();
if (!client) {
setMethodsError("No active account");
return;
}
setMethodsLoading(true);
setMethodsError(null);
try {
const methods = await client.supportedMethods();
setSupportedMethods(methods);
} catch (error) {
if (error instanceof Nip86AuthError) {
setMethodsError("Unauthorized - you may not have admin access");
} else {
setMethodsError(
error instanceof Error ? error.message : "Failed to fetch methods",
);
}
setSupportedMethods(null);
} finally {
setMethodsLoading(false);
}
}, [getClient]);
// Fetch methods on mount if account is available
useEffect(() => {
if (hasAccount) {
fetchSupportedMethods();
}
}, [hasAccount, fetchSupportedMethods]);
// Check if relay supports NIP-86
const supportsNip86 = info?.supported_nips?.includes(86);
return (
<div className="flex flex-col gap-6 p-4">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex-1">
<h2 className="text-2xl font-bold">
{info?.name || "Unknown Relay"}
</h2>
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
{url}
<Button
variant="link"
size="icon"
className="size-4 text-muted-foreground"
onClick={() => copy(url)}
>
{copied ? (
<CopyCheck className="size-3" />
) : (
<Copy className="size-3" />
)}
</Button>
</div>
{info?.description && (
<p className="text-sm mt-2 text-muted-foreground">
{info.description}
</p>
)}
</div>
</div>
{/* NIP-86 Support Status */}
{!supportsNip86 && info && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
<AlertCircle className="size-4" />
<span>
This relay does not advertise NIP-86 support. Admin features may not
be available.
</span>
</div>
)}
{/* No Account Warning */}
{!hasAccount && (
<div className="flex items-center gap-2 text-sm text-yellow-500 bg-yellow-500/10 p-3 rounded-md">
<Shield className="size-4" />
<span>
Sign in with an account to access admin features. Only relay
metadata is shown.
</span>
</div>
)}
{/* Supported NIPs */}
{info?.supported_nips && info.supported_nips.length > 0 && (
<div>
<h3 className="mb-3 font-semibold text-sm">Supported NIPs</h3>
<div className="flex flex-wrap gap-2">
{info.supported_nips.map((num: number) => (
<NIPBadge
key={num}
nipNumber={String(num).padStart(2, "0")}
showName={true}
/>
))}
</div>
</div>
)}
{/* Admin Sections */}
{hasAccount && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Admin Controls</h3>
<Button
variant="ghost"
size="sm"
onClick={fetchSupportedMethods}
disabled={methodsLoading}
>
{methodsLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
<span className="ml-2">Refresh</span>
</Button>
</div>
{/* Loading State */}
{methodsLoading && !supportedMethods && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Checking admin access...</span>
</div>
)}
{/* Error State */}
{methodsError && (
<div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 p-3 rounded-md">
<AlertCircle className="size-4" />
<span>{methodsError}</span>
</div>
)}
{/* Admin Sections Accordion */}
{supportedMethods && supportedMethods.length > 0 && (
<Accordion type="multiple" className="w-full">
{/* Metadata Section */}
{categoryHasMethods("metadata", supportedMethods) && (
<AccordionItem value="metadata">
<AccordionTrigger>
<div className="flex items-center gap-2">
<Settings className="size-4" />
<span>Relay Metadata</span>
</div>
</AccordionTrigger>
<AccordionContent>
<MetadataSection
url={url}
getClient={getClient}
supportedMethods={supportedMethods}
currentInfo={info}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Moderation Section */}
{categoryHasMethods("moderation", supportedMethods) && (
<AccordionItem value="moderation">
<AccordionTrigger>
<div className="flex items-center gap-2">
<Shield className="size-4" />
<span>Moderation</span>
</div>
</AccordionTrigger>
<AccordionContent>
<ModerationSection
url={url}
getClient={getClient}
supportedMethods={supportedMethods}
/>
</AccordionContent>
</AccordionItem>
)}
{/* Kind Filtering Section */}
{categoryHasMethods("kindFiltering", supportedMethods) && (
<AccordionItem value="kinds">
<AccordionTrigger>
<div className="flex items-center gap-2">
<Filter className="size-4" />
<span>Kind Filtering</span>
</div>
</AccordionTrigger>
<AccordionContent>
<KindFilterSection
url={url}
getClient={getClient}
supportedMethods={supportedMethods}
/>
</AccordionContent>
</AccordionItem>
)}
{/* IP Blocking Section */}
{categoryHasMethods("ipBlocking", supportedMethods) && (
<AccordionItem value="ips">
<AccordionTrigger>
<div className="flex items-center gap-2">
<Globe className="size-4" />
<span>IP Blocking</span>
</div>
</AccordionTrigger>
<AccordionContent>
<IpBlockingSection
url={url}
getClient={getClient}
supportedMethods={supportedMethods}
/>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
)}
{/* No Methods Available */}
{supportedMethods && supportedMethods.length === 0 && (
<div className="text-sm text-muted-foreground">
No admin methods available on this relay.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -20,6 +20,9 @@ const DecodeViewer = lazy(() => import("./DecodeViewer"));
const RelayViewer = lazy(() =>
import("./RelayViewer").then((m) => ({ default: m.RelayViewer })),
);
const RelayAdminViewer = lazy(() =>
import("./RelayAdminViewer").then((m) => ({ default: m.RelayAdminViewer })),
);
const KindRenderer = lazy(() => import("./KindRenderer"));
const KindsViewer = lazy(() => import("./KindsViewer"));
const NipsViewer = lazy(() => import("./NipsViewer"));
@@ -172,6 +175,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "relay":
content = <RelayViewer url={window.props.url} />;
break;
case "relay-admin":
content = <RelayAdminViewer url={window.props.url} />;
break;
case "debug":
content = <DebugViewer />;
break;

View File

@@ -0,0 +1,147 @@
/**
* Confirm Action Dialog
*
* Reusable confirmation dialog for destructive actions.
*/
import { useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface ConfirmActionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
destructive?: boolean;
showReasonInput?: boolean;
onConfirm: (reason?: string) => Promise<void>;
}
export function ConfirmActionDialog({
open,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
destructive = false,
showReasonInput = false,
onConfirm,
}: ConfirmActionDialogProps) {
const [loading, setLoading] = useState(false);
const [reason, setReason] = useState("");
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm(reason || undefined);
onOpenChange(false);
setReason("");
} catch {
// Error is handled by caller via toast
} finally {
setLoading(false);
}
};
const handleCancel = () => {
onOpenChange(false);
setReason("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{destructive && (
<AlertTriangle className="size-5 text-destructive" />
)}
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{showReasonInput && (
<div className="py-4">
<Input
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={loading}>
{cancelText}
</Button>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={loading}
>
{loading && <Loader2 className="size-4 mr-2 animate-spin" />}
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Batch Confirm Dialog
*
* For confirming actions on multiple items.
*/
interface BatchConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
itemCount: number;
itemType: string;
actionText: string;
destructive?: boolean;
showReasonInput?: boolean;
onConfirm: (reason?: string) => Promise<void>;
}
export function BatchConfirmDialog({
open,
onOpenChange,
title,
itemCount,
itemType,
actionText,
destructive = false,
showReasonInput = false,
onConfirm,
}: BatchConfirmDialogProps) {
const description = `You are about to ${actionText.toLowerCase()} ${itemCount} ${itemType}${itemCount === 1 ? "" : "s"}. This action cannot be undone.`;
return (
<ConfirmActionDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
confirmText={`${actionText} ${itemCount} ${itemType}${itemCount === 1 ? "" : "s"}`}
destructive={destructive}
showReasonInput={showReasonInput}
onConfirm={onConfirm}
/>
);
}

View File

@@ -0,0 +1,318 @@
/**
* IP Blocking Section
*
* Manage blocked IP addresses on the relay.
*/
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { RefreshCw, Loader2, Trash2, Plus, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import type { Nip86Client, IpEntry } from "@/lib/nip86-client";
import { BatchConfirmDialog, ConfirmActionDialog } from "./ConfirmActionDialog";
interface IpBlockingSectionProps {
url: string;
getClient: () => Nip86Client | null;
supportedMethods: string[];
}
export function IpBlockingSection({
getClient,
supportedMethods,
}: IpBlockingSectionProps) {
const canListIps = supportedMethods.includes("listblockedips");
const canBlockIp = supportedMethods.includes("blockip");
const canUnblockIp = supportedMethods.includes("unblockip");
const [ips, setIps] = useState<IpEntry[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
// Add IP form
const [newIp, setNewIp] = useState("");
const [newReason, setNewReason] = useState("");
const [addingIp, setAddingIp] = useState(false);
// Selection state for batch operations
const [selectedIps, setSelectedIps] = useState<Set<string>>(new Set());
const [showBatchDialog, setShowBatchDialog] = useState(false);
// Single unblock confirmation
const [ipToUnblock, setIpToUnblock] = useState<string | null>(null);
const fetchIps = useCallback(async () => {
const client = getClient();
if (!client || !canListIps) return;
setLoading(true);
try {
const result = await client.listBlockedIps();
setIps(result);
setLoaded(true);
setSelectedIps(new Set());
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to fetch blocked IPs",
);
} finally {
setLoading(false);
}
}, [getClient, canListIps]);
const handleBlockIp = async () => {
const client = getClient();
if (!client || !canBlockIp || !newIp.trim()) return;
setAddingIp(true);
try {
await client.blockIp(newIp.trim(), newReason.trim() || undefined);
toast.success(`IP ${newIp} blocked`);
setNewIp("");
setNewReason("");
// Refresh list
await fetchIps();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to block IP",
);
} finally {
setAddingIp(false);
}
};
const handleUnblockIp = async (ip: string) => {
const client = getClient();
if (!client || !canUnblockIp) return;
try {
await client.unblockIp(ip);
toast.success(`IP ${ip} unblocked`);
setIps((prev) => prev.filter((entry) => entry.ip !== ip));
setSelectedIps((prev) => {
const next = new Set(prev);
next.delete(ip);
return next;
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to unblock IP",
);
}
};
const handleBatchUnblock = async () => {
const client = getClient();
if (!client || !canUnblockIp || selectedIps.size === 0) return;
let successCount = 0;
let failCount = 0;
for (const ip of selectedIps) {
try {
await client.unblockIp(ip);
successCount++;
} catch {
failCount++;
}
}
if (successCount > 0) {
toast.success(`Unblocked ${successCount} IP(s)`);
}
if (failCount > 0) {
toast.error(`Failed to unblock ${failCount} IP(s)`);
}
// Refresh list
await fetchIps();
};
const toggleIpSelection = (ip: string) => {
setSelectedIps((prev) => {
const next = new Set(prev);
if (next.has(ip)) {
next.delete(ip);
} else {
next.add(ip);
}
return next;
});
};
const toggleAllSelection = () => {
if (selectedIps.size === ips.length) {
setSelectedIps(new Set());
} else {
setSelectedIps(new Set(ips.map((entry) => entry.ip)));
}
};
// Validate IP address format (basic validation)
const isValidIp = (ip: string): boolean => {
// IPv4
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// IPv6 (simplified)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
};
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex items-center justify-between gap-2">
{canListIps && (
<Button
variant="outline"
size="sm"
onClick={fetchIps}
disabled={loading}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
<span className="ml-2">{loaded ? "Refresh" : "Load IPs"}</span>
</Button>
)}
{canUnblockIp && selectedIps.size > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowBatchDialog(true)}
>
<Trash2 className="size-4 mr-2" />
Unblock {selectedIps.size} selected
</Button>
)}
</div>
{/* Add IP Form */}
{canBlockIp && (
<div className="space-y-2">
<label className="text-sm font-medium">Block IP Address</label>
<div className="flex gap-2">
<Input
value={newIp}
onChange={(e) => setNewIp(e.target.value)}
placeholder="192.168.1.1 or 2001:db8::1"
className="flex-1"
/>
<Input
value={newReason}
onChange={(e) => setNewReason(e.target.value)}
placeholder="Reason (optional)"
className="flex-1"
/>
<Button
size="sm"
onClick={handleBlockIp}
disabled={addingIp || !newIp.trim() || !isValidIp(newIp.trim())}
>
{addingIp ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
</Button>
</div>
{newIp && !isValidIp(newIp) && (
<p className="text-xs text-destructive">
Invalid IP address format
</p>
)}
</div>
)}
{/* IPs List */}
{loaded && ips.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">
Blocked IPs ({ips.length})
</label>
{canUnblockIp && ips.length > 1 && (
<Button variant="ghost" size="sm" onClick={toggleAllSelection}>
{selectedIps.size === ips.length
? "Deselect All"
: "Select All"}
</Button>
)}
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{ips.map((entry) => (
<div
key={entry.ip}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted/50"
>
{canUnblockIp && (
<Checkbox
checked={selectedIps.has(entry.ip)}
onCheckedChange={() => toggleIpSelection(entry.ip)}
/>
)}
<Globe className="size-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-mono text-sm">{entry.ip}</div>
{entry.reason && (
<div className="text-xs text-muted-foreground truncate">
{entry.reason}
</div>
)}
</div>
{canUnblockIp && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => setIpToUnblock(entry.ip)}
>
<Trash2 className="size-3" />
</Button>
)}
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{loaded && ips.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center">
No blocked IP addresses.
</div>
)}
{/* Single Unblock Confirmation */}
<ConfirmActionDialog
open={!!ipToUnblock}
onOpenChange={(open) => !open && setIpToUnblock(null)}
title="Unblock IP Address"
description={`Are you sure you want to unblock ${ipToUnblock}?`}
confirmText="Unblock"
onConfirm={async () => {
if (ipToUnblock) {
await handleUnblockIp(ipToUnblock);
setIpToUnblock(null);
}
}}
/>
{/* Batch Unblock Dialog */}
<BatchConfirmDialog
open={showBatchDialog}
onOpenChange={setShowBatchDialog}
title="Unblock IP Addresses"
itemCount={selectedIps.size}
itemType="IP address"
actionText="Unblock"
onConfirm={handleBatchUnblock}
/>
</div>
);
}

View File

@@ -0,0 +1,252 @@
/**
* Kind Filter Section
*
* Manage allowed/disallowed event kinds on the relay.
*/
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { RefreshCw, Loader2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { KindSelector } from "@/components/KindSelector";
import { KindBadge } from "@/components/KindBadge";
import type { Nip86Client } from "@/lib/nip86-client";
import { BatchConfirmDialog } from "./ConfirmActionDialog";
interface KindFilterSectionProps {
url: string;
getClient: () => Nip86Client | null;
supportedMethods: string[];
}
export function KindFilterSection({
getClient,
supportedMethods,
}: KindFilterSectionProps) {
const canListKinds = supportedMethods.includes("listallowedkinds");
const canAllowKind = supportedMethods.includes("allowkind");
const canDisallowKind = supportedMethods.includes("disallowkind");
const [kinds, setKinds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
// Selection state for batch operations
const [selectedKinds, setSelectedKinds] = useState<Set<number>>(new Set());
const [showBatchDialog, setShowBatchDialog] = useState(false);
const [_batchLoading, setBatchLoading] = useState(false);
const fetchKinds = useCallback(async () => {
const client = getClient();
if (!client || !canListKinds) return;
setLoading(true);
try {
const result = await client.listAllowedKinds();
setKinds(result.sort((a, b) => a - b));
setLoaded(true);
setSelectedKinds(new Set());
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to fetch kinds",
);
} finally {
setLoading(false);
}
}, [getClient, canListKinds]);
const handleAddKind = async (kind: number) => {
const client = getClient();
if (!client || !canAllowKind) return;
try {
await client.allowKind(kind);
toast.success(`Kind ${kind} allowed`);
// Refresh list
await fetchKinds();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to allow kind",
);
}
};
const handleRemoveKind = async (kind: number) => {
const client = getClient();
if (!client || !canDisallowKind) return;
try {
await client.disallowKind(kind);
toast.success(`Kind ${kind} disallowed`);
setKinds((prev) => prev.filter((k) => k !== kind));
setSelectedKinds((prev) => {
const next = new Set(prev);
next.delete(kind);
return next;
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to disallow kind",
);
}
};
const handleBatchRemove = async () => {
const client = getClient();
if (!client || !canDisallowKind || selectedKinds.size === 0) return;
setBatchLoading(true);
let successCount = 0;
let failCount = 0;
for (const kind of selectedKinds) {
try {
await client.disallowKind(kind);
successCount++;
} catch {
failCount++;
}
}
setBatchLoading(false);
if (successCount > 0) {
toast.success(`Disallowed ${successCount} kind(s)`);
}
if (failCount > 0) {
toast.error(`Failed to disallow ${failCount} kind(s)`);
}
// Refresh list
await fetchKinds();
};
const toggleKindSelection = (kind: number) => {
setSelectedKinds((prev) => {
const next = new Set(prev);
if (next.has(kind)) {
next.delete(kind);
} else {
next.add(kind);
}
return next;
});
};
const toggleAllSelection = () => {
if (selectedKinds.size === kinds.length) {
setSelectedKinds(new Set());
} else {
setSelectedKinds(new Set(kinds));
}
};
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex items-center justify-between gap-2">
{canListKinds && (
<Button
variant="outline"
size="sm"
onClick={fetchKinds}
disabled={loading}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
<span className="ml-2">{loaded ? "Refresh" : "Load Kinds"}</span>
</Button>
)}
{canDisallowKind && selectedKinds.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setShowBatchDialog(true)}
>
<Trash2 className="size-4 mr-2" />
Remove {selectedKinds.size} selected
</Button>
)}
</div>
{/* Add Kind */}
{canAllowKind && (
<div className="space-y-2">
<label className="text-sm font-medium">Add Kind</label>
<KindSelector onSelect={handleAddKind} exclude={kinds} />
</div>
)}
{/* Kinds List */}
{loaded && kinds.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">
Allowed Kinds ({kinds.length})
</label>
{canDisallowKind && kinds.length > 1 && (
<Button variant="ghost" size="sm" onClick={toggleAllSelection}>
{selectedKinds.size === kinds.length
? "Deselect All"
: "Select All"}
</Button>
)}
</div>
<div className="grid grid-cols-1 gap-2 max-h-[300px] overflow-y-auto">
{kinds.map((kind) => (
<div
key={kind}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted/50"
>
{canDisallowKind && (
<Checkbox
checked={selectedKinds.has(kind)}
onCheckedChange={() => toggleKindSelection(kind)}
/>
)}
<KindBadge kind={kind} />
{canDisallowKind && (
<Button
variant="ghost"
size="icon"
className="size-6 ml-auto"
onClick={() => handleRemoveKind(kind)}
>
<Trash2 className="size-3" />
</Button>
)}
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{loaded && kinds.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center">
No allowed kinds configured. This relay may accept all kinds.
</div>
)}
{/* Batch Remove Dialog */}
<BatchConfirmDialog
open={showBatchDialog}
onOpenChange={setShowBatchDialog}
title="Remove Kinds"
itemCount={selectedKinds.size}
itemType="kind"
actionText="Remove"
destructive
onConfirm={async () => {
await handleBatchRemove();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,189 @@
/**
* Metadata Section
*
* Edit relay name, description, and icon.
*/
import { useState } from "react";
import { toast } from "sonner";
import { Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { Nip86Client } from "@/lib/nip86-client";
interface MetadataSectionProps {
url: string;
getClient: () => Nip86Client | null;
supportedMethods: string[];
currentInfo?: {
name?: string;
description?: string;
icon?: string;
} | null;
}
export function MetadataSection({
getClient,
supportedMethods,
currentInfo,
}: MetadataSectionProps) {
const canChangeName = supportedMethods.includes("changerelayname");
const canChangeDescription = supportedMethods.includes(
"changerelaydescription",
);
const canChangeIcon = supportedMethods.includes("changerelayicon");
const [name, setName] = useState(currentInfo?.name || "");
const [description, setDescription] = useState(
currentInfo?.description || "",
);
const [iconUrl, setIconUrl] = useState(currentInfo?.icon || "");
const [savingName, setSavingName] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [savingIcon, setSavingIcon] = useState(false);
const handleSaveName = async () => {
const client = getClient();
if (!client || !name.trim()) return;
setSavingName(true);
try {
await client.changeRelayName(name.trim());
toast.success("Relay name updated");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to update name",
);
} finally {
setSavingName(false);
}
};
const handleSaveDescription = async () => {
const client = getClient();
if (!client) return;
setSavingDescription(true);
try {
await client.changeRelayDescription(description);
toast.success("Relay description updated");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to update description",
);
} finally {
setSavingDescription(false);
}
};
const handleSaveIcon = async () => {
const client = getClient();
if (!client || !iconUrl.trim()) return;
setSavingIcon(true);
try {
await client.changeRelayIcon(iconUrl.trim());
toast.success("Relay icon updated");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to update icon",
);
} finally {
setSavingIcon(false);
}
};
return (
<div className="space-y-4">
{/* Name */}
{canChangeName && (
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<div className="flex gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Relay name"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveName}
disabled={savingName || !name.trim()}
>
{savingName ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
</div>
)}
{/* Description */}
{canChangeDescription && (
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<div className="flex gap-2">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Relay description"
className="flex-1 min-h-[80px]"
/>
<Button
size="sm"
onClick={handleSaveDescription}
disabled={savingDescription}
>
{savingDescription ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
</div>
)}
{/* Icon URL */}
{canChangeIcon && (
<div className="space-y-2">
<label className="text-sm font-medium">Icon URL</label>
<div className="flex gap-2">
<Input
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
placeholder="https://example.com/icon.png"
className="flex-1"
/>
<Button
size="sm"
onClick={handleSaveIcon}
disabled={savingIcon || !iconUrl.trim()}
>
{savingIcon ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
</Button>
</div>
{iconUrl && (
<img
src={iconUrl}
alt="Relay icon preview"
className="size-16 rounded-md object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,856 @@
/**
* Moderation Section
*
* Manage pubkey bans/allows and event moderation.
*/
import { useState, useCallback } from "react";
import { toast } from "sonner";
import {
RefreshCw,
Loader2,
Trash2,
UserX,
UserCheck,
ShieldAlert,
ShieldCheck,
ShieldX,
FileX,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UserName } from "@/components/nostr/UserName";
import { KindBadge } from "@/components/KindBadge";
import type {
Nip86Client,
PubkeyEntry,
EventEntry,
ModerationQueueEntry,
} from "@/lib/nip86-client";
import { BatchConfirmDialog } from "./ConfirmActionDialog";
import eventStore from "@/services/event-store";
import { eventLoader } from "@/services/loaders";
import type { NostrEvent } from "nostr-tools/core";
interface ModerationSectionProps {
url: string;
getClient: () => Nip86Client | null;
supportedMethods: string[];
}
export function ModerationSection({
url,
getClient,
supportedMethods,
}: ModerationSectionProps) {
// Capability checks
const canBanPubkey = supportedMethods.includes("banpubkey");
const canListBannedPubkeys = supportedMethods.includes("listbannedpubkeys");
const canAllowPubkey = supportedMethods.includes("allowpubkey");
const canListAllowedPubkeys = supportedMethods.includes("listallowedpubkeys");
const canListModQueue = supportedMethods.includes(
"listeventsneedingmoderation",
);
const canAllowEvent = supportedMethods.includes("allowevent");
const canBanEvent = supportedMethods.includes("banevent");
const canListBannedEvents = supportedMethods.includes("listbannedevents");
// State for each list
const [bannedPubkeys, setBannedPubkeys] = useState<PubkeyEntry[]>([]);
const [allowedPubkeys, setAllowedPubkeys] = useState<PubkeyEntry[]>([]);
const [moderationQueue, setModerationQueue] = useState<
ModerationQueueEntry[]
>([]);
const [bannedEvents, setBannedEvents] = useState<EventEntry[]>([]);
// Event cache for previews
const [eventCache, setEventCache] = useState<Record<string, NostrEvent>>({});
// Loading states
const [loadingBanned, setLoadingBanned] = useState(false);
const [loadingAllowed, setLoadingAllowed] = useState(false);
const [loadingQueue, setLoadingQueue] = useState(false);
const [loadingBannedEvents, setLoadingBannedEvents] = useState(false);
// Loaded flags
const [loadedBanned, setLoadedBanned] = useState(false);
const [loadedAllowed, setLoadedAllowed] = useState(false);
const [loadedQueue, setLoadedQueue] = useState(false);
const [loadedBannedEvents, setLoadedBannedEvents] = useState(false);
// Add form state
const [newPubkey, setNewPubkey] = useState("");
const [newReason, setNewReason] = useState("");
const [addingPubkey, setAddingPubkey] = useState(false);
// Selection states
const [selectedBannedPubkeys, setSelectedBannedPubkeys] = useState<
Set<string>
>(new Set());
const [_selectedAllowedPubkeys, setSelectedAllowedPubkeys] = useState<
Set<string>
>(new Set());
const [selectedQueueEvents, setSelectedQueueEvents] = useState<Set<string>>(
new Set(),
);
const [_selectedBannedEvents, setSelectedBannedEvents] = useState<
Set<string>
>(new Set());
// Dialog states
const [_showBanDialog, _setShowBanDialog] = useState(false);
const [showUnbanDialog, setShowUnbanDialog] = useState(false);
const [showApproveDialog, setShowApproveDialog] = useState(false);
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [_pubkeyToAction, _setPubkeyToAction] = useState<{
pubkey: string;
action: "ban" | "unban" | "remove";
} | null>(null);
const [_eventToAction, _setEventToAction] = useState<{
id: string;
action: "approve" | "ban" | "unban";
} | null>(null);
// Parse pubkey input (supports npub, hex, nprofile)
const parsePubkeyInput = (input: string): string | null => {
const trimmed = input.trim();
// Try hex
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
// Try bech32
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "npub") {
return decoded.data;
}
if (decoded.type === "nprofile") {
return decoded.data.pubkey;
}
} catch {
// Not a valid bech32
}
return null;
};
// Fetch events for preview
const fetchEventPreviews = useCallback(
async (eventIds: string[]) => {
const newCache: Record<string, NostrEvent> = { ...eventCache };
for (const id of eventIds) {
if (newCache[id]) continue;
// First check if already in store
const existing = eventStore.getEvent(id);
if (existing) {
newCache[id] = existing;
continue;
}
// Try to fetch from the relay we're administering
try {
eventLoader({ id, relays: [url] }).subscribe({
next: (event) => {
if (event) {
setEventCache((prev) => ({ ...prev, [event.id]: event }));
}
},
});
} catch {
// Ignore fetch errors
}
}
setEventCache(newCache);
},
[eventCache, url],
);
// Fetch functions
const fetchBannedPubkeys = useCallback(async () => {
const client = getClient();
if (!client || !canListBannedPubkeys) return;
setLoadingBanned(true);
try {
const result = await client.listBannedPubkeys();
setBannedPubkeys(result);
setLoadedBanned(true);
setSelectedBannedPubkeys(new Set());
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to fetch banned pubkeys",
);
} finally {
setLoadingBanned(false);
}
}, [getClient, canListBannedPubkeys]);
const fetchAllowedPubkeys = useCallback(async () => {
const client = getClient();
if (!client || !canListAllowedPubkeys) return;
setLoadingAllowed(true);
try {
const result = await client.listAllowedPubkeys();
setAllowedPubkeys(result);
setLoadedAllowed(true);
setSelectedAllowedPubkeys(new Set());
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to fetch allowed pubkeys",
);
} finally {
setLoadingAllowed(false);
}
}, [getClient, canListAllowedPubkeys]);
const fetchModerationQueue = useCallback(async () => {
const client = getClient();
if (!client || !canListModQueue) return;
setLoadingQueue(true);
try {
const result = await client.listEventsNeedingModeration();
setModerationQueue(result);
setLoadedQueue(true);
setSelectedQueueEvents(new Set());
// Fetch event previews
if (result.length > 0) {
fetchEventPreviews(result.map((e) => e.id));
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to fetch moderation queue",
);
} finally {
setLoadingQueue(false);
}
}, [getClient, canListModQueue, fetchEventPreviews]);
const fetchBannedEvents = useCallback(async () => {
const client = getClient();
if (!client || !canListBannedEvents) return;
setLoadingBannedEvents(true);
try {
const result = await client.listBannedEvents();
setBannedEvents(result);
setLoadedBannedEvents(true);
setSelectedBannedEvents(new Set());
// Fetch event previews
if (result.length > 0) {
fetchEventPreviews(result.map((e) => e.id));
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to fetch banned events",
);
} finally {
setLoadingBannedEvents(false);
}
}, [getClient, canListBannedEvents, fetchEventPreviews]);
// Action handlers
const handleBanPubkey = async () => {
const client = getClient();
const pubkey = parsePubkeyInput(newPubkey);
if (!client || !canBanPubkey || !pubkey) return;
setAddingPubkey(true);
try {
await client.banPubkey(pubkey, newReason.trim() || undefined);
toast.success("Pubkey banned");
setNewPubkey("");
setNewReason("");
if (loadedBanned) await fetchBannedPubkeys();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to ban pubkey",
);
} finally {
setAddingPubkey(false);
}
};
const handleAllowPubkey = async () => {
const client = getClient();
const pubkey = parsePubkeyInput(newPubkey);
if (!client || !canAllowPubkey || !pubkey) return;
setAddingPubkey(true);
try {
await client.allowPubkey(pubkey, newReason.trim() || undefined);
toast.success("Pubkey allowed");
setNewPubkey("");
setNewReason("");
if (loadedAllowed) await fetchAllowedPubkeys();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to allow pubkey",
);
} finally {
setAddingPubkey(false);
}
};
const handleUnbanPubkey = async (pubkey: string) => {
const client = getClient();
if (!client || !canAllowPubkey) return;
try {
await client.allowPubkey(pubkey);
toast.success("Pubkey unbanned");
setBannedPubkeys((prev) => prev.filter((p) => p.pubkey !== pubkey));
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to unban pubkey",
);
}
};
const handleBatchUnban = async () => {
const client = getClient();
if (!client || !canAllowPubkey) return;
let successCount = 0;
for (const pubkey of selectedBannedPubkeys) {
try {
await client.allowPubkey(pubkey);
successCount++;
} catch {
// Continue with others
}
}
if (successCount > 0) {
toast.success(`Unbanned ${successCount} pubkey(s)`);
await fetchBannedPubkeys();
}
};
const handleApproveEvent = async (eventId: string) => {
const client = getClient();
if (!client || !canAllowEvent) return;
try {
await client.allowEvent(eventId);
toast.success("Event approved");
setModerationQueue((prev) => prev.filter((e) => e.id !== eventId));
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to approve event",
);
}
};
const handleBanEventFromQueue = async (eventId: string) => {
const client = getClient();
if (!client || !canBanEvent) return;
try {
await client.banEvent(eventId);
toast.success("Event banned");
setModerationQueue((prev) => prev.filter((e) => e.id !== eventId));
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to ban event",
);
}
};
const handleBatchApprove = async () => {
const client = getClient();
if (!client || !canAllowEvent) return;
let successCount = 0;
for (const eventId of selectedQueueEvents) {
try {
await client.allowEvent(eventId);
successCount++;
} catch {
// Continue
}
}
if (successCount > 0) {
toast.success(`Approved ${successCount} event(s)`);
await fetchModerationQueue();
}
};
const handleBatchReject = async () => {
const client = getClient();
if (!client || !canBanEvent) return;
let successCount = 0;
for (const eventId of selectedQueueEvents) {
try {
await client.banEvent(eventId);
successCount++;
} catch {
// Continue
}
}
if (successCount > 0) {
toast.success(`Rejected ${successCount} event(s)`);
await fetchModerationQueue();
}
};
// Render event preview
const renderEventPreview = (eventId: string) => {
const event = eventCache[eventId];
if (!event) {
return (
<span className="font-mono text-xs text-muted-foreground truncate">
{eventId.slice(0, 8)}...{eventId.slice(-8)}
</span>
);
}
return (
<div className="flex items-center gap-2 min-w-0">
<KindBadge kind={event.kind} variant="compact" className="shrink-0" />
<UserName pubkey={event.pubkey} className="shrink-0 text-xs" />
<span className="text-xs text-muted-foreground truncate">
{event.content.slice(0, 50)}
{event.content.length > 50 ? "..." : ""}
</span>
</div>
);
};
// Determine which tabs to show
const showPubkeyTab =
canBanPubkey ||
canListBannedPubkeys ||
canAllowPubkey ||
canListAllowedPubkeys;
const showEventsTab =
canListModQueue || canAllowEvent || canBanEvent || canListBannedEvents;
return (
<div className="space-y-4">
<Tabs defaultValue={showPubkeyTab ? "pubkeys" : "events"}>
<TabsList>
{showPubkeyTab && <TabsTrigger value="pubkeys">Pubkeys</TabsTrigger>}
{showEventsTab && <TabsTrigger value="events">Events</TabsTrigger>}
</TabsList>
{/* Pubkeys Tab */}
{showPubkeyTab && (
<TabsContent value="pubkeys" className="space-y-4">
{/* Add Pubkey Form */}
{(canBanPubkey || canAllowPubkey) && (
<div className="space-y-2">
<label className="text-sm font-medium">
Add Pubkey to Ban/Allow List
</label>
<div className="flex gap-2">
<Input
value={newPubkey}
onChange={(e) => setNewPubkey(e.target.value)}
placeholder="npub or hex pubkey"
className="flex-1"
/>
<Input
value={newReason}
onChange={(e) => setNewReason(e.target.value)}
placeholder="Reason (optional)"
className="flex-1"
/>
</div>
<div className="flex gap-2">
{canBanPubkey && (
<Button
size="sm"
variant="destructive"
onClick={handleBanPubkey}
disabled={addingPubkey || !parsePubkeyInput(newPubkey)}
>
{addingPubkey ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<UserX className="size-4 mr-2" />
)}
Ban
</Button>
)}
{canAllowPubkey && (
<Button
size="sm"
onClick={handleAllowPubkey}
disabled={addingPubkey || !parsePubkeyInput(newPubkey)}
>
{addingPubkey ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<UserCheck className="size-4 mr-2" />
)}
Allow
</Button>
)}
</div>
</div>
)}
{/* Banned Pubkeys */}
{canListBannedPubkeys && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium flex items-center gap-2">
<UserX className="size-4 text-destructive" />
Banned Pubkeys
{loadedBanned && ` (${bannedPubkeys.length})`}
</label>
<div className="flex gap-2">
{selectedBannedPubkeys.size > 0 && canAllowPubkey && (
<Button
variant="outline"
size="sm"
onClick={() => setShowUnbanDialog(true)}
>
Unban {selectedBannedPubkeys.size} selected
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={fetchBannedPubkeys}
disabled={loadingBanned}
>
{loadingBanned ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</div>
</div>
{loadedBanned && bannedPubkeys.length > 0 && (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{bannedPubkeys.map((entry) => (
<div
key={entry.pubkey}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
>
{canAllowPubkey && (
<Checkbox
checked={selectedBannedPubkeys.has(entry.pubkey)}
onCheckedChange={() => {
setSelectedBannedPubkeys((prev) => {
const next = new Set(prev);
if (next.has(entry.pubkey)) {
next.delete(entry.pubkey);
} else {
next.add(entry.pubkey);
}
return next;
});
}}
/>
)}
<UserName
pubkey={entry.pubkey}
className="flex-1 truncate"
/>
{entry.reason && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{entry.reason}
</span>
)}
{canAllowPubkey && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => handleUnbanPubkey(entry.pubkey)}
>
<Trash2 className="size-3" />
</Button>
)}
</div>
))}
</div>
)}
{loadedBanned && bannedPubkeys.length === 0 && (
<div className="text-sm text-muted-foreground py-2">
No banned pubkeys
</div>
)}
</div>
)}
{/* Allowed Pubkeys */}
{canListAllowedPubkeys && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium flex items-center gap-2">
<UserCheck className="size-4 text-green-500" />
Allowed Pubkeys
{loadedAllowed && ` (${allowedPubkeys.length})`}
</label>
<Button
variant="outline"
size="sm"
onClick={fetchAllowedPubkeys}
disabled={loadingAllowed}
>
{loadingAllowed ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</div>
{loadedAllowed && allowedPubkeys.length > 0 && (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{allowedPubkeys.map((entry) => (
<div
key={entry.pubkey}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
>
<UserName
pubkey={entry.pubkey}
className="flex-1 truncate"
/>
{entry.reason && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{entry.reason}
</span>
)}
</div>
))}
</div>
)}
{loadedAllowed && allowedPubkeys.length === 0 && (
<div className="text-sm text-muted-foreground py-2">
No allowed pubkeys (relay may be public)
</div>
)}
</div>
)}
</TabsContent>
)}
{/* Events Tab */}
{showEventsTab && (
<TabsContent value="events" className="space-y-4">
{/* Moderation Queue */}
{canListModQueue && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium flex items-center gap-2">
<ShieldAlert className="size-4 text-yellow-500" />
Moderation Queue
{loadedQueue && ` (${moderationQueue.length})`}
</label>
<div className="flex gap-2">
{selectedQueueEvents.size > 0 && (
<>
{canAllowEvent && (
<Button
size="sm"
onClick={() => setShowApproveDialog(true)}
>
<ShieldCheck className="size-4 mr-1" />
Approve {selectedQueueEvents.size}
</Button>
)}
{canBanEvent && (
<Button
variant="destructive"
size="sm"
onClick={() => setShowRejectDialog(true)}
>
<ShieldX className="size-4 mr-1" />
Reject {selectedQueueEvents.size}
</Button>
)}
</>
)}
<Button
variant="outline"
size="sm"
onClick={fetchModerationQueue}
disabled={loadingQueue}
>
{loadingQueue ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</div>
</div>
{loadedQueue && moderationQueue.length > 0 && (
<div className="space-y-1 max-h-[250px] overflow-y-auto">
{moderationQueue.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
>
<Checkbox
checked={selectedQueueEvents.has(entry.id)}
onCheckedChange={() => {
setSelectedQueueEvents((prev) => {
const next = new Set(prev);
if (next.has(entry.id)) {
next.delete(entry.id);
} else {
next.add(entry.id);
}
return next;
});
}}
/>
<div className="flex-1 min-w-0">
{renderEventPreview(entry.id)}
</div>
<div className="flex gap-1 shrink-0">
{canAllowEvent && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => handleApproveEvent(entry.id)}
>
<ShieldCheck className="size-3 text-green-500" />
</Button>
)}
{canBanEvent && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => handleBanEventFromQueue(entry.id)}
>
<ShieldX className="size-3 text-destructive" />
</Button>
)}
</div>
</div>
))}
</div>
)}
{loadedQueue && moderationQueue.length === 0 && (
<div className="text-sm text-muted-foreground py-2">
No events pending moderation
</div>
)}
</div>
)}
{/* Banned Events */}
{canListBannedEvents && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium flex items-center gap-2">
<FileX className="size-4 text-destructive" />
Banned Events
{loadedBannedEvents && ` (${bannedEvents.length})`}
</label>
<Button
variant="outline"
size="sm"
onClick={fetchBannedEvents}
disabled={loadingBannedEvents}
>
{loadingBannedEvents ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</div>
{loadedBannedEvents && bannedEvents.length > 0 && (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{bannedEvents.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
>
<div className="flex-1 min-w-0">
{renderEventPreview(entry.id)}
</div>
{entry.reason && (
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{entry.reason}
</span>
)}
</div>
))}
</div>
)}
{loadedBannedEvents && bannedEvents.length === 0 && (
<div className="text-sm text-muted-foreground py-2">
No banned events
</div>
)}
</div>
)}
</TabsContent>
)}
</Tabs>
{/* Batch Dialogs */}
<BatchConfirmDialog
open={showUnbanDialog}
onOpenChange={setShowUnbanDialog}
title="Unban Pubkeys"
itemCount={selectedBannedPubkeys.size}
itemType="pubkey"
actionText="Unban"
onConfirm={handleBatchUnban}
/>
<BatchConfirmDialog
open={showApproveDialog}
onOpenChange={setShowApproveDialog}
title="Approve Events"
itemCount={selectedQueueEvents.size}
itemType="event"
actionText="Approve"
onConfirm={handleBatchApprove}
/>
<BatchConfirmDialog
open={showRejectDialog}
onOpenChange={setShowRejectDialog}
title="Reject Events"
itemCount={selectedQueueEvents.size}
itemType="event"
actionText="Reject"
destructive
onConfirm={handleBatchReject}
/>
</div>
);
}

310
src/lib/nip86-client.ts Normal file
View File

@@ -0,0 +1,310 @@
/**
* NIP-86 Relay Management API Client
*
* JSON-RPC-like API for relay administration.
* Uses NIP-98 HTTP Auth for authorization.
*
* @see https://github.com/nostr-protocol/nips/blob/master/86.md
*/
import { createAuthHeader, type SignerFn } from "./nip98";
/** NIP-86 Content-Type header */
const CONTENT_TYPE = "application/nostr+json+rpc";
/** Banned/allowed pubkey entry */
export interface PubkeyEntry {
pubkey: string;
reason?: string;
}
/** Banned event entry */
export interface EventEntry {
id: string;
reason?: string;
}
/** Event needing moderation */
export interface ModerationQueueEntry {
id: string;
reason?: string;
}
/** Blocked IP entry */
export interface IpEntry {
ip: string;
reason?: string;
}
/** NIP-86 API error */
export class Nip86Error extends Error {
constructor(message: string) {
super(message);
this.name = "Nip86Error";
}
}
/** NIP-86 authorization error (401) */
export class Nip86AuthError extends Nip86Error {
constructor(message: string = "Unauthorized") {
super(message);
this.name = "Nip86AuthError";
}
}
/**
* NIP-86 Relay Management API Client
*
* Provides methods to manage relay settings, moderation, and access control.
*/
export class Nip86Client {
private httpUrl: string;
/**
* Create a NIP-86 client
*
* @param relayUrl - WebSocket relay URL (wss://...)
* @param sign - Signer function for NIP-98 auth
*/
constructor(
public readonly relayUrl: string,
private sign: SignerFn,
) {
// Convert ws(s):// to http(s)://
this.httpUrl = relayUrl.replace(/^ws/, "http");
}
/**
* Make a NIP-86 API call
*
* @param method - Method name
* @param params - Method parameters
* @returns Result from relay
*/
async call<T>(method: string, params: unknown[] = []): Promise<T> {
const body = JSON.stringify({ method, params });
const authHeader = await createAuthHeader(
{
url: this.httpUrl,
method: "POST",
payload: body,
},
this.sign,
);
const response = await fetch(this.httpUrl, {
method: "POST",
headers: {
"Content-Type": CONTENT_TYPE,
Authorization: authHeader,
},
body,
});
if (response.status === 401) {
throw new Nip86AuthError();
}
if (!response.ok) {
throw new Nip86Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.error) {
throw new Nip86Error(result.error);
}
return result.result;
}
// ─────────────────────────────────────────────────────────────────────────
// Discovery
// ─────────────────────────────────────────────────────────────────────────
/**
* Get list of supported methods
*/
async supportedMethods(): Promise<string[]> {
return this.call<string[]>("supportedmethods");
}
// ─────────────────────────────────────────────────────────────────────────
// Pubkey Management
// ─────────────────────────────────────────────────────────────────────────
/**
* Ban a pubkey from the relay
*/
async banPubkey(pubkey: string, reason?: string): Promise<boolean> {
const params: unknown[] = [pubkey];
if (reason) params.push(reason);
return this.call<boolean>("banpubkey", params);
}
/**
* List all banned pubkeys
*/
async listBannedPubkeys(): Promise<PubkeyEntry[]> {
return this.call<PubkeyEntry[]>("listbannedpubkeys");
}
/**
* Allow a pubkey (for private relays or unban)
*/
async allowPubkey(pubkey: string, reason?: string): Promise<boolean> {
const params: unknown[] = [pubkey];
if (reason) params.push(reason);
return this.call<boolean>("allowpubkey", params);
}
/**
* List all allowed pubkeys
*/
async listAllowedPubkeys(): Promise<PubkeyEntry[]> {
return this.call<PubkeyEntry[]>("listallowedpubkeys");
}
// ─────────────────────────────────────────────────────────────────────────
// Event Moderation
// ─────────────────────────────────────────────────────────────────────────
/**
* List events waiting for moderation approval
*/
async listEventsNeedingModeration(): Promise<ModerationQueueEntry[]> {
return this.call<ModerationQueueEntry[]>("listeventsneedingmoderation");
}
/**
* Approve an event
*/
async allowEvent(eventId: string, reason?: string): Promise<boolean> {
const params: unknown[] = [eventId];
if (reason) params.push(reason);
return this.call<boolean>("allowevent", params);
}
/**
* Ban an event
*/
async banEvent(eventId: string, reason?: string): Promise<boolean> {
const params: unknown[] = [eventId];
if (reason) params.push(reason);
return this.call<boolean>("banevent", params);
}
/**
* List all banned events
*/
async listBannedEvents(): Promise<EventEntry[]> {
return this.call<EventEntry[]>("listbannedevents");
}
// ─────────────────────────────────────────────────────────────────────────
// Relay Metadata
// ─────────────────────────────────────────────────────────────────────────
/**
* Change relay name
*/
async changeRelayName(name: string): Promise<boolean> {
return this.call<boolean>("changerelayname", [name]);
}
/**
* Change relay description
*/
async changeRelayDescription(description: string): Promise<boolean> {
return this.call<boolean>("changerelaydescription", [description]);
}
/**
* Change relay icon URL
*/
async changeRelayIcon(iconUrl: string): Promise<boolean> {
return this.call<boolean>("changerelayicon", [iconUrl]);
}
// ─────────────────────────────────────────────────────────────────────────
// Kind Filtering
// ─────────────────────────────────────────────────────────────────────────
/**
* Allow a kind on the relay
*/
async allowKind(kind: number): Promise<boolean> {
return this.call<boolean>("allowkind", [kind]);
}
/**
* Disallow a kind on the relay
*/
async disallowKind(kind: number): Promise<boolean> {
return this.call<boolean>("disallowkind", [kind]);
}
/**
* List all allowed kinds
*/
async listAllowedKinds(): Promise<number[]> {
return this.call<number[]>("listallowedkinds");
}
// ─────────────────────────────────────────────────────────────────────────
// IP Blocking
// ─────────────────────────────────────────────────────────────────────────
/**
* Block an IP address
*/
async blockIp(ip: string, reason?: string): Promise<boolean> {
const params: unknown[] = [ip];
if (reason) params.push(reason);
return this.call<boolean>("blockip", params);
}
/**
* Unblock an IP address
*/
async unblockIp(ip: string): Promise<boolean> {
return this.call<boolean>("unblockip", [ip]);
}
/**
* List all blocked IPs
*/
async listBlockedIps(): Promise<IpEntry[]> {
return this.call<IpEntry[]>("listblockedips");
}
}
/**
* Method categories for UI organization
*/
export const METHOD_CATEGORIES = {
metadata: ["changerelayname", "changerelaydescription", "changerelayicon"],
moderation: [
"banpubkey",
"listbannedpubkeys",
"allowpubkey",
"listallowedpubkeys",
"listeventsneedingmoderation",
"allowevent",
"banevent",
"listbannedevents",
],
kindFiltering: ["allowkind", "disallowkind", "listallowedkinds"],
ipBlocking: ["blockip", "unblockip", "listblockedips"],
} as const;
/**
* Check if a category has any supported methods
*/
export function categoryHasMethods(
category: keyof typeof METHOD_CATEGORIES,
supportedMethods: string[],
): boolean {
return METHOD_CATEGORIES[category].some((m) => supportedMethods.includes(m));
}

94
src/lib/nip98.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* NIP-98 HTTP Auth
*
* Creates signed authorization events for HTTP requests.
* Used by NIP-86 Relay Management API.
*
* @see https://github.com/nostr-protocol/nips/blob/master/98.md
*/
import type { EventTemplate, NostrEvent } from "nostr-tools/core";
/** HTTP Auth event kind (NIP-98) */
export const HTTP_AUTH_KIND = 27235;
/** Options for creating a NIP-98 auth event */
export interface Nip98Options {
/** The URL being accessed */
url: string;
/** HTTP method (GET, POST, PUT, DELETE) */
method: "GET" | "POST" | "PUT" | "DELETE";
/** Request body (required for NIP-86) - will be hashed */
payload?: string;
}
/** Signer function type */
export type SignerFn = (event: EventTemplate) => Promise<NostrEvent>;
/**
* Compute SHA-256 hash of a string and return as hex
*/
async function sha256Hex(data: string): Promise<string> {
const encoder = new TextEncoder();
const buffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
/**
* Create a NIP-98 HTTP Auth event
*
* @param options - URL, method, and optional payload
* @param sign - Signer function
* @returns Signed auth event
*/
export async function createNip98Event(
options: Nip98Options,
sign: SignerFn,
): Promise<NostrEvent> {
const tags: string[][] = [
["u", options.url],
["method", options.method],
];
// Add payload hash if provided (required for NIP-86)
if (options.payload) {
const hash = await sha256Hex(options.payload);
tags.push(["payload", hash]);
}
const template: EventTemplate = {
kind: HTTP_AUTH_KIND,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
};
return sign(template);
}
/**
* Format a NIP-98 event as an Authorization header value
*
* @param event - Signed NIP-98 event
* @returns Authorization header value (Nostr base64(event))
*/
export function formatAuthHeader(event: NostrEvent): string {
return `Nostr ${btoa(JSON.stringify(event))}`;
}
/**
* Create Authorization header for an HTTP request
*
* @param options - URL, method, and optional payload
* @param sign - Signer function
* @returns Authorization header value
*/
export async function createAuthHeader(
options: Nip98Options,
sign: SignerFn,
): Promise<string> {
const event = await createNip98Event(options, sign);
return formatAuthHeader(event);
}

View File

@@ -0,0 +1,49 @@
/**
* Parser for RELAY ADMIN command
*
* Usage: relay admin <url>
*
* Examples:
* relay admin wss://relay.damus.io
* relay admin relay.primal.net
*/
import { normalizeRelayURL } from "./relay-url";
export interface ParsedRelayAdminCommand {
url: string;
}
/**
* Parse RELAY ADMIN command arguments
*
* @param args - Command arguments (URL only)
* @returns Parsed command with normalized relay URL
* @throws Error if URL is missing or invalid
*/
export function parseRelayAdminCommand(
args: string[],
): ParsedRelayAdminCommand {
if (args.length < 1) {
throw new Error("Usage: relay admin <url>");
}
let url = args[0];
// Auto-add wss:// protocol if not present
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
url = `wss://${url}`;
}
// Validate URL format
try {
const parsedUrl = new URL(url);
if (!parsedUrl.protocol.startsWith("ws")) {
throw new Error("Relay must be a WebSocket URL (ws:// or wss://)");
}
} catch {
throw new Error(`Invalid relay URL: ${url}`);
}
return { url: normalizeRelayURL(url) };
}

View File

@@ -14,6 +14,7 @@ export type AppId =
| "encode"
| "decode"
| "relay"
| "relay-admin"
| "debug"
| "conn"
| "chat"

View File

@@ -4,6 +4,7 @@ import type { AppId } from "./app";
import { parseOpenCommand } from "@/lib/open-parser";
import { parseProfileCommand } from "@/lib/profile-parser";
import { parseRelayCommand } from "@/lib/relay-parser";
import { parseRelayAdminCommand } from "@/lib/relay-admin-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
@@ -484,7 +485,7 @@ export const manPages: Record<string, ManPageEntry> = {
"relay wss://relay.damus.io View relay information",
"relay nos.lol View relay capabilities",
],
seeAlso: ["req", "profile"],
seeAlso: ["req", "profile", "relay admin"],
appId: "relay",
category: "Nostr",
argParser: (args: string[]) => {
@@ -492,6 +493,32 @@ export const manPages: Record<string, ManPageEntry> = {
return parsed;
},
},
"relay admin": {
name: "relay admin",
section: "1",
synopsis: "relay admin <url>",
description:
"Manage a Nostr relay using the NIP-86 Relay Management API. Allows administrators to configure relay metadata, moderate pubkeys and events, manage kind filtering, and block IPs. Requires an active account for admin features (NIP-98 auth). Non-authenticated users can still view relay metadata.",
options: [
{
flag: "<url>",
description:
"Relay WebSocket URL (wss:// or ws://) or domain (auto-adds wss://)",
},
],
examples: [
"relay admin wss://relay.damus.io Open relay admin panel",
"relay admin nos.lol Manage relay settings",
"relay admin my-relay.com Administer your own relay",
],
seeAlso: ["relay", "conn"],
appId: "relay-admin",
category: "Nostr",
argParser: (args: string[]) => {
const parsed = parseRelayAdminCommand(args);
return parsed;
},
},
conn: {
name: "conn",
section: "1",