import { useCallback, useEffect, useMemo, useState } from 'react' import relayAdmin from '@/services/relay-admin.service' import client from '@/services/client.service' import { Button } from '@/components/ui/button' import { toast } from 'sonner' import { nip19 } from 'nostr-tools' const EXAMPLE_POLICY = `{ "kind": { "whitelist": [0, 1, 3, 6, 7, 10002], "blacklist": [] }, "global": { "description": "Global rules applied to all events", "size_limit": 65536, "max_age_of_event": 86400, "max_age_event_in_future": 300 }, "rules": { "1": { "description": "Kind 1 (short text notes)", "content_limit": 8192, "write_allow_follows": true }, "30023": { "description": "Long-form articles", "content_limit": 100000, "tag_validation": { "d": "^[a-z0-9-]{1,64}$", "t": "^[a-z0-9-]{1,32}$" } } }, "default_policy": "allow", "policy_admins": [""], "policy_follow_whitelist_enabled": true }` function npubToHex(input: string): string | null { if (!input) return null if (/^[0-9a-fA-F]{64}$/.test(input)) return input.toLowerCase() if (input.startsWith('npub1')) { try { const { type, data } = nip19.decode(input) if (type === 'npub' && typeof data === 'string') return data } catch { return null } } return null } function truncatePubkey(pk: string): string { return `${pk.substring(0, 16)}...${pk.substring(pk.length - 8)}` } export default function PolicyTab() { const [policyJson, setPolicyJson] = useState('') const [isLoading, setIsLoading] = useState(false) const [policyEnabled, setPolicyEnabled] = useState(false) const [isPolicyAdmin, setIsPolicyAdmin] = useState(false) const [userRole, setUserRole] = useState('') const [validationErrors, setValidationErrors] = useState([]) const [policyFollows, setPolicyFollows] = useState([]) const [newAdminInput, setNewAdminInput] = useState('') const isLoggedIn = !!client.pubkey const policyAdmins = useMemo(() => { try { if (policyJson) { const parsed = JSON.parse(policyJson) return (parsed.policy_admins || []) as string[] } } catch { // ignore } return [] as string[] }, [policyJson]) useEffect(() => { relayAdmin.loadPolicyConfig().then((config) => { setPolicyEnabled(!!(config as { enabled?: boolean }).enabled) }).catch(() => setPolicyEnabled(false)) if (isLoggedIn) { relayAdmin.fetchUserRole().then((role) => { setUserRole(role) // Check if current user is a policy admin by loading policy loadPolicySilent() }).catch(() => setUserRole('')) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoggedIn]) // After policy loads, check if current user is a policy admin useEffect(() => { if (client.pubkey && policyAdmins.length > 0) { setIsPolicyAdmin(policyAdmins.includes(client.pubkey)) } }, [policyAdmins]) const loadPolicySilent = useCallback(async () => { try { const data = await relayAdmin.loadPolicy() if (data && Object.keys(data).length > 0) { setPolicyJson(JSON.stringify(data, null, 2)) } } catch { // silent } }, []) const handleLoadPolicy = useCallback(async () => { setIsLoading(true) setValidationErrors([]) try { // Try loading via kind 12345 events from relay first const events = await client.fetchEvents(client.currentRelays, { kinds: [12345], limit: 1 }) if (events && events.length > 0) { let content = events[0].content try { content = JSON.stringify(JSON.parse(content), null, 2) } catch { // keep as-is } setPolicyJson(content) toast.success('Policy loaded from relay event') } else { // Fall back to API const data = await relayAdmin.loadPolicy() if (data && Object.keys(data).length > 0) { setPolicyJson(JSON.stringify(data, null, 2)) toast.success('Policy loaded from file') } else { setPolicyJson('') toast.info('No policy configuration found') } } } catch (e) { toast.error(`Error loading policy: ${e instanceof Error ? e.message : String(e)}`) } finally { setIsLoading(false) } }, []) const handleValidatePolicy = useCallback((): boolean => { const errors: string[] = [] if (!policyJson.trim()) { setValidationErrors(['Policy JSON is empty']) toast.error('Validation failed') return false } let parsed: Record try { parsed = JSON.parse(policyJson) } catch (e) { setValidationErrors([`JSON parse error: ${e instanceof Error ? e.message : String(e)}`]) toast.error('Invalid JSON syntax') return false } if (typeof parsed !== 'object' || parsed === null) { setValidationErrors(['Policy must be a JSON object']) toast.error('Validation failed') return false } // Validate policy_admins if (parsed.policy_admins) { if (!Array.isArray(parsed.policy_admins)) { errors.push('policy_admins must be an array') } else { for (const admin of parsed.policy_admins) { if (typeof admin !== 'string' || !/^[0-9a-fA-F]{64}$/.test(admin)) { errors.push(`Invalid policy_admin pubkey: ${admin}`) } } } } // Validate rules if (parsed.rules) { if (typeof parsed.rules !== 'object') { errors.push('rules must be an object') } else { for (const [kindStr, rule] of Object.entries(parsed.rules as Record>)) { if (!/^\d+$/.test(kindStr)) { errors.push(`Invalid kind number: ${kindStr}`) } if (rule.tag_validation && typeof rule.tag_validation === 'object') { for (const [tag, pattern] of Object.entries(rule.tag_validation as Record)) { try { new RegExp(pattern) } catch { errors.push(`Invalid regex for tag '${tag}': ${pattern}`) } } } } } } // Validate default_policy if (parsed.default_policy && !['allow', 'deny'].includes(parsed.default_policy as string)) { errors.push("default_policy must be 'allow' or 'deny'") } setValidationErrors(errors) if (errors.length > 0) { toast.error('Validation failed - see errors below') return false } toast.success('Validation passed') return true }, [policyJson]) const handleSavePolicy = useCallback(async () => { const isValid = handleValidatePolicy() if (!isValid) return setIsLoading(true) try { if (!client.signer) { toast.error('No signer available. Please log in.') return } const event = await client.signer.signEvent({ kind: 12345, created_at: Math.floor(Date.now() / 1000), tags: [], content: policyJson }) await client.publishEvent(client.currentRelays, event) toast.success('Policy updated and published') } catch (e) { toast.error(`Error saving policy: ${e instanceof Error ? e.message : String(e)}`) } finally { setIsLoading(false) } }, [policyJson, handleValidatePolicy]) const handleFormatJson = useCallback(() => { try { const parsed = JSON.parse(policyJson) setPolicyJson(JSON.stringify(parsed, null, 2)) toast.success('JSON formatted') } catch (e) { toast.error(`Cannot format: ${e instanceof Error ? e.message : String(e)}`) } }, [policyJson]) const handleRefreshFollows = useCallback(async () => { setIsLoading(true) setPolicyFollows([]) try { let admins: string[] = [] try { const config = JSON.parse(policyJson || '{}') admins = config.policy_admins || [] } catch { toast.error('Cannot parse policy JSON to get admins') setIsLoading(false) return } if (admins.length === 0) { toast.warning('No policy admins configured') setIsLoading(false) return } const events = await client.fetchEvents(client.currentRelays, { kinds: [3], authors: admins, limit: admins.length }) const followsSet = new Set() for (const event of events) { if (event.tags) { for (const tag of event.tags) { if (tag[0] === 'p' && tag[1] && tag[1].length === 64) { followsSet.add(tag[1]) } } } } const follows = Array.from(followsSet) setPolicyFollows(follows) toast.success(`Loaded ${follows.length} follows from ${events.length} admin(s)`) } catch (e) { toast.error(`Error loading follows: ${e instanceof Error ? e.message : String(e)}`) } finally { setIsLoading(false) } }, [policyJson]) const handleAddAdmin = useCallback(() => { const input = newAdminInput.trim() if (!input) { toast.error('Please enter a pubkey') return } const hexPubkey = npubToHex(input) if (!hexPubkey || hexPubkey.length !== 64) { toast.error('Invalid pubkey format. Use hex (64 chars) or npub') return } try { const config = JSON.parse(policyJson || '{}') if (!config.policy_admins) config.policy_admins = [] if (config.policy_admins.includes(hexPubkey)) { toast.warning('Admin already in list') return } config.policy_admins.push(hexPubkey) setPolicyJson(JSON.stringify(config, null, 2)) setNewAdminInput('') toast.info("Admin added - click 'Save & Publish' to apply") } catch (e) { toast.error(`Error adding admin: ${e instanceof Error ? e.message : String(e)}`) } }, [newAdminInput, policyJson]) const handleRemoveAdmin = useCallback( (pubkey: string) => { try { const config = JSON.parse(policyJson || '{}') if (config.policy_admins) { config.policy_admins = config.policy_admins.filter((p: string) => p !== pubkey) setPolicyJson(JSON.stringify(config, null, 2)) toast.info("Admin removed - click 'Save & Publish' to apply") } } catch (e) { toast.error(`Error removing admin: ${e instanceof Error ? e.message : String(e)}`) } }, [policyJson] ) // Not logged in if (!isLoggedIn) { return (

Policy Configuration

Please log in to access policy configuration.

) } // Logged in but no permission if (userRole !== 'owner' && !isPolicyAdmin) { return (

Policy Configuration

Policy configuration requires owner or policy admin permissions.

To become a policy admin, ask an existing policy admin to add your pubkey to the{' '} policy_admins{' '} list.

Current user role: {userRole || 'none'}

) } return (

Policy Configuration

{/* Policy Editor Section */}

Policy Editor

{policyEnabled ? 'Policy Enabled' : 'Policy Disabled'} {isPolicyAdmin && ( Policy Admin )}

Edit the policy JSON below and click "Save & Publish" to update the relay's policy configuration. Changes are applied immediately after validation.

Policy updates are published as kind 12345 events and require policy admin permissions.