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