PolicyTab.tsx raw
1 import { useCallback, useEffect, useMemo, useState } from 'react'
2 import relayAdmin from '@/services/relay-admin.service'
3 import client from '@/services/client.service'
4 import { Button } from '@/components/ui/button'
5 import { toast } from 'sonner'
6 import { nip19 } from 'nostr-tools'
7
8 const EXAMPLE_POLICY = `{
9 "kind": {
10 "whitelist": [0, 1, 3, 6, 7, 10002],
11 "blacklist": []
12 },
13 "global": {
14 "description": "Global rules applied to all events",
15 "size_limit": 65536,
16 "max_age_of_event": 86400,
17 "max_age_event_in_future": 300
18 },
19 "rules": {
20 "1": {
21 "description": "Kind 1 (short text notes)",
22 "content_limit": 8192,
23 "write_allow_follows": true
24 },
25 "30023": {
26 "description": "Long-form articles",
27 "content_limit": 100000,
28 "tag_validation": {
29 "d": "^[a-z0-9-]{1,64}$",
30 "t": "^[a-z0-9-]{1,32}$"
31 }
32 }
33 },
34 "default_policy": "allow",
35 "policy_admins": ["<your-hex-pubkey>"],
36 "policy_follow_whitelist_enabled": true
37 }`
38
39 function npubToHex(input: string): string | null {
40 if (!input) return null
41 if (/^[0-9a-fA-F]{64}$/.test(input)) return input.toLowerCase()
42 if (input.startsWith('npub1')) {
43 try {
44 const { type, data } = nip19.decode(input)
45 if (type === 'npub' && typeof data === 'string') return data
46 } catch {
47 return null
48 }
49 }
50 return null
51 }
52
53 function truncatePubkey(pk: string): string {
54 return `${pk.substring(0, 16)}...${pk.substring(pk.length - 8)}`
55 }
56
57 export default function PolicyTab() {
58 const [policyJson, setPolicyJson] = useState('')
59 const [isLoading, setIsLoading] = useState(false)
60 const [policyEnabled, setPolicyEnabled] = useState(false)
61 const [isPolicyAdmin, setIsPolicyAdmin] = useState(false)
62 const [userRole, setUserRole] = useState('')
63 const [validationErrors, setValidationErrors] = useState<string[]>([])
64 const [policyFollows, setPolicyFollows] = useState<string[]>([])
65 const [newAdminInput, setNewAdminInput] = useState('')
66
67 const isLoggedIn = !!client.pubkey
68
69 const policyAdmins = useMemo(() => {
70 try {
71 if (policyJson) {
72 const parsed = JSON.parse(policyJson)
73 return (parsed.policy_admins || []) as string[]
74 }
75 } catch {
76 // ignore
77 }
78 return [] as string[]
79 }, [policyJson])
80
81 useEffect(() => {
82 relayAdmin.loadPolicyConfig().then((config) => {
83 setPolicyEnabled(!!(config as { enabled?: boolean }).enabled)
84 }).catch(() => setPolicyEnabled(false))
85
86 if (isLoggedIn) {
87 relayAdmin.fetchUserRole().then((role) => {
88 setUserRole(role)
89 // Check if current user is a policy admin by loading policy
90 loadPolicySilent()
91 }).catch(() => setUserRole(''))
92 }
93 // eslint-disable-next-line react-hooks/exhaustive-deps
94 }, [isLoggedIn])
95
96 // After policy loads, check if current user is a policy admin
97 useEffect(() => {
98 if (client.pubkey && policyAdmins.length > 0) {
99 setIsPolicyAdmin(policyAdmins.includes(client.pubkey))
100 }
101 }, [policyAdmins])
102
103 const loadPolicySilent = useCallback(async () => {
104 try {
105 const data = await relayAdmin.loadPolicy()
106 if (data && Object.keys(data).length > 0) {
107 setPolicyJson(JSON.stringify(data, null, 2))
108 }
109 } catch {
110 // silent
111 }
112 }, [])
113
114 const handleLoadPolicy = useCallback(async () => {
115 setIsLoading(true)
116 setValidationErrors([])
117 try {
118 // Try loading via kind 12345 events from relay first
119 const events = await client.fetchEvents(client.currentRelays, {
120 kinds: [12345],
121 limit: 1
122 })
123 if (events && events.length > 0) {
124 let content = events[0].content
125 try {
126 content = JSON.stringify(JSON.parse(content), null, 2)
127 } catch {
128 // keep as-is
129 }
130 setPolicyJson(content)
131 toast.success('Policy loaded from relay event')
132 } else {
133 // Fall back to API
134 const data = await relayAdmin.loadPolicy()
135 if (data && Object.keys(data).length > 0) {
136 setPolicyJson(JSON.stringify(data, null, 2))
137 toast.success('Policy loaded from file')
138 } else {
139 setPolicyJson('')
140 toast.info('No policy configuration found')
141 }
142 }
143 } catch (e) {
144 toast.error(`Error loading policy: ${e instanceof Error ? e.message : String(e)}`)
145 } finally {
146 setIsLoading(false)
147 }
148 }, [])
149
150 const handleValidatePolicy = useCallback((): boolean => {
151 const errors: string[] = []
152
153 if (!policyJson.trim()) {
154 setValidationErrors(['Policy JSON is empty'])
155 toast.error('Validation failed')
156 return false
157 }
158
159 let parsed: Record<string, unknown>
160 try {
161 parsed = JSON.parse(policyJson)
162 } catch (e) {
163 setValidationErrors([`JSON parse error: ${e instanceof Error ? e.message : String(e)}`])
164 toast.error('Invalid JSON syntax')
165 return false
166 }
167
168 if (typeof parsed !== 'object' || parsed === null) {
169 setValidationErrors(['Policy must be a JSON object'])
170 toast.error('Validation failed')
171 return false
172 }
173
174 // Validate policy_admins
175 if (parsed.policy_admins) {
176 if (!Array.isArray(parsed.policy_admins)) {
177 errors.push('policy_admins must be an array')
178 } else {
179 for (const admin of parsed.policy_admins) {
180 if (typeof admin !== 'string' || !/^[0-9a-fA-F]{64}$/.test(admin)) {
181 errors.push(`Invalid policy_admin pubkey: ${admin}`)
182 }
183 }
184 }
185 }
186
187 // Validate rules
188 if (parsed.rules) {
189 if (typeof parsed.rules !== 'object') {
190 errors.push('rules must be an object')
191 } else {
192 for (const [kindStr, rule] of Object.entries(parsed.rules as Record<string, Record<string, unknown>>)) {
193 if (!/^\d+$/.test(kindStr)) {
194 errors.push(`Invalid kind number: ${kindStr}`)
195 }
196 if (rule.tag_validation && typeof rule.tag_validation === 'object') {
197 for (const [tag, pattern] of Object.entries(rule.tag_validation as Record<string, string>)) {
198 try {
199 new RegExp(pattern)
200 } catch {
201 errors.push(`Invalid regex for tag '${tag}': ${pattern}`)
202 }
203 }
204 }
205 }
206 }
207 }
208
209 // Validate default_policy
210 if (parsed.default_policy && !['allow', 'deny'].includes(parsed.default_policy as string)) {
211 errors.push("default_policy must be 'allow' or 'deny'")
212 }
213
214 setValidationErrors(errors)
215 if (errors.length > 0) {
216 toast.error('Validation failed - see errors below')
217 return false
218 }
219
220 toast.success('Validation passed')
221 return true
222 }, [policyJson])
223
224 const handleSavePolicy = useCallback(async () => {
225 const isValid = handleValidatePolicy()
226 if (!isValid) return
227
228 setIsLoading(true)
229 try {
230 if (!client.signer) {
231 toast.error('No signer available. Please log in.')
232 return
233 }
234
235 const event = await client.signer.signEvent({
236 kind: 12345,
237 created_at: Math.floor(Date.now() / 1000),
238 tags: [],
239 content: policyJson
240 })
241
242 await client.publishEvent(client.currentRelays, event)
243 toast.success('Policy updated and published')
244 } catch (e) {
245 toast.error(`Error saving policy: ${e instanceof Error ? e.message : String(e)}`)
246 } finally {
247 setIsLoading(false)
248 }
249 }, [policyJson, handleValidatePolicy])
250
251 const handleFormatJson = useCallback(() => {
252 try {
253 const parsed = JSON.parse(policyJson)
254 setPolicyJson(JSON.stringify(parsed, null, 2))
255 toast.success('JSON formatted')
256 } catch (e) {
257 toast.error(`Cannot format: ${e instanceof Error ? e.message : String(e)}`)
258 }
259 }, [policyJson])
260
261 const handleRefreshFollows = useCallback(async () => {
262 setIsLoading(true)
263 setPolicyFollows([])
264 try {
265 let admins: string[] = []
266 try {
267 const config = JSON.parse(policyJson || '{}')
268 admins = config.policy_admins || []
269 } catch {
270 toast.error('Cannot parse policy JSON to get admins')
271 setIsLoading(false)
272 return
273 }
274
275 if (admins.length === 0) {
276 toast.warning('No policy admins configured')
277 setIsLoading(false)
278 return
279 }
280
281 const events = await client.fetchEvents(client.currentRelays, {
282 kinds: [3],
283 authors: admins,
284 limit: admins.length
285 })
286
287 const followsSet = new Set<string>()
288 for (const event of events) {
289 if (event.tags) {
290 for (const tag of event.tags) {
291 if (tag[0] === 'p' && tag[1] && tag[1].length === 64) {
292 followsSet.add(tag[1])
293 }
294 }
295 }
296 }
297
298 const follows = Array.from(followsSet)
299 setPolicyFollows(follows)
300 toast.success(`Loaded ${follows.length} follows from ${events.length} admin(s)`)
301 } catch (e) {
302 toast.error(`Error loading follows: ${e instanceof Error ? e.message : String(e)}`)
303 } finally {
304 setIsLoading(false)
305 }
306 }, [policyJson])
307
308 const handleAddAdmin = useCallback(() => {
309 const input = newAdminInput.trim()
310 if (!input) {
311 toast.error('Please enter a pubkey')
312 return
313 }
314
315 const hexPubkey = npubToHex(input)
316 if (!hexPubkey || hexPubkey.length !== 64) {
317 toast.error('Invalid pubkey format. Use hex (64 chars) or npub')
318 return
319 }
320
321 try {
322 const config = JSON.parse(policyJson || '{}')
323 if (!config.policy_admins) config.policy_admins = []
324 if (config.policy_admins.includes(hexPubkey)) {
325 toast.warning('Admin already in list')
326 return
327 }
328 config.policy_admins.push(hexPubkey)
329 setPolicyJson(JSON.stringify(config, null, 2))
330 setNewAdminInput('')
331 toast.info("Admin added - click 'Save & Publish' to apply")
332 } catch (e) {
333 toast.error(`Error adding admin: ${e instanceof Error ? e.message : String(e)}`)
334 }
335 }, [newAdminInput, policyJson])
336
337 const handleRemoveAdmin = useCallback(
338 (pubkey: string) => {
339 try {
340 const config = JSON.parse(policyJson || '{}')
341 if (config.policy_admins) {
342 config.policy_admins = config.policy_admins.filter((p: string) => p !== pubkey)
343 setPolicyJson(JSON.stringify(config, null, 2))
344 toast.info("Admin removed - click 'Save & Publish' to apply")
345 }
346 } catch (e) {
347 toast.error(`Error removing admin: ${e instanceof Error ? e.message : String(e)}`)
348 }
349 },
350 [policyJson]
351 )
352
353 // Not logged in
354 if (!isLoggedIn) {
355 return (
356 <div className="p-4 w-full">
357 <h2 className="text-2xl font-semibold mb-4">Policy Configuration</h2>
358 <div className="text-center py-8 rounded-lg border bg-card">
359 <p className="text-muted-foreground">Please log in to access policy configuration.</p>
360 </div>
361 </div>
362 )
363 }
364
365 // Logged in but no permission
366 if (userRole !== 'owner' && !isPolicyAdmin) {
367 return (
368 <div className="p-4 w-full">
369 <h2 className="text-2xl font-semibold mb-4">Policy Configuration</h2>
370 <div className="text-center py-8 rounded-lg border bg-card space-y-2">
371 <p className="text-muted-foreground">
372 Policy configuration requires owner or policy admin permissions.
373 </p>
374 <p className="text-muted-foreground">
375 To become a policy admin, ask an existing policy admin to add your pubkey to the{' '}
376 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">policy_admins</code>{' '}
377 list.
378 </p>
379 <p className="text-sm text-muted-foreground">
380 Current user role: <span className="font-semibold">{userRole || 'none'}</span>
381 </p>
382 </div>
383 </div>
384 )
385 }
386
387 return (
388 <div className="p-4 space-y-4 w-full">
389 <h2 className="text-2xl font-semibold">Policy Configuration</h2>
390
391 {/* Policy Editor Section */}
392 <div className="rounded-lg border bg-card p-4 space-y-3">
393 <div className="flex items-center justify-between flex-wrap gap-2">
394 <h3 className="text-lg font-semibold">Policy Editor</h3>
395 <div className="flex items-center gap-2">
396 <span
397 className={`px-3 py-1 rounded-full text-xs font-semibold ${
398 policyEnabled
399 ? 'bg-green-600 text-white'
400 : 'bg-destructive text-destructive-foreground'
401 }`}
402 >
403 {policyEnabled ? 'Policy Enabled' : 'Policy Disabled'}
404 </span>
405 {isPolicyAdmin && (
406 <span className="px-3 py-1 rounded-full text-xs font-semibold bg-primary text-primary-foreground">
407 Policy Admin
408 </span>
409 )}
410 </div>
411 </div>
412
413 <div className="rounded-md bg-muted/50 border p-3 text-sm space-y-1">
414 <p>
415 Edit the policy JSON below and click "Save & Publish" to update the relay's policy
416 configuration. Changes are applied immediately after validation.
417 </p>
418 <p className="text-muted-foreground text-xs">
419 Policy updates are published as kind 12345 events and require policy admin permissions.
420 </p>
421 </div>
422
423 <textarea
424 className="w-full h-96 rounded-md border bg-background p-3 font-mono text-sm leading-relaxed resize-y focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
425 value={policyJson}
426 onChange={(e) => setPolicyJson(e.target.value)}
427 placeholder="Loading policy configuration..."
428 disabled={isLoading}
429 spellCheck={false}
430 />
431
432 {validationErrors.length > 0 && (
433 <div className="rounded-md bg-destructive/10 border border-destructive p-3">
434 <h4 className="text-sm font-semibold text-destructive mb-1">Validation Errors:</h4>
435 <ul className="list-disc pl-5 text-sm text-destructive space-y-0.5">
436 {validationErrors.map((err, i) => (
437 <li key={i}>{err}</li>
438 ))}
439 </ul>
440 </div>
441 )}
442
443 <div className="flex flex-wrap gap-2">
444 <Button variant="outline" size="sm" onClick={handleLoadPolicy} disabled={isLoading}>
445 Load Current
446 </Button>
447 <Button variant="secondary" size="sm" onClick={handleFormatJson} disabled={isLoading}>
448 Format JSON
449 </Button>
450 <Button
451 variant="outline"
452 size="sm"
453 onClick={() => {
454 handleValidatePolicy()
455 }}
456 disabled={isLoading}
457 className="border-yellow-500/50 text-yellow-500 hover:bg-yellow-500/10"
458 >
459 Validate
460 </Button>
461 <Button size="sm" onClick={handleSavePolicy} disabled={isLoading}>
462 Save & Publish
463 </Button>
464 </div>
465 </div>
466
467 {/* Policy Administrators Section */}
468 <div className="rounded-lg border bg-card p-4 space-y-3">
469 <h3 className="text-lg font-semibold">Policy Administrators</h3>
470
471 <div className="rounded-md bg-muted/50 border p-3 text-sm space-y-1">
472 <p>
473 Policy admins can update the relay's policy configuration via kind 12345 events. Their
474 follows get whitelisted if{' '}
475 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
476 policy_follow_whitelist_enabled
477 </code>{' '}
478 is true in the policy.
479 </p>
480 <p className="text-muted-foreground text-xs">
481 Note: Policy admins are separate from relay admins (ORLY_ADMINS). Changes here update
482 the JSON editor - click "Save & Publish" to apply.
483 </p>
484 </div>
485
486 <div className="space-y-2">
487 {policyAdmins.length === 0 ? (
488 <p className="text-center py-3 text-muted-foreground italic text-sm">
489 No policy admins configured
490 </p>
491 ) : (
492 policyAdmins.map((admin) => (
493 <div
494 key={admin}
495 className="flex items-center justify-between rounded-md border bg-background px-3 py-2"
496 >
497 <span className="font-mono text-sm" title={admin}>
498 {truncatePubkey(admin)}
499 </span>
500 <button
501 onClick={() => handleRemoveAdmin(admin)}
502 disabled={isLoading}
503 title="Remove admin"
504 className="w-6 h-6 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center hover:brightness-90 disabled:opacity-50 disabled:cursor-not-allowed"
505 >
506 X
507 </button>
508 </div>
509 ))
510 )}
511 </div>
512
513 <div className="flex gap-2">
514 <input
515 type="text"
516 placeholder="npub or hex pubkey"
517 value={newAdminInput}
518 onChange={(e) => setNewAdminInput(e.target.value)}
519 onKeyDown={(e) => e.key === 'Enter' && handleAddAdmin()}
520 disabled={isLoading}
521 className="flex-1 rounded-md border bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
522 />
523 <Button
524 size="sm"
525 onClick={handleAddAdmin}
526 disabled={isLoading || !newAdminInput.trim()}
527 >
528 + Add Admin
529 </Button>
530 </div>
531 </div>
532
533 {/* Policy Follow Whitelist Section */}
534 <div className="rounded-lg border bg-card p-4 space-y-3">
535 <h3 className="text-lg font-semibold">Policy Follow Whitelist</h3>
536
537 <div className="rounded-md bg-muted/50 border p-3 text-sm">
538 <p>
539 Pubkeys followed by policy admins (kind 3 events). These get automatic read+write access
540 when rules have{' '}
541 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
542 write_allow_follows: true
543 </code>
544 .
545 </p>
546 </div>
547
548 <div className="flex items-center justify-between">
549 <span className="text-sm font-semibold">
550 {policyFollows.length} pubkey(s) in whitelist
551 </span>
552 <Button variant="outline" size="sm" onClick={handleRefreshFollows} disabled={isLoading}>
553 Refresh Follows
554 </Button>
555 </div>
556
557 <div className="max-h-72 overflow-y-auto rounded-md border bg-background">
558 {policyFollows.length === 0 ? (
559 <p className="text-center py-4 text-sm text-muted-foreground italic">
560 No follows loaded. Click "Refresh Follows" to load from database.
561 </p>
562 ) : (
563 <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 p-3">
564 {policyFollows.map((follow) => (
565 <div
566 key={follow}
567 title={follow}
568 className="px-2 py-1.5 rounded-md border bg-card font-mono text-xs truncate"
569 >
570 {follow.substring(0, 12)}...{follow.substring(follow.length - 6)}
571 </div>
572 ))}
573 </div>
574 )}
575 </div>
576 </div>
577
578 {/* Policy Reference Section */}
579 <div className="rounded-lg border bg-card p-4 space-y-3">
580 <h3 className="text-lg font-semibold">Policy Reference</h3>
581
582 <div className="space-y-3 text-sm">
583 <div>
584 <h4 className="font-semibold mb-1">Structure Overview</h4>
585 <ul className="list-disc pl-5 space-y-0.5 text-muted-foreground">
586 <li>
587 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">kind.whitelist</code>{' '}
588 - Only allow these event kinds (takes precedence)
589 </li>
590 <li>
591 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">kind.blacklist</code>{' '}
592 - Deny these event kinds (if no whitelist)
593 </li>
594 <li>
595 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">global</code> -
596 Rules applied to all events
597 </li>
598 <li>
599 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">rules</code> -
600 Per-kind rules (keyed by kind number as string)
601 </li>
602 <li>
603 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">default_policy</code>{' '}
604 - "allow" or "deny" when no rules match
605 </li>
606 <li>
607 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">policy_admins</code>{' '}
608 - Hex pubkeys that can update policy
609 </li>
610 <li>
611 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
612 policy_follow_whitelist_enabled
613 </code>{' '}
614 - Enable follow-based access
615 </li>
616 </ul>
617 </div>
618
619 <div>
620 <h4 className="font-semibold mb-1">Rule Fields</h4>
621 <ul className="list-disc pl-5 space-y-0.5 text-muted-foreground">
622 <li>
623 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">description</code>{' '}
624 - Human-readable rule description
625 </li>
626 <li>
627 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">write_allow</code>{' '}
628 /{' '}
629 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">write_deny</code>{' '}
630 - Pubkey lists for write access
631 </li>
632 <li>
633 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">read_allow</code>{' '}
634 /{' '}
635 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">read_deny</code>{' '}
636 - Pubkey lists for read access
637 </li>
638 <li>
639 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
640 write_allow_follows
641 </code>{' '}
642 - Grant access to policy admin follows
643 </li>
644 <li>
645 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">size_limit</code>{' '}
646 - Max total event size in bytes
647 </li>
648 <li>
649 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">content_limit</code>{' '}
650 - Max content field size in bytes
651 </li>
652 <li>
653 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">max_expiry</code>{' '}
654 - Max expiry offset in seconds
655 </li>
656 <li>
657 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
658 max_age_of_event
659 </code>{' '}
660 - Max age of created_at in seconds
661 </li>
662 <li>
663 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
664 max_age_event_in_future
665 </code>{' '}
666 - Max future offset in seconds
667 </li>
668 <li>
669 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
670 must_have_tags
671 </code>{' '}
672 - Required tag letters (e.g., ["d", "t"])
673 </li>
674 <li>
675 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">
676 tag_validation
677 </code>{' '}
678 - Regex patterns for tag values
679 </li>
680 <li>
681 <code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">script</code> -
682 Path to external validation script
683 </li>
684 </ul>
685 </div>
686
687 <div>
688 <h4 className="font-semibold mb-1">Example Policy</h4>
689 <pre className="rounded-md border bg-background p-3 font-mono text-xs leading-snug overflow-x-auto whitespace-pre">
690 {EXAMPLE_POLICY}
691 </pre>
692 </div>
693 </div>
694 </div>
695 </div>
696 )
697 }
698