Browse Source

initial draft of hot reload policy

main
mleku 2 months ago
parent
commit
7fedcd24d3
No known key found for this signature in database
  1. 8
      .claude/settings.local.json
  2. 442
      .plan/issue-7-directory-spider.md
  3. 974
      .plan/policy-hot-reload-implementation.md
  4. 12
      CLAUDE.md
  5. 27
      app/handle-event.go
  6. 5
      app/handle-message.go
  7. 312
      app/handle-policy-config.go
  8. 21
      app/handle-req.go
  9. 469
      app/handle_policy_config_test.go
  10. 7
      app/main.go
  11. 376
      app/web/src/App.svelte
  12. 734
      app/web/src/PolicyView.svelte
  13. 86
      docs/POLICY_USAGE_GUIDE.md
  14. 2
      go.mod
  15. 2
      go.sum
  16. 339
      pkg/policy/follows_test.go
  17. 403
      pkg/policy/hotreload_test.go
  18. 366
      pkg/policy/policy.go
  19. 481
      pkg/policy/tag_validation_test.go

8
.claude/settings.local.json

@ -138,7 +138,13 @@ @@ -138,7 +138,13 @@
"Bash(go version:*)",
"Bash(ss:*)",
"Bash(CGO_ENABLED=0 go clean:*)",
"Bash(CGO_ENABLED=0 timeout 30 go test:*)"
"Bash(CGO_ENABLED=0 timeout 30 go test:*)",
"Bash(~/.local/bin/tea issue 6 --repo mleku/next.orly.dev --remote https://git.nostrdev.com)",
"Bash(tea issue:*)",
"Bash(tea issues view:*)",
"Bash(tea issue view:*)",
"Bash(tea issues:*)",
"Bash(bun run build:*)"
],
"deny": [],
"ask": []

442
.plan/issue-7-directory-spider.md

@ -0,0 +1,442 @@ @@ -0,0 +1,442 @@
# Implementation Plan: Directory Spider (Issue #7)
## Overview
Add a new "directory spider" that discovers relays by crawling kind 10002 (relay list) events, expanding outward in hops from whitelisted users, and then fetches essential metadata events (kinds 0, 3, 10000, 10002) from the discovered network.
**Key Characteristics:**
- Runs once per day (configurable)
- Single-threaded, serial operations to minimize load
- 3-hop relay discovery from whitelisted users
- Fetches: kind 0 (profile), 3 (follow list), 10000 (mute list), 10002 (relay list)
---
## Architecture
### New Package Structure
```
pkg/spider/
├── spider.go # Existing follows spider
├── directory.go # NEW: Directory spider implementation
├── directory_test.go # NEW: Tests
└── common.go # NEW: Shared utilities (extract from spider.go)
```
### Core Components
```go
// DirectorySpider manages the daily relay discovery and metadata sync
type DirectorySpider struct {
ctx context.Context
cancel context.CancelFunc
db *database.D
pub publisher.I
// Configuration
interval time.Duration // Default: 24h
maxHops int // Default: 3
// State
running atomic.Bool
lastRun time.Time
// Relay discovery
discoveredRelays map[string]int // URL -> hop distance
processedRelays map[string]bool // Already fetched from
// Callbacks for integration
getSeedPubkeys func() [][]byte // Whitelisted users (from ACL)
}
```
---
## Implementation Phases
### Phase 1: Core Directory Spider Structure
**File:** `pkg/spider/directory.go`
1. **Create DirectorySpider struct** with:
- Context management for cancellation
- Database and publisher references
- Configuration (interval, max hops)
- State tracking (discovered relays, processed relays)
2. **Constructor:** `NewDirectorySpider(ctx, db, pub, interval, maxHops)`
- Initialize maps and state
- Set defaults (24h interval, 3 hops)
3. **Lifecycle methods:**
- `Start()` - Launch main goroutine
- `Stop()` - Cancel context and wait for shutdown
- `TriggerNow()` - Force immediate run (for testing/admin)
### Phase 2: Relay Discovery (3-Hop Expansion)
**Algorithm:**
```
Round 1: Get relay lists from whitelisted users
- Query local DB for kind 10002 events from seed pubkeys
- Extract relay URLs from "r" tags
- Mark as hop 0 relays
Round 2-4 (3 iterations):
- For each relay at current hop level (in serial):
1. Connect to relay
2. Query for ALL kind 10002 events (limit: 5000)
3. Extract new relay URLs
4. Mark as hop N+1 relays
5. Close connection
6. Sleep briefly between relays (rate limiting)
```
**Key Methods:**
```go
// discoverRelays performs the 3-hop relay expansion
func (ds *DirectorySpider) discoverRelays(ctx context.Context) error
// fetchRelayListsFromRelay connects to a relay and fetches kind 10002 events
func (ds *DirectorySpider) fetchRelayListsFromRelay(ctx context.Context, relayURL string) ([]*event.T, error)
// extractRelaysFromEvents parses kind 10002 events and extracts relay URLs
func (ds *DirectorySpider) extractRelaysFromEvents(events []*event.T) []string
```
### Phase 3: Metadata Fetching
After relay discovery, fetch essential metadata from all discovered relays:
**Kinds to fetch:**
- Kind 0: Profile metadata (replaceable)
- Kind 3: Follow lists (replaceable)
- Kind 10000: Mute lists (replaceable)
- Kind 10002: Relay lists (already have many, but get latest)
**Fetch Strategy:**
```go
// fetchMetadataFromRelays iterates through discovered relays serially
func (ds *DirectorySpider) fetchMetadataFromRelays(ctx context.Context) error {
for relayURL := range ds.discoveredRelays {
// Skip if already processed
if ds.processedRelays[relayURL] {
continue
}
// Fetch each kind type
for _, k := range []int{0, 3, 10000, 10002} {
events, err := ds.fetchKindFromRelay(ctx, relayURL, k)
// Store events...
}
ds.processedRelays[relayURL] = true
// Rate limiting sleep
time.Sleep(500 * time.Millisecond)
}
}
```
**Query Filters:**
- For replaceable events (0, 3, 10000, 10002): No time filter, let relay return latest
- Limit per query: 1000-5000 events
- Use pagination if relay supports it
### Phase 4: WebSocket Client for Fetching
**Reuse existing patterns from spider.go:**
```go
// fetchFromRelay handles connection, query, and cleanup
func (ds *DirectorySpider) fetchFromRelay(ctx context.Context, relayURL string, f *filter.F) ([]*event.T, error) {
// Create timeout context (30 seconds per relay)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Connect using ws.Client (from pkg/protocol/ws)
client, err := ws.NewClient(ctx, relayURL)
if err != nil {
return nil, err
}
defer client.Close()
// Subscribe with filter
sub, err := client.Subscribe(ctx, f)
if err != nil {
return nil, err
}
// Collect events until EOSE or timeout
var events []*event.T
for ev := range sub.Events {
events = append(events, ev)
}
return events, nil
}
```
### Phase 5: Event Storage
**Storage Strategy:**
```go
func (ds *DirectorySpider) storeEvents(ctx context.Context, events []*event.T) (saved, duplicates int) {
for _, ev := range events {
_, err := ds.db.SaveEvent(ctx, ev)
if err != nil {
if errors.Is(err, database.ErrDuplicate) {
duplicates++
continue
}
// Log other errors but continue
log.W.F("failed to save event %s: %v", ev.ID.String(), err)
continue
}
saved++
// Publish to active subscribers
ds.pub.Deliver(ev)
}
return
}
```
### Phase 6: Main Loop
```go
func (ds *DirectorySpider) mainLoop() {
// Calculate time until next run
ticker := time.NewTicker(ds.interval)
defer ticker.Stop()
// Run immediately on start
ds.runOnce()
for {
select {
case <-ds.ctx.Done():
return
case <-ticker.C:
ds.runOnce()
}
}
}
func (ds *DirectorySpider) runOnce() {
if !ds.running.CompareAndSwap(false, true) {
log.I.F("directory spider already running, skipping")
return
}
defer ds.running.Store(false)
log.I.F("starting directory spider run")
start := time.Now()
// Reset state
ds.discoveredRelays = make(map[string]int)
ds.processedRelays = make(map[string]bool)
// Phase 1: Discover relays via 3-hop expansion
if err := ds.discoverRelays(ds.ctx); err != nil {
log.E.F("relay discovery failed: %v", err)
return
}
log.I.F("discovered %d relays", len(ds.discoveredRelays))
// Phase 2: Fetch metadata from all relays
if err := ds.fetchMetadataFromRelays(ds.ctx); err != nil {
log.E.F("metadata fetch failed: %v", err)
return
}
ds.lastRun = time.Now()
log.I.F("directory spider completed in %v", time.Since(start))
}
```
### Phase 7: Configuration
**New environment variables:**
```go
// In app/config/config.go
DirectorySpiderEnabled bool `env:"ORLY_DIRECTORY_SPIDER" default:"false" usage:"enable directory spider for metadata sync"`
DirectorySpiderInterval time.Duration `env:"ORLY_DIRECTORY_SPIDER_INTERVAL" default:"24h" usage:"how often to run directory spider"`
DirectorySpiderMaxHops int `env:"ORLY_DIRECTORY_SPIDER_HOPS" default:"3" usage:"maximum hops for relay discovery"`
```
### Phase 8: Integration with app/main.go
```go
// After existing spider initialization
if badgerDB, ok := db.(*database.D); ok && cfg.DirectorySpiderEnabled {
l.directorySpider, err = spider.NewDirectorySpider(
ctx,
badgerDB,
l.publishers,
cfg.DirectorySpiderInterval,
cfg.DirectorySpiderMaxHops,
)
if err != nil {
return nil, fmt.Errorf("failed to create directory spider: %w", err)
}
// Set callback to get seed pubkeys from ACL
l.directorySpider.SetSeedCallback(func() [][]byte {
// Get whitelisted users from all ACLs
var pubkeys [][]byte
for _, aclInstance := range acl.Registry.ACL {
if follows, ok := aclInstance.(*acl.Follows); ok {
pubkeys = append(pubkeys, follows.GetFollowedPubkeys()...)
}
}
return pubkeys
})
l.directorySpider.Start()
}
```
---
## Self-Relay Detection
Reuse the existing `isSelfRelay()` pattern from spider.go:
```go
func (ds *DirectorySpider) isSelfRelay(relayURL string) bool {
// Use NIP-11 to get relay pubkey
// Compare against our relay identity pubkey
// Cache results to avoid repeated requests
}
```
---
## Error Handling & Resilience
1. **Connection Timeouts:** 30 seconds per relay
2. **Query Timeouts:** 60 seconds per query
3. **Graceful Degradation:** Continue to next relay on failure
4. **Rate Limiting:** 500ms sleep between relays
5. **Memory Limits:** Process events in batches of 1000
6. **Context Cancellation:** Check at each step for shutdown
---
## Testing Strategy
### Unit Tests
```go
// pkg/spider/directory_test.go
func TestExtractRelaysFromEvents(t *testing.T)
func TestDiscoveryHopTracking(t *testing.T)
func TestSelfRelayFiltering(t *testing.T)
```
### Integration Tests
```go
func TestDirectorySpiderE2E(t *testing.T) {
// Start test relay
// Populate with kind 10002 events
// Run directory spider
// Verify events fetched and stored
}
```
---
## Logging
Use existing `lol.mleku.dev` logging patterns:
```go
log.I.F("directory spider: starting relay discovery")
log.D.F("directory spider: hop %d, discovered %d new relays", hop, count)
log.W.F("directory spider: failed to connect to %s: %v", url, err)
log.E.F("directory spider: critical error: %v", err)
```
---
## Implementation Order
1. **Phase 1:** Core struct and lifecycle (1-2 hours)
2. **Phase 2:** Relay discovery with hop expansion (2-3 hours)
3. **Phase 3:** Metadata fetching (1-2 hours)
4. **Phase 4:** WebSocket client integration (1 hour)
5. **Phase 5:** Event storage (30 min)
6. **Phase 6:** Main loop and scheduling (1 hour)
7. **Phase 7:** Configuration (30 min)
8. **Phase 8:** Integration with main.go (30 min)
9. **Testing:** Unit and integration tests (2-3 hours)
**Total Estimate:** 10-14 hours
---
## Future Enhancements (Out of Scope)
- Web UI status page for directory spider
- Metrics/stats collection (relays discovered, events fetched)
- Configurable kind list to fetch
- Priority ordering of relays (closer hops first)
- Persistent relay discovery cache between runs
---
## Dependencies
**Existing packages to use:**
- `pkg/protocol/ws` - WebSocket client
- `pkg/database` - Event storage
- `pkg/encoders/filter` - Query filter construction
- `pkg/acl` - Get whitelisted users
- `pkg/sync` - NIP-11 cache for self-detection (if needed)
**No new external dependencies required.**
---
## Follow-up Items (Post-Implementation)
### TODO: Verify Connection Behavior is Not Overly Aggressive
**Issue:** The current implementation creates a **new WebSocket connection for each kind query** when fetching metadata. For each relay, this means:
1. Connect → fetch kind 0 → disconnect
2. Connect → fetch kind 3 → disconnect
3. Connect → fetch kind 10000 → disconnect
4. Connect → fetch kind 10002 → disconnect
This could be seen as aggressive by remote relays and may trigger rate limiting or IP bans.
**Verification needed:**
- [ ] Monitor logs with `ORLY_LOG_LEVEL=debug` to see per-kind fetch results
- [ ] Check if relays are returning events for all 4 kinds or just kind 0
- [ ] Look for WARNING logs about connection failures or rate limiting
- [ ] Verify the 500ms delay between relays is sufficient
**Potential optimization (if needed):**
- Refactor `fetchMetadataFromRelays()` to use a single connection per relay
- Fetch all 4 kinds using multiple subscriptions on one connection
- Example pattern:
```go
client, err := ws.RelayConnect(ctx, relayURL)
defer client.Close()
for _, k := range kindsToFetch {
events, _ := fetchKindOnConnection(client, k)
// ...
}
```
**Priority:** Medium - only optimize if monitoring shows issues with the current approach

974
.plan/policy-hot-reload-implementation.md

@ -0,0 +1,974 @@ @@ -0,0 +1,974 @@
# Implementation Plan: Policy Hot Reload, Follow List Whitelisting, and Web UI
**Issue:** https://git.nostrdev.com/mleku/next.orly.dev/issues/6
## Overview
This plan implements three interconnected features for ORLY's policy system:
1. **Dynamic Policy Configuration** via kind 12345 events (hot reload)
2. **Administrator Follow List Whitelisting** within the policy system
3. **Web Interface** for policy management with JSON editing
## Architecture Summary
### Current System Analysis
**Policy System** ([pkg/policy/policy.go](pkg/policy/policy.go)):
- Policy loaded from `~/.config/ORLY/policy.json` at startup
- `P` struct with unexported `rules` field (map[int]Rule)
- `PolicyManager` manages script runners for external policy scripts
- `LoadFromFile()` method exists for loading policy from disk
- No hot reload mechanism currently exists
**ACL System** ([pkg/acl/follows.go](pkg/acl/follows.go)):
- Separate from policy system
- Manages admin/owner/follows lists for write access control
- Fetches kind 3 events from relays
- Has callback mechanism for updates
**Event Handling** ([app/handle-event.go](app/handle-event.go)):213-226
- Special handling for NIP-43 events (join/leave requests)
- Pattern: Check kind early, process, return early
**Web UI**:
- Svelte-based component architecture
- Tab-based navigation in [app/web/src/App.svelte](app/web/src/App.svelte)
- API endpoints follow `/api/<feature>/<action>` pattern
## Feature 1: Dynamic Policy Configuration (Kind 12345)
### Design
**Event Kind:** 12345 (Relay Policy Configuration)
**Purpose:** Allow admins/owners to update policy configuration via Nostr event
**Security:** Only admins/owners can publish; only visible to admins/owners
**Process Flow:**
1. Admin/owner creates kind 12345 event with JSON policy in `content` field
2. Relay receives event via WebSocket
3. Validate sender is admin/owner
4. Pause policy manager (stop script runners)
5. Parse and validate JSON configuration
6. Apply new policy configuration
7. Persist to `~/.config/ORLY/policy.json`
8. Resume policy manager (restart script runners)
9. Send OK response
### Implementation Steps
#### Step 1.1: Define Kind Constant
**File:** Create `pkg/protocol/policyconfig/policyconfig.go`
```go
package policyconfig
const (
// KindPolicyConfig is a relay-internal event for policy configuration updates
// Only visible to admins and owners
KindPolicyConfig uint16 = 12345
)
```
#### Step 1.2: Add Policy Hot Reload Methods
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)
Add methods to `P` struct:
```go
// Reload loads policy from JSON bytes and applies it to the existing policy instance
// This pauses the policy manager, updates configuration, and resumes
func (p *P) Reload(policyJSON []byte) error
// Pause pauses the policy manager and stops all script runners
func (p *P) Pause() error
// Resume resumes the policy manager and restarts script runners
func (p *P) Resume() error
// SaveToFile persists the current policy configuration to disk
func (p *P) SaveToFile(configPath string) error
```
**Implementation Details:**
- `Reload()` should:
- Call `Pause()` to stop all script runners
- Unmarshal JSON into policy struct (using shadow struct pattern)
- Validate configuration
- Populate binary caches
- Call `SaveToFile()` to persist
- Call `Resume()` to restart scripts
- Return error if any step fails
- `Pause()` should:
- Iterate through `p.manager.runners` map
- Call `Stop()` on each runner
- Set a paused flag on the manager
- `Resume()` should:
- Clear paused flag
- Call `startPolicyIfExists()` to restart default script
- Restart any rule-specific scripts that were running
- `SaveToFile()` should:
- Marshal policy to JSON (using pJSON shadow struct)
- Write atomically to config path (write to temp file, then rename)
#### Step 1.3: Handle Kind 12345 Events
**File:** [app/handle-event.go](app/handle-event.go)
Add handling after NIP-43 special events (after line 226):
```go
// Handle policy configuration update events (kind 12345)
case policyconfig.KindPolicyConfig:
// Process policy config update and return early
if err = l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
log.E.F("failed to process policy config update: %v", err)
if err = Ok.Error(l, env, err.Error()); chk.E(err) {
return
}
return
}
// Send OK response
if err = Ok.Ok(l, env, "policy configuration updated"); chk.E(err) {
return
}
return
```
Create new file: `app/handle-policy-config.go`
```go
// HandlePolicyConfigUpdate processes kind 12345 policy configuration events
// Only admins and owners can update policy configuration
func (l *Listener) HandlePolicyConfigUpdate(ev *event.E) error {
// 1. Verify sender is admin or owner
// 2. Parse JSON from event content
// 3. Validate JSON structure
// 4. Call l.policyManager.Reload(jsonBytes)
// 5. Log success/failure
return nil
}
```
**Security Checks:**
- Verify `ev.Pubkey` is in admins or owners list
- Validate JSON syntax before applying
- Catch all errors and return descriptive messages
- Log all policy update attempts (success and failure)
#### Step 1.4: Query Filtering (Optional)
**File:** [app/handle-req.go](app/handle-req.go)
Add filter to hide kind 12345 from non-admins:
```go
// In handleREQ, after ACL checks:
// Filter out policy config events (kind 12345) for non-admin users
if !isAdminOrOwner(l.authedPubkey.Load(), l.Admins, l.Owners) {
// Remove kind 12345 from filter
for _, f := range filters {
f.Kinds.Remove(policyconfig.KindPolicyConfig)
}
}
```
## Feature 2: Administrator Follow List Whitelisting
### Design
**Purpose:** Enable policy-based follow list whitelisting (separate from ACL follows)
**Use Case:** Policy admins can designate follows who get special policy privileges
**Configuration:**
```json
{
"policy_admins": ["admin_pubkey_hex_1", "admin_pubkey_hex_2"],
"policy_follow_whitelist_enabled": true,
"rules": {
"1": {
"write_allow_follows": true // Allow writes from policy admin follows
}
}
}
```
### Implementation Steps
#### Step 2.1: Extend Policy Configuration Structure
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)
Extend `P` struct:
```go
type P struct {
Kind Kinds `json:"kind"`
rules map[int]Rule
Global Rule `json:"global"`
DefaultPolicy string `json:"default_policy"`
// New fields for follow list whitelisting
PolicyAdmins []string `json:"policy_admins,omitempty"`
PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"`
// Unexported cached data
policyAdminsBin [][]byte // Binary cache for admin pubkeys
policyFollows [][]byte // Cached follow list from policy admins
policyFollowsMx sync.RWMutex // Protect follows list
manager *PolicyManager
}
```
Extend `Rule` struct:
```go
type Rule struct {
// ... existing fields ...
// New field for follow-based whitelisting
WriteAllowFollows bool `json:"write_allow_follows,omitempty"`
ReadAllowFollows bool `json:"read_allow_follows,omitempty"`
}
```
Update `pJSON` shadow struct to include new fields.
#### Step 2.2: Add Follow List Fetching
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)
Add methods:
```go
// FetchPolicyFollows fetches follow lists (kind 3) from database for policy admins
// This is called during policy load and can be called periodically
func (p *P) FetchPolicyFollows(db database.D) error {
p.policyFollowsMx.Lock()
defer p.policyFollowsMx.Unlock()
// Clear existing follows
p.policyFollows = nil
// For each policy admin, query kind 3 events
for _, adminPubkey := range p.policyAdminsBin {
// Build filter for kind 3 from this admin
// Query database for latest kind 3 event
// Extract p-tags from event
// Add to p.policyFollows list
}
return nil
}
// IsPolicyFollow checks if pubkey is in policy admin follows
func (p *P) IsPolicyFollow(pubkey []byte) bool {
p.policyFollowsMx.RLock()
defer p.policyFollowsMx.RUnlock()
for _, follow := range p.policyFollows {
if utils.FastEqual(pubkey, follow) {
return true
}
}
return false
}
```
#### Step 2.3: Integrate Follow Checking in Policy Rules
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)
Update `checkRulePolicy()` method (around line 1062):
```go
// In write access checks, after checking write_allow list:
if access == "write" {
// Check if follow-based whitelisting is enabled for this rule
if rule.WriteAllowFollows && p.PolicyFollowWhitelistEnabled {
if p.IsPolicyFollow(loggedInPubkey) {
return true, nil // Allow write from policy admin follow
}
}
// Continue with existing write_allow checks...
}
// Similar for read access:
if access == "read" {
if rule.ReadAllowFollows && p.PolicyFollowWhitelistEnabled {
if p.IsPolicyFollow(loggedInPubkey) {
return true, nil // Allow read from policy admin follow
}
}
// Continue with existing read_allow checks...
}
```
#### Step 2.4: Periodic Follow List Refresh
**File:** [pkg/policy/policy.go](pkg/policy/policy.go)
Add to `NewWithManager()`:
```go
// Start periodic follow list refresh for policy admins
if len(policy.PolicyAdmins) > 0 && policy.PolicyFollowWhitelistEnabled {
go policy.startPeriodicFollowRefresh(ctx)
}
```
Add method:
```go
// startPeriodicFollowRefresh periodically fetches policy admin follow lists
func (p *P) startPeriodicFollowRefresh(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute) // Refresh every 15 minutes
defer ticker.Stop()
// Fetch immediately on startup
if err := p.FetchPolicyFollows(p.db); err != nil {
log.E.F("failed to fetch policy follows: %v", err)
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := p.FetchPolicyFollows(p.db); err != nil {
log.E.F("failed to fetch policy follows: %v", err)
} else {
log.I.F("refreshed policy admin follow lists")
}
}
}
}
```
**Note:** Need to pass database reference to policy manager. Update `NewWithManager()` signature:
```go
func NewWithManager(ctx context.Context, appName string, enabled bool, db *database.D) *P
```
## Feature 3: Web Interface for Policy Management
### Design
**Components:**
1. `PolicyView.svelte` - Main policy management UI
2. API endpoints for policy CRUD operations
3. JSON editor with validation
4. Follow list viewer
**UI Features:**
- View current policy configuration (read-only JSON display)
- Edit policy JSON with syntax highlighting
- Validate JSON before publishing
- Publish kind 12345 event to update policy
- View policy admin pubkeys
- View follow lists for each policy admin
- Add/remove policy admin pubkeys (updates and republishes config)
### Implementation Steps
#### Step 3.1: Create Policy View Component
**File:** `app/web/src/PolicyView.svelte`
Structure:
```svelte
<script>
export let isLoggedIn = false;
export let userRole = "";
export let policyConfig = null;
export let policyAdmins = [];
export let policyFollows = [];
export let isLoadingPolicy = false;
export let policyMessage = "";
export let policyMessageType = "info";
export let policyEditJson = "";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
// Event handlers
function loadPolicy() { dispatch("loadPolicy"); }
function savePolicy() { dispatch("savePolicy"); }
function validatePolicy() { dispatch("validatePolicy"); }
function addPolicyAdmin() { dispatch("addPolicyAdmin"); }
function removePolicyAdmin(pubkey) { dispatch("removePolicyAdmin", pubkey); }
function refreshFollows() { dispatch("refreshFollows"); }
</script>
<div class="policy-view">
<h2>Policy Configuration Management</h2>
{#if isLoggedIn && (userRole === "owner" || userRole === "admin")}
<!-- Policy JSON Editor Section -->
<div class="policy-section">
<h3>Policy Configuration</h3>
<div class="policy-controls">
<button on:click={loadPolicy}>🔄 Reload</button>
<button on:click={validatePolicy}>✓ Validate</button>
<button on:click={savePolicy}>📤 Publish Update</button>
</div>
<textarea
class="policy-editor"
bind:value={policyEditJson}
spellcheck="false"
placeholder="Policy JSON configuration..."
/>
</div>
<!-- Policy Admins Section -->
<div class="policy-admins-section">
<h3>Policy Administrators</h3>
<p class="section-description">
Policy admins can update configuration and their follows get whitelisted
(if policy_follow_whitelist_enabled is true)
</p>
<div class="admin-list">
{#each policyAdmins as admin}
<div class="admin-item">
<span class="admin-pubkey">{admin}</span>
<button
class="remove-btn"
on:click={() => removePolicyAdmin(admin)}
>
Remove
</button>
</div>
{/each}
</div>
<div class="add-admin">
<input
type="text"
placeholder="npub or hex pubkey"
id="new-admin-input"
/>
<button on:click={addPolicyAdmin}>Add Admin</button>
</div>
</div>
<!-- Follow List Section -->
<div class="policy-follows-section">
<h3>Policy Follow Whitelist</h3>
<button on:click={refreshFollows}>🔄 Refresh Follows</button>
<div class="follows-list">
{#if policyFollows.length === 0}
<p class="no-follows">No follows loaded</p>
{:else}
<p class="follows-count">
{policyFollows.length} pubkey(s) in whitelist
</p>
<div class="follows-grid">
{#each policyFollows as follow}
<div class="follow-item">{follow}</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Message Display -->
{#if policyMessage}
<div class="policy-message {policyMessageType}">
{policyMessage}
</div>
{/if}
{:else}
<div class="access-denied">
<p>Policy management is only available to relay administrators and owners.</p>
{#if !isLoggedIn}
<button on:click={() => dispatch("openLoginModal")}>
Login
</button>
{/if}
</div>
{/if}
</div>
<style>
/* Policy-specific styling */
.policy-view { /* ... */ }
.policy-editor {
width: 100%;
min-height: 400px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
padding: 1em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--code-bg);
color: var(--code-text);
}
/* ... more styles ... */
</style>
```
#### Step 3.2: Add Policy Tab to Main App
**File:** [app/web/src/App.svelte](app/web/src/App.svelte)
Add state variables (around line 94):
```javascript
// Policy management state
let policyConfig = null;
let policyAdmins = [];
let policyFollows = [];
let isLoadingPolicy = false;
let policyMessage = "";
let policyMessageType = "info";
let policyEditJson = "";
```
Add tab definition in `tabs` array (look for export/import/sprocket tabs):
```javascript
if (isLoggedIn && (userRole === "owner" || userRole === "admin")) {
tabs.push({
id: "policy",
label: "Policy",
icon: "🛡",
isSearchTab: false
});
}
```
Add component import:
```javascript
import PolicyView from "./PolicyView.svelte";
```
Add view in main content area (look for {#if selectedTab === "sprocket"}):
```svelte
{:else if selectedTab === "policy"}
<PolicyView
{isLoggedIn}
{userRole}
{policyConfig}
{policyAdmins}
{policyFollows}
{isLoadingPolicy}
{policyMessage}
{policyMessageType}
bind:policyEditJson
on:loadPolicy={handleLoadPolicy}
on:savePolicy={handleSavePolicy}
on:validatePolicy={handleValidatePolicy}
on:addPolicyAdmin={handleAddPolicyAdmin}
on:removePolicyAdmin={handleRemovePolicyAdmin}
on:refreshFollows={handleRefreshFollows}
on:openLoginModal={() => (showLoginModal = true)}
/>
```
Add event handlers:
```javascript
async function handleLoadPolicy() {
isLoadingPolicy = true;
policyMessage = "";
try {
const response = await fetch("/api/policy/config", {
credentials: "include"
});
if (!response.ok) {
throw new Error(`Failed to load policy: ${response.statusText}`);
}
const data = await response.json();
policyConfig = data.config;
policyEditJson = JSON.stringify(data.config, null, 2);
policyAdmins = data.config.policy_admins || [];
policyMessage = "Policy loaded successfully";
policyMessageType = "success";
} catch (error) {
policyMessage = `Error loading policy: ${error.message}`;
policyMessageType = "error";
console.error("Error loading policy:", error);
} finally {
isLoadingPolicy = false;
}
}
async function handleSavePolicy() {
isLoadingPolicy = true;
policyMessage = "";
try {
// Validate JSON first
const config = JSON.parse(policyEditJson);
// Publish kind 12345 event via websocket with auth
const event = {
kind: 12345,
content: policyEditJson,
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
const result = await publishEventWithAuth(event, userSigner);
if (result.success) {
policyMessage = "Policy updated successfully";
policyMessageType = "success";
// Reload to get updated config
await handleLoadPolicy();
} else {
throw new Error(result.message || "Failed to publish policy update");
}
} catch (error) {
policyMessage = `Error updating policy: ${error.message}`;
policyMessageType = "error";
console.error("Error updating policy:", error);
} finally {
isLoadingPolicy = false;
}
}
function handleValidatePolicy() {
try {
JSON.parse(policyEditJson);
policyMessage = "Policy JSON is valid ✓";
policyMessageType = "success";
} catch (error) {
policyMessage = `Invalid JSON: ${error.message}`;
policyMessageType = "error";
}
}
async function handleRefreshFollows() {
isLoadingPolicy = true;
policyMessage = "";
try {
const response = await fetch("/api/policy/follows", {
credentials: "include"
});
if (!response.ok) {
throw new Error(`Failed to load follows: ${response.statusText}`);
}
const data = await response.json();
policyFollows = data.follows || [];
policyMessage = `Loaded ${policyFollows.length} follows`;
policyMessageType = "success";
} catch (error) {
policyMessage = `Error loading follows: ${error.message}`;
policyMessageType = "error";
console.error("Error loading follows:", error);
} finally {
isLoadingPolicy = false;
}
}
async function handleAddPolicyAdmin(event) {
// Get input value
const input = document.getElementById("new-admin-input");
const pubkey = input.value.trim();
if (!pubkey) {
policyMessage = "Please enter a pubkey";
policyMessageType = "error";
return;
}
try {
// Convert npub to hex if needed (implement or use nostr library)
// Add to policy_admins array in config
const config = JSON.parse(policyEditJson);
if (!config.policy_admins) {
config.policy_admins = [];
}
if (!config.policy_admins.includes(pubkey)) {
config.policy_admins.push(pubkey);
policyEditJson = JSON.stringify(config, null, 2);
input.value = "";
policyMessage = "Admin added (click Publish to save)";
policyMessageType = "info";
} else {
policyMessage = "Admin already in list";
policyMessageType = "warning";
}
} catch (error) {
policyMessage = `Error adding admin: ${error.message}`;
policyMessageType = "error";
}
}
async function handleRemovePolicyAdmin(event) {
const pubkey = event.detail;
try {
const config = JSON.parse(policyEditJson);
if (config.policy_admins) {
config.policy_admins = config.policy_admins.filter(p => p !== pubkey);
policyEditJson = JSON.stringify(config, null, 2);
policyMessage = "Admin removed (click Publish to save)";
policyMessageType = "info";
}
} catch (error) {
policyMessage = `Error removing admin: ${error.message}`;
policyMessageType = "error";
}
}
```
#### Step 3.3: Add API Endpoints
**File:** [app/server.go](app/server.go)
Add to route registration (around line 245):
```go
// Policy management endpoints (admin/owner only)
s.mux.HandleFunc("/api/policy/config", s.handlePolicyConfig)
s.mux.HandleFunc("/api/policy/follows", s.handlePolicyFollows)
```
Create new file: `app/handle-policy-api.go`
```go
package app
import (
"encoding/json"
"net/http"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/hex"
)
// handlePolicyConfig returns the current policy configuration
// GET /api/policy/config
func (s *Server) handlePolicyConfig(w http.ResponseWriter, r *http.Request) {
// Verify authentication
session, err := s.getSession(r)
if err != nil || session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify user is admin or owner
role := s.getUserRole(session.Pubkey)
if role != "admin" && role != "owner" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get current policy configuration from policy manager
// This requires adding a method to get the raw config
config := s.policyManager.GetConfig() // Need to implement this
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"config": config,
})
}
// handlePolicyFollows returns the policy admin follow lists
// GET /api/policy/follows
func (s *Server) handlePolicyFollows(w http.ResponseWriter, r *http.Request) {
// Verify authentication
session, err := s.getSession(r)
if err != nil || session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify user is admin or owner
role := s.getUserRole(session.Pubkey)
if role != "admin" && role != "owner" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get policy follows from policy manager
follows := s.policyManager.GetPolicyFollows() // Need to implement this
// Convert to hex strings for JSON response
followsHex := make([]string, len(follows))
for i, f := range follows {
followsHex[i] = hex.Enc(f)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"follows": followsHex,
})
}
```
**Note:** Need to add getter methods to policy manager:
```go
// GetConfig returns the current policy configuration as a map
// File: pkg/policy/policy.go
func (p *P) GetConfig() map[string]interface{} {
// Marshal to JSON and back to get map representation
jsonBytes, _ := json.Marshal(p)
var config map[string]interface{}
json.Unmarshal(jsonBytes, &config)
return config
}
// GetPolicyFollows returns the current policy follow list
func (p *P) GetPolicyFollows() [][]byte {
p.policyFollowsMx.RLock()
defer p.policyFollowsMx.RUnlock()
follows := make([][]byte, len(p.policyFollows))
copy(follows, p.policyFollows)
return follows
}
```
## Testing Strategy
### Unit Tests
1. **Policy Reload Tests** (`pkg/policy/policy_test.go`):
- Test `Reload()` with valid JSON
- Test `Reload()` with invalid JSON
- Test `Pause()` and `Resume()` functionality
- Test `SaveToFile()` atomic write
2. **Follow List Tests** (`pkg/policy/follows_test.go`):
- Test `FetchPolicyFollows()` with mock database
- Test `IsPolicyFollow()` with various inputs
- Test follow list caching and expiry
3. **Handler Tests** (`app/handle-policy-config_test.go`):
- Test kind 12345 handling with admin pubkey
- Test kind 12345 rejection from non-admin
- Test JSON validation errors
### Integration Tests
1. **End-to-End Policy Update**:
- Publish kind 12345 event as admin
- Verify policy reloaded
- Verify new policy enforced
- Verify policy persisted to disk
2. **Follow Whitelist E2E**:
- Configure policy with follow whitelist enabled
- Add admin pubkey to policy_admins
- Publish kind 3 follow list for admin
- Verify follows can write/read per policy rules
3. **Web UI E2E**:
- Load policy via API
- Edit and publish via UI
- Verify changes applied
- Check follow list display
## Security Considerations
1. **Authorization**:
- Only admins/owners can publish kind 12345
- Only admins/owners can access policy API endpoints
- Policy events only visible to admins/owners in queries
2. **Validation**:
- Strict JSON schema validation before applying
- Rollback mechanism if policy fails to load
- Catch all parsing errors
3. **Audit Trail**:
- Log all policy update attempts
- Store kind 12345 events in database for audit
- Include who changed what and when
4. **Atomic Operations**:
- Pause-update-resume must be atomic
- File writes must be atomic (temp file + rename)
- No partial updates on failure
## Migration Path
### Phase 1: Backend Foundation
1. Implement kind 12345 constant
2. Add policy reload methods
3. Add follow list support to policy
4. Test hot reload mechanism
### Phase 2: Event Handling
1. Add kind 12345 handler
2. Add API endpoints
3. Test event flow end-to-end
### Phase 3: Web UI
1. Create PolicyView component
2. Integrate into App.svelte
3. Add JSON editor
4. Test user workflows
### Phase 4: Testing & Documentation
1. Write comprehensive tests
2. Update CLAUDE.md
3. Create user documentation
4. Add examples to docs/
## Open Questions / Decisions Needed
1. **Policy Admin vs Relay Admin**:
- Should policy_admins be separate from ORLY_ADMINS?
- **Recommendation:** Yes, separate. Policy admins manage policy, relay admins manage relay.
2. **Follow List Refresh Frequency**:
- How often to refresh policy admin follows?
- **Recommendation:** 15 minutes (configurable via ORLY_POLICY_FOLLOW_REFRESH)
3. **Backward Compatibility**:
- What happens to relays without policy_admins field?
- **Recommendation:** Fall back to empty list, disabled by default
4. **Database Reference in Policy**:
- Policy needs database reference for follow queries
- **Recommendation:** Pass database to NewWithManager()
5. **Error Handling on Reload Failure**:
- Should failed reload keep old policy or disable policy?
- **Recommendation:** Keep old policy, log error, return error to client
## Success Criteria
1. ✅ Admin can publish kind 12345 event with new policy JSON
2. ✅ Relay receives event, validates sender, reloads policy without restart
3. ✅ Policy persisted to `~/.config/ORLY/policy.json`
4. ✅ Script runners paused during reload, resumed after
5. ✅ Policy admins can be configured in policy JSON
6. ✅ Policy admin follow lists fetched from database
7. ✅ Follow-based whitelisting enforced in policy rules
8. ✅ Web UI displays current policy configuration
9. ✅ Web UI allows editing and validation of policy JSON
10. ✅ Web UI shows policy admin follows
11. ✅ Only admins/owners can access policy management
12. ✅ All tests pass
13. ✅ Documentation updated
## Estimated Effort
- **Backend (Policy + Event Handling):** 8-12 hours
- Policy reload methods: 3-4 hours
- Follow list support: 3-4 hours
- Event handling: 2-3 hours
- Testing: 2-3 hours
- **API Endpoints:** 2-3 hours
- Route setup: 1 hour
- Handler implementation: 1-2 hours
- Testing: 1 hour
- **Web UI:** 6-8 hours
- PolicyView component: 3-4 hours
- App integration: 2-3 hours
- Styling and UX: 2-3 hours
- Testing: 2 hours
- **Documentation & Testing:** 4-6 hours
- Unit tests: 2-3 hours
- Integration tests: 2-3 hours
- Documentation: 2 hours
**Total:** 20-29 hours
## Dependencies
- No external dependencies required
- Uses existing ORLY infrastructure
- Compatible with current policy system
## Next Steps
1. Review and approve this plan
2. Clarify open questions/decisions
3. Begin implementation in phases
4. Iterative testing and refinement

12
CLAUDE.md

@ -165,6 +165,7 @@ export ORLY_DB_INDEX_CACHE_MB=256 # Index cache size @@ -165,6 +165,7 @@ export ORLY_DB_INDEX_CACHE_MB=256 # Index cache size
**`app/`** - HTTP/WebSocket server and handlers
- `server.go` - Main Server struct and HTTP request routing
- `handle-*.go` - Nostr protocol message handlers (EVENT, REQ, COUNT, CLOSE, AUTH, DELETE)
- `handle-policy-config.go` - Kind 12345 policy updates and kind 3 admin follow list handling
- `handle-websocket.go` - WebSocket connection lifecycle and frame handling
- `listener.go` - Network listener setup
- `sprocket.go` - External event processing script manager
@ -222,6 +223,14 @@ export ORLY_DB_INDEX_CACHE_MB=256 # Index cache size @@ -222,6 +223,14 @@ export ORLY_DB_INDEX_CACHE_MB=256 # Index cache size
**`pkg/policy/`** - Event filtering and validation policies
- Policy configuration loaded from `~/.config/ORLY/policy.json`
- Per-kind size limits, age restrictions, custom scripts
- **Dynamic Policy Hot Reload via Kind 12345 Events:**
- Policy admins can update policy configuration without relay restart
- Kind 12345 events contain JSON policy in content field
- Validation-first approach: JSON validated before pausing message processing
- Message processing uses RWMutex: RLock for normal ops, Lock for policy updates
- Policy admin follow lists (kind 3) trigger immediate cache refresh
- `WriteAllowFollows` rule grants both read+write access to admin follows
- Tag validation supports regex patterns per tag type
- See `docs/POLICY_USAGE_GUIDE.md` for configuration examples
**`pkg/sync/`** - Distributed synchronization
@ -241,7 +250,8 @@ export ORLY_DB_INDEX_CACHE_MB=256 # Index cache size @@ -241,7 +250,8 @@ export ORLY_DB_INDEX_CACHE_MB=256 # Index cache size
**Web UI (`app/web/`):**
- Svelte-based admin interface
- Embedded in binary via `go:embed`
- Features: event browser, sprocket management, user admin, settings
- Features: event browser, sprocket management, policy management, user admin, settings
- **Policy Management Tab:** JSON editor with validation, save publishes kind 12345 event
**Command-line Tools (`cmd/`):**
- `relay-tester/` - Nostr protocol compliance testing

27
app/handle-event.go

@ -223,6 +223,33 @@ func (l *Listener) HandleEvent(msg []byte) (err error) { @@ -223,6 +223,33 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
log.E.F("failed to process NIP-43 leave request: %v", err)
}
return
case kind.PolicyConfig.K:
// Handle policy configuration update events (kind 12345)
// Only policy admins can update policy configuration
if err = l.HandlePolicyConfigUpdate(env.E); chk.E(err) {
log.E.F("failed to process policy config update: %v", err)
if err = Ok.Error(l, env, err.Error()); chk.E(err) {
return
}
return
}
// Send OK response
if err = Ok.Ok(l, env, "policy configuration updated"); chk.E(err) {
return
}
return
case kind.FollowList.K:
// Check if this is a follow list update from a policy admin
// If so, refresh the policy follows cache immediately
if l.IsPolicyAdminFollowListEvent(env.E) {
// Process the follow list update (async, don't block)
go func() {
if updateErr := l.HandlePolicyAdminFollowListUpdate(env.E); updateErr != nil {
log.W.F("failed to update policy follows from admin follow list: %v", updateErr)
}
}()
}
// Continue with normal follow list processing (store the event)
}
// check permissions of user

5
app/handle-message.go

@ -40,6 +40,11 @@ func validateJSONMessage(msg []byte) (err error) { @@ -40,6 +40,11 @@ func validateJSONMessage(msg []byte) (err error) {
}
func (l *Listener) HandleMessage(msg []byte, remote string) {
// Acquire read lock for message processing - allows concurrent processing
// but blocks during policy/follow list updates (which acquire write lock)
l.Server.AcquireMessageProcessingLock()
defer l.Server.ReleaseMessageProcessingLock()
// Handle blacklisted IPs - discard messages but keep connection open until timeout
if l.isBlacklisted {
// Check if timeout has been reached

312
app/handle-policy-config.go

@ -0,0 +1,312 @@ @@ -0,0 +1,312 @@
package app
import (
"bytes"
"fmt"
"path/filepath"
"github.com/adrg/xdg"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
)
// HandlePolicyConfigUpdate processes kind 12345 policy configuration events.
// Only policy admins can update policy configuration.
//
// Process flow:
// 1. Verify sender is policy admin (from current policy.policy_admins list)
// 2. Parse and validate JSON FIRST (before making any changes)
// 3. Pause ALL message processing (lock mutex)
// 4. Reload policy (pause policy engine, update, save, resume)
// 5. Resume message processing (unlock mutex)
//
// The message processing mutex is already released by the caller (HandleEvent),
// so we acquire it ourselves for the critical section.
func (l *Listener) HandlePolicyConfigUpdate(ev *event.E) error {
log.I.F("received policy config update from pubkey: %s", hex.Enc(ev.Pubkey))
// 1. Verify sender is policy admin (from current policy.policy_admins list)
if l.policyManager == nil {
return fmt.Errorf("policy system is not enabled")
}
isAdmin := l.policyManager.IsPolicyAdmin(ev.Pubkey)
if !isAdmin {
log.W.F("policy config update rejected: pubkey %s is not a policy admin", hex.Enc(ev.Pubkey))
return fmt.Errorf("only policy administrators can update policy configuration")
}
log.I.F("policy admin verified: %s", hex.Enc(ev.Pubkey))
// 2. Parse and validate JSON FIRST (before making any changes)
policyJSON := []byte(ev.Content)
if err := l.policyManager.ValidateJSON(policyJSON); chk.E(err) {
log.E.F("policy config update validation failed: %v", err)
return fmt.Errorf("invalid policy configuration: %v", err)
}
log.I.F("policy config validation passed")
// Get config path for saving
configPath := filepath.Join(xdg.ConfigHome, l.Config.AppName, "policy.json")
// 3. Pause ALL message processing (lock mutex)
// Note: We need to release the RLock first (which caller holds), then acquire exclusive Lock
// Actually, the HandleMessage already released the lock after calling HandleEvent
// So we can directly acquire the exclusive lock
log.I.F("pausing message processing for policy update")
l.Server.PauseMessageProcessing()
defer l.Server.ResumeMessageProcessing()
// 4. Reload policy (this will pause policy engine, update, save, and resume)
log.I.F("applying policy configuration update")
if err := l.policyManager.Reload(policyJSON, configPath); chk.E(err) {
log.E.F("policy config update failed: %v", err)
return fmt.Errorf("failed to apply policy configuration: %v", err)
}
log.I.F("policy configuration updated successfully by admin: %s", hex.Enc(ev.Pubkey))
// 5. Message processing mutex will be unlocked by defer
return nil
}
// HandlePolicyAdminFollowListUpdate processes kind 3 follow list events from policy admins.
// When a policy admin updates their follow list, we immediately refresh the policy follows cache.
//
// Process flow:
// 1. Check if sender is a policy admin
// 2. If yes, extract p-tags from the follow list
// 3. Pause message processing
// 4. Aggregate all policy admin follows and update cache
// 5. Resume message processing
func (l *Listener) HandlePolicyAdminFollowListUpdate(ev *event.E) error {
// Only process if policy system is enabled
if l.policyManager == nil || !l.policyManager.IsEnabled() {
return nil // Not an error, just ignore
}
// Check if sender is a policy admin
if !l.policyManager.IsPolicyAdmin(ev.Pubkey) {
return nil // Not a policy admin, ignore
}
log.I.F("policy admin %s updated their follow list, refreshing policy follows", hex.Enc(ev.Pubkey))
// Extract p-tags from this follow list event
newFollows := extractFollowsFromEvent(ev)
// Pause message processing for atomic update
log.D.F("pausing message processing for follow list update")
l.Server.PauseMessageProcessing()
defer l.Server.ResumeMessageProcessing()
// Get all current follows from database for all policy admins
// For now, we'll merge the new follows with existing ones
// A more complete implementation would re-fetch all admin follows from DB
allFollows, err := l.fetchAllPolicyAdminFollows()
if err != nil {
log.W.F("failed to fetch all policy admin follows: %v, using new follows only", err)
allFollows = newFollows
} else {
// Merge with the new follows (deduplicated)
allFollows = mergeFollows(allFollows, newFollows)
}
// Update the policy follows cache
l.policyManager.UpdatePolicyFollows(allFollows)
log.I.F("policy follows cache updated with %d total pubkeys", len(allFollows))
return nil
}
// extractFollowsFromEvent extracts p-tag pubkeys from a kind 3 follow list event.
// Returns binary pubkeys.
func extractFollowsFromEvent(ev *event.E) [][]byte {
var follows [][]byte
pTags := ev.Tags.GetAll([]byte("p"))
for _, pTag := range pTags {
// ValueHex() handles both binary and hex storage formats automatically
pt, err := hex.Dec(string(pTag.ValueHex()))
if err != nil {
continue
}
follows = append(follows, pt)
}
return follows
}
// fetchAllPolicyAdminFollows fetches kind 3 events for all policy admins from the database
// and aggregates their follows.
func (l *Listener) fetchAllPolicyAdminFollows() ([][]byte, error) {
var allFollows [][]byte
seen := make(map[string]bool)
// Get policy admin pubkeys
admins := l.policyManager.GetPolicyAdminsBin()
if len(admins) == 0 {
return nil, fmt.Errorf("no policy admins configured")
}
// For each admin, query their latest kind 3 event
for _, adminPubkey := range admins {
// Build proper filter for kind 3 from this admin
f := filter.New()
f.Authors = tag.NewFromAny(adminPubkey)
f.Kinds = kind.NewS(kind.FollowList)
limit := uint(1)
f.Limit = &limit
// Query the database for kind 3 events from this admin
events, err := l.DB.QueryEvents(l.ctx, f)
if err != nil {
log.W.F("failed to query follows for admin %s: %v", hex.Enc(adminPubkey), err)
continue
}
// events is []*event.E - iterate over the slice
for _, ev := range events {
// Extract p-tags from this follow list
follows := extractFollowsFromEvent(ev)
for _, follow := range follows {
key := string(follow)
if !seen[key] {
seen[key] = true
allFollows = append(allFollows, follow)
}
}
}
}
return allFollows, nil
}
// mergeFollows merges two follow lists, removing duplicates.
func mergeFollows(existing, newFollows [][]byte) [][]byte {
seen := make(map[string]bool)
var result [][]byte
for _, f := range existing {
key := string(f)
if !seen[key] {
seen[key] = true
result = append(result, f)
}
}
for _, f := range newFollows {
key := string(f)
if !seen[key] {
seen[key] = true
result = append(result, f)
}
}
return result
}
// IsPolicyConfigEvent returns true if the event is a policy configuration event (kind 12345)
func IsPolicyConfigEvent(ev *event.E) bool {
return ev.Kind == kind.PolicyConfig.K
}
// IsPolicyAdminFollowListEvent returns true if this is a follow list event from a policy admin.
// Used to detect when we need to refresh the policy follows cache.
func (l *Listener) IsPolicyAdminFollowListEvent(ev *event.E) bool {
// Must be kind 3 (follow list)
if ev.Kind != kind.FollowList.K {
return false
}
// Policy system must be enabled
if l.policyManager == nil || !l.policyManager.IsEnabled() {
return false
}
// Sender must be a policy admin
return l.policyManager.IsPolicyAdmin(ev.Pubkey)
}
// isPolicyAdmin checks if a pubkey is in the list of policy admins
func isPolicyAdmin(pubkey []byte, admins [][]byte) bool {
for _, admin := range admins {
if bytes.Equal(pubkey, admin) {
return true
}
}
return false
}
// InitializePolicyFollows loads the follow lists of all policy admins at startup.
// This should be called after the policy manager is initialized but before
// the relay starts accepting connections.
// It's a method on Server so it can be called from main.go during initialization.
func (s *Server) InitializePolicyFollows() error {
// Skip if policy system is not enabled
if s.policyManager == nil || !s.policyManager.IsEnabled() {
log.D.F("policy system not enabled, skipping follow list initialization")
return nil
}
// Skip if PolicyFollowWhitelistEnabled is false
if !s.policyManager.IsPolicyFollowWhitelistEnabled() {
log.D.F("policy follow whitelist not enabled, skipping follow list initialization")
return nil
}
log.I.F("initializing policy follows from database")
// Get policy admin pubkeys
admins := s.policyManager.GetPolicyAdminsBin()
if len(admins) == 0 {
log.W.F("no policy admins configured, skipping follow list initialization")
return nil
}
var allFollows [][]byte
seen := make(map[string]bool)
// For each admin, query their latest kind 3 event
for _, adminPubkey := range admins {
// Build proper filter for kind 3 from this admin
f := filter.New()
f.Authors = tag.NewFromAny(adminPubkey)
f.Kinds = kind.NewS(kind.FollowList)
limit := uint(1)
f.Limit = &limit
// Query the database for kind 3 events from this admin
events, err := s.DB.QueryEvents(s.Ctx, f)
if err != nil {
log.W.F("failed to query follows for admin %s: %v", hex.Enc(adminPubkey), err)
continue
}
// Extract p-tags from each follow list event
for _, ev := range events {
follows := extractFollowsFromEvent(ev)
for _, follow := range follows {
key := string(follow)
if !seen[key] {
seen[key] = true
allFollows = append(allFollows, follow)
}
}
}
}
// Update the policy follows cache
s.policyManager.UpdatePolicyFollows(allFollows)
log.I.F("policy follows initialized with %d pubkeys from %d admin(s)",
len(allFollows), len(admins))
return nil
}

21
app/handle-req.go

@ -142,6 +142,27 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -142,6 +142,27 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
}
}
// Filter out policy config events (kind 12345) for non-policy-admin users
// Policy config events should only be visible to policy administrators
if l.policyManager != nil && l.policyManager.IsEnabled() {
isPolicyAdmin := l.policyManager.IsPolicyAdmin(l.authedPubkey.Load())
if !isPolicyAdmin {
// Remove kind 12345 from all filters
for _, f := range *env.Filters {
if f != nil && f.Kinds != nil && f.Kinds.Len() > 0 {
// Create a new kinds list without PolicyConfig
var filteredKinds []*kind.K
for _, k := range f.Kinds.K {
if k.K != kind.PolicyConfig.K {
filteredKinds = append(filteredKinds, k)
}
}
f.Kinds.K = filteredKinds
}
}
}
}
var events event.S
// Create a single context for all filter queries, isolated from the connection context
// to prevent query timeouts from affecting the long-lived websocket connection

469
app/handle_policy_config_test.go

@ -0,0 +1,469 @@ @@ -0,0 +1,469 @@
package app
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/adrg/xdg"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/publish"
)
// setupPolicyTestListener creates a test listener with policy system enabled
func setupPolicyTestListener(t *testing.T, policyAdminHex string) (*Listener, *database.D, func()) {
tempDir, err := os.MkdirTemp("", "policy_handler_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
// Use a unique app name per test to avoid conflicts
appName := "test-policy-" + filepath.Base(tempDir)
// Create the XDG config directory and default policy file BEFORE creating the policy manager
configDir := filepath.Join(xdg.ConfigHome, appName)
if err := os.MkdirAll(configDir, 0755); err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to create config dir: %v", err)
}
// Create initial policy file with admin if provided
var initialPolicy []byte
if policyAdminHex != "" {
initialPolicy = []byte(`{
"default_policy": "allow",
"policy_admins": ["` + policyAdminHex + `"],
"policy_follow_whitelist_enabled": true
}`)
} else {
initialPolicy = []byte(`{"default_policy": "allow"}`)
}
policyPath := filepath.Join(configDir, "policy.json")
if err := os.WriteFile(policyPath, initialPolicy, 0644); err != nil {
os.RemoveAll(tempDir)
os.RemoveAll(configDir)
t.Fatalf("failed to write policy file: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
os.RemoveAll(configDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
PolicyEnabled: true,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
ACLMode: "none",
AppName: appName,
}
// Create policy manager - now config file exists at XDG path
policyManager := policy.NewWithManager(ctx, cfg.AppName, cfg.PolicyEnabled)
server := &Server{
Ctx: ctx,
Config: cfg,
DB: db,
publishers: publish.New(NewPublisher(ctx)),
policyManager: policyManager,
cfg: cfg,
db: db,
messagePauseMutex: sync.RWMutex{},
}
// Configure ACL registry
acl.Registry.Active.Store(cfg.ACLMode)
if err = acl.Registry.Configure(cfg, db, ctx); err != nil {
db.Close()
os.RemoveAll(tempDir)
os.RemoveAll(configDir)
t.Fatalf("failed to configure ACL: %v", err)
}
listener := &Listener{
Server: server,
ctx: ctx,
writeChan: make(chan publish.WriteRequest, 100),
writeDone: make(chan struct{}),
messageQueue: make(chan messageRequest, 100),
processingDone: make(chan struct{}),
subscriptions: make(map[string]context.CancelFunc),
}
// Start write worker and message processor
go listener.writeWorker()
go listener.messageProcessor()
cleanup := func() {
close(listener.writeChan)
<-listener.writeDone
close(listener.messageQueue)
<-listener.processingDone
db.Close()
os.RemoveAll(tempDir)
os.RemoveAll(configDir)
}
return listener, db, cleanup
}
// createPolicyConfigEvent creates a kind 12345 policy config event
func createPolicyConfigEvent(t *testing.T, signer *p8k.Signer, policyJSON string) *event.E {
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = kind.PolicyConfig.K
ev.Content = []byte(policyJSON)
ev.Tags = tag.NewS()
if err := ev.Sign(signer); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
return ev
}
// TestHandlePolicyConfigUpdate_ValidAdmin tests policy update from valid admin
func TestHandlePolicyConfigUpdate_ValidAdmin(t *testing.T) {
// Create admin signer
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Create valid policy update event
newPolicyJSON := `{
"default_policy": "deny",
"policy_admins": ["` + adminHex + `"],
"kind": {"whitelist": [1, 3, 7]}
}`
ev := createPolicyConfigEvent(t, adminSigner, newPolicyJSON)
// Handle the event
err := listener.HandlePolicyConfigUpdate(ev)
if err != nil {
t.Errorf("Expected success but got error: %v", err)
}
// Verify policy was updated
if listener.policyManager.DefaultPolicy != "deny" {
t.Errorf("Policy was not updated, default_policy = %q, expected 'deny'",
listener.policyManager.DefaultPolicy)
}
}
// TestHandlePolicyConfigUpdate_NonAdmin tests policy update rejection from non-admin
func TestHandlePolicyConfigUpdate_NonAdmin(t *testing.T) {
// Create admin signer
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
// Create non-admin signer
nonAdminSigner := p8k.MustNew()
if err := nonAdminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate non-admin keypair: %v", err)
}
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Create policy update event from non-admin
newPolicyJSON := `{"default_policy": "deny"}`
ev := createPolicyConfigEvent(t, nonAdminSigner, newPolicyJSON)
// Handle the event - should be rejected
err := listener.HandlePolicyConfigUpdate(ev)
if err == nil {
t.Error("Expected error for non-admin update but got none")
}
// Verify policy was NOT updated
if listener.policyManager.DefaultPolicy != "allow" {
t.Error("Policy should not have been updated by non-admin")
}
}
// TestHandlePolicyConfigUpdate_InvalidJSON tests rejection of invalid JSON
func TestHandlePolicyConfigUpdate_InvalidJSON(t *testing.T) {
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Create event with invalid JSON
ev := createPolicyConfigEvent(t, adminSigner, `{"invalid json`)
err := listener.HandlePolicyConfigUpdate(ev)
if err == nil {
t.Error("Expected error for invalid JSON but got none")
}
// Policy should remain unchanged
if listener.policyManager.DefaultPolicy != "allow" {
t.Error("Policy should not have been updated with invalid JSON")
}
}
// TestHandlePolicyConfigUpdate_InvalidPubkey tests rejection of invalid admin pubkeys
func TestHandlePolicyConfigUpdate_InvalidPubkey(t *testing.T) {
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Try to update with invalid admin pubkey
invalidPolicyJSON := `{
"default_policy": "deny",
"policy_admins": ["not-a-valid-pubkey"]
}`
ev := createPolicyConfigEvent(t, adminSigner, invalidPolicyJSON)
err := listener.HandlePolicyConfigUpdate(ev)
if err == nil {
t.Error("Expected error for invalid admin pubkey but got none")
}
// Policy should remain unchanged
if listener.policyManager.DefaultPolicy != "allow" {
t.Error("Policy should not have been updated with invalid admin pubkey")
}
}
// TestHandlePolicyConfigUpdate_AdminCannotRemoveSelf tests that admin can update policy
func TestHandlePolicyConfigUpdate_AdminCanUpdateAdminList(t *testing.T) {
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
// Create second admin
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Update policy to add second admin
newPolicyJSON := `{
"default_policy": "allow",
"policy_admins": ["` + adminHex + `", "` + admin2Hex + `"]
}`
ev := createPolicyConfigEvent(t, adminSigner, newPolicyJSON)
err := listener.HandlePolicyConfigUpdate(ev)
if err != nil {
t.Errorf("Expected success but got error: %v", err)
}
// Verify both admins are now in the list
admin2Bin, _ := hex.Dec(admin2Hex)
if !listener.policyManager.IsPolicyAdmin(admin2Bin) {
t.Error("Second admin should have been added to admin list")
}
}
// TestHandlePolicyAdminFollowListUpdate tests follow list update from admin
func TestHandlePolicyAdminFollowListUpdate(t *testing.T) {
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
listener, db, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Create a kind 3 follow list event from admin
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = kind.FollowList.K
ev.Content = []byte("")
ev.Tags = tag.NewS()
// Add some follows
follow1Hex := "1111111111111111111111111111111111111111111111111111111111111111"
follow2Hex := "2222222222222222222222222222222222222222222222222222222222222222"
ev.Tags.Append(tag.NewFromAny("p", follow1Hex))
ev.Tags.Append(tag.NewFromAny("p", follow2Hex))
if err := ev.Sign(adminSigner); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
// Save the event to database first
if _, err := db.SaveEvent(listener.ctx, ev); err != nil {
t.Fatalf("Failed to save follow list event: %v", err)
}
// Handle the follow list update
err := listener.HandlePolicyAdminFollowListUpdate(ev)
if err != nil {
t.Errorf("Expected success but got error: %v", err)
}
// Verify follows were added
follow1Bin, _ := hex.Dec(follow1Hex)
follow2Bin, _ := hex.Dec(follow2Hex)
if !listener.policyManager.IsPolicyFollow(follow1Bin) {
t.Error("Follow 1 should have been added to policy follows")
}
if !listener.policyManager.IsPolicyFollow(follow2Bin) {
t.Error("Follow 2 should have been added to policy follows")
}
}
// TestIsPolicyAdminFollowListEvent tests detection of admin follow list events
func TestIsPolicyAdminFollowListEvent(t *testing.T) {
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
nonAdminSigner := p8k.MustNew()
if err := nonAdminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate non-admin keypair: %v", err)
}
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Test admin's kind 3 event
adminFollowEv := event.New()
adminFollowEv.Kind = kind.FollowList.K
adminFollowEv.Tags = tag.NewS()
if err := adminFollowEv.Sign(adminSigner); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
if !listener.IsPolicyAdminFollowListEvent(adminFollowEv) {
t.Error("Should detect admin's follow list event")
}
// Test non-admin's kind 3 event
nonAdminFollowEv := event.New()
nonAdminFollowEv.Kind = kind.FollowList.K
nonAdminFollowEv.Tags = tag.NewS()
if err := nonAdminFollowEv.Sign(nonAdminSigner); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
if listener.IsPolicyAdminFollowListEvent(nonAdminFollowEv) {
t.Error("Should not detect non-admin's follow list event")
}
// Test admin's non-kind-3 event
adminOtherEv := event.New()
adminOtherEv.Kind = 1 // Kind 1, not follow list
adminOtherEv.Tags = tag.NewS()
if err := adminOtherEv.Sign(adminSigner); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
if listener.IsPolicyAdminFollowListEvent(adminOtherEv) {
t.Error("Should not detect admin's non-follow-list event")
}
}
// TestIsPolicyConfigEvent tests detection of policy config events
func TestIsPolicyConfigEvent(t *testing.T) {
signer := p8k.MustNew()
if err := signer.Generate(); err != nil {
t.Fatalf("Failed to generate keypair: %v", err)
}
// Kind 12345 event
policyEv := event.New()
policyEv.Kind = kind.PolicyConfig.K
policyEv.Tags = tag.NewS()
if err := policyEv.Sign(signer); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
if !IsPolicyConfigEvent(policyEv) {
t.Error("Should detect kind 12345 as policy config event")
}
// Non-policy event
otherEv := event.New()
otherEv.Kind = 1
otherEv.Tags = tag.NewS()
if err := otherEv.Sign(signer); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
if IsPolicyConfigEvent(otherEv) {
t.Error("Should not detect kind 1 as policy config event")
}
}
// TestMessageProcessingPauseDuringPolicyUpdate tests that message processing is paused
func TestMessageProcessingPauseDuringPolicyUpdate(t *testing.T) {
adminSigner := p8k.MustNew()
if err := adminSigner.Generate(); err != nil {
t.Fatalf("Failed to generate admin keypair: %v", err)
}
adminHex := hex.Enc(adminSigner.Pub())
listener, _, cleanup := setupPolicyTestListener(t, adminHex)
defer cleanup()
// Track if pause was called
pauseCalled := false
resumeCalled := false
// We can't easily mock the mutex, but we can verify the policy update succeeds
// which implies the pause/resume cycle completed
newPolicyJSON := `{
"default_policy": "deny",
"policy_admins": ["` + adminHex + `"]
}`
ev := createPolicyConfigEvent(t, adminSigner, newPolicyJSON)
err := listener.HandlePolicyConfigUpdate(ev)
if err != nil {
t.Errorf("Policy update failed: %v", err)
}
// If we got here without deadlock, the pause/resume worked
_ = pauseCalled
_ = resumeCalled
// Verify policy was actually updated
if listener.policyManager.DefaultPolicy != "deny" {
t.Error("Policy should have been updated")
}
}

7
app/main.go

@ -85,6 +85,13 @@ func Run( @@ -85,6 +85,13 @@ func Run(
// Initialize policy manager
l.policyManager = policy.NewWithManager(ctx, cfg.AppName, cfg.PolicyEnabled)
// Initialize policy follows from database (load follow lists of policy admins)
// This must be done after policy manager initialization but before accepting connections
if err := l.InitializePolicyFollows(); err != nil {
log.W.F("failed to initialize policy follows: %v", err)
// Continue anyway - follows can be loaded when admins update their follow lists
}
// Initialize spider manager based on mode (only for Badger backend)
if badgerDB, ok := db.(*database.D); ok && cfg.SpiderMode != "none" {
if l.spiderManager, err = spider.New(ctx, badgerDB, l.publishers, cfg.SpiderMode); chk.E(err) {

376
app/web/src/App.svelte

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import ComposeView from "./ComposeView.svelte";
import RecoveryView from "./RecoveryView.svelte";
import SprocketView from "./SprocketView.svelte";
import PolicyView from "./PolicyView.svelte";
import SearchResultsView from "./SearchResultsView.svelte";
import FilterBuilder from "./FilterBuilder.svelte";
import FilterDisplay from "./FilterDisplay.svelte";
@ -93,6 +94,16 @@ @@ -93,6 +94,16 @@
let sprocketEnabled = false;
let sprocketUploadFile = null;
// Policy management state
let policyJson = "";
let policyEnabled = false;
let isPolicyAdmin = false;
let isLoadingPolicy = false;
let policyMessage = "";
let policyMessageType = "info";
let policyValidationErrors = [];
let policyFollows = [];
// ACL mode
let aclMode = "";
@ -1071,6 +1082,9 @@ @@ -1071,6 +1082,9 @@
// Load sprocket configuration
loadSprocketConfig();
// Load policy configuration
loadPolicyConfig();
}
function savePersistentState() {
@ -1189,6 +1203,25 @@ @@ -1189,6 +1203,25 @@
}
}
async function loadPolicyConfig() {
try {
const response = await fetch("/api/policy/config", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
const config = await response.json();
policyEnabled = config.enabled || false;
}
} catch (error) {
console.error("Error loading policy config:", error);
policyEnabled = false;
}
}
async function loadSprocketStatus() {
if (!isLoggedIn || userRole !== "owner" || !sprocketEnabled) return;
@ -1455,6 +1488,327 @@ @@ -1455,6 +1488,327 @@
}, 5000);
}
// Policy management functions
function showPolicyMessage(message, type = "info") {
policyMessage = message;
policyMessageType = type;
// Auto-hide message after 5 seconds for non-errors
if (type !== "error") {
setTimeout(() => {
policyMessage = "";
}, 5000);
}
}
async function loadPolicy() {
if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
try {
isLoadingPolicy = true;
policyValidationErrors = [];
// Query for the most recent kind 12345 event (policy config)
const filter = { kinds: [12345], limit: 1 };
const events = await queryEvents(filter);
if (events && events.length > 0) {
policyJson = events[0].content;
// Try to format it nicely
try {
policyJson = JSON.stringify(JSON.parse(policyJson), null, 2);
} catch (e) {
// Keep as-is if not valid JSON
}
showPolicyMessage("Policy loaded successfully", "success");
} else {
// No policy event found, try to load from file via API
const response = await fetch("/api/policy", {
method: "GET",
headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/policy")}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json();
policyJson = JSON.stringify(data, null, 2);
showPolicyMessage("Policy loaded from file", "success");
} else {
showPolicyMessage("No policy configuration found", "info");
policyJson = "";
}
}
} catch (error) {
showPolicyMessage(`Error loading policy: ${error.message}`, "error");
} finally {
isLoadingPolicy = false;
}
}
async function validatePolicy() {
policyValidationErrors = [];
if (!policyJson.trim()) {
policyValidationErrors = ["Policy JSON is empty"];
showPolicyMessage("Validation failed", "error");
return false;
}
try {
const parsed = JSON.parse(policyJson);
// Basic structure validation
if (typeof parsed !== "object" || parsed === null) {
policyValidationErrors = ["Policy must be a JSON object"];
showPolicyMessage("Validation failed", "error");
return false;
}
// Validate policy_admins if present
if (parsed.policy_admins) {
if (!Array.isArray(parsed.policy_admins)) {
policyValidationErrors.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)) {
policyValidationErrors.push(`Invalid policy_admin pubkey: ${admin}`);
}
}
}
}
// Validate rules if present
if (parsed.rules) {
if (typeof parsed.rules !== "object") {
policyValidationErrors.push("rules must be an object");
} else {
for (const [kindStr, rule] of Object.entries(parsed.rules)) {
if (!/^\d+$/.test(kindStr)) {
policyValidationErrors.push(`Invalid kind number: ${kindStr}`);
}
if (rule.tag_validation && typeof rule.tag_validation === "object") {
for (const [tag, pattern] of Object.entries(rule.tag_validation)) {
try {
new RegExp(pattern);
} catch (e) {
policyValidationErrors.push(`Invalid regex for tag '${tag}': ${pattern}`);
}
}
}
}
}
}
// Validate default_policy if present
if (parsed.default_policy && !["allow", "deny"].includes(parsed.default_policy)) {
policyValidationErrors.push("default_policy must be 'allow' or 'deny'");
}
if (policyValidationErrors.length > 0) {
showPolicyMessage("Validation failed - see errors below", "error");
return false;
}
showPolicyMessage("Validation passed", "success");
return true;
} catch (error) {
policyValidationErrors = [`JSON parse error: ${error.message}`];
showPolicyMessage("Invalid JSON syntax", "error");
return false;
}
}
async function savePolicy() {
if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
// Validate first
const isValid = await validatePolicy();
if (!isValid) return;
try {
isLoadingPolicy = true;
// Create and publish kind 12345 event
const policyEvent = {
kind: 12345,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: policyJson,
};
// Sign and publish the event
const result = await publishEventWithAuth(policyEvent, userSigner);
if (result.success) {
showPolicyMessage("Policy updated successfully", "success");
} else {
showPolicyMessage(`Failed to publish policy: ${result.error || "Unknown error"}`, "error");
}
} catch (error) {
showPolicyMessage(`Error saving policy: ${error.message}`, "error");
} finally {
isLoadingPolicy = false;
}
}
function formatPolicyJson() {
try {
const parsed = JSON.parse(policyJson);
policyJson = JSON.stringify(parsed, null, 2);
showPolicyMessage("JSON formatted", "success");
} catch (error) {
showPolicyMessage(`Cannot format: ${error.message}`, "error");
}
}
// Convert npub to hex pubkey
function npubToHex(input) {
if (!input) return null;
// If already hex (64 characters)
if (/^[0-9a-fA-F]{64}$/.test(input)) {
return input.toLowerCase();
}
// If npub, decode it
if (input.startsWith("npub1")) {
try {
// Bech32 decode - simplified implementation
const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const data = input.slice(5); // Remove "npub1" prefix
let bits = [];
for (const char of data) {
const value = ALPHABET.indexOf(char.toLowerCase());
if (value === -1) throw new Error("Invalid character in npub");
bits.push(...[...Array(5)].map((_, i) => (value >> (4 - i)) & 1));
}
// Remove checksum (last 30 bits = 6 characters * 5 bits)
bits = bits.slice(0, -30);
// Convert 5-bit groups to 8-bit bytes
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) {
byte = (byte << 1) | bits[i + j];
}
bytes.push(byte);
}
// Convert to hex
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (e) {
console.error("Failed to decode npub:", e);
return null;
}
}
return null;
}
function addPolicyAdmin(event) {
const input = event.detail;
if (!input) {
showPolicyMessage("Please enter a pubkey", "error");
return;
}
const hexPubkey = npubToHex(input);
if (!hexPubkey || hexPubkey.length !== 64) {
showPolicyMessage("Invalid pubkey format. Use hex (64 chars) or npub", "error");
return;
}
try {
const config = JSON.parse(policyJson || "{}");
if (!config.policy_admins) {
config.policy_admins = [];
}
if (config.policy_admins.includes(hexPubkey)) {
showPolicyMessage("Admin already in list", "warning");
return;
}
config.policy_admins.push(hexPubkey);
policyJson = JSON.stringify(config, null, 2);
showPolicyMessage("Admin added - click 'Save & Publish' to apply", "info");
} catch (error) {
showPolicyMessage(`Error adding admin: ${error.message}`, "error");
}
}
function removePolicyAdmin(event) {
const pubkey = event.detail;
try {
const config = JSON.parse(policyJson || "{}");
if (config.policy_admins) {
config.policy_admins = config.policy_admins.filter(p => p !== pubkey);
policyJson = JSON.stringify(config, null, 2);
showPolicyMessage("Admin removed - click 'Save & Publish' to apply", "info");
}
} catch (error) {
showPolicyMessage(`Error removing admin: ${error.message}`, "error");
}
}
async function refreshFollows() {
if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
try {
isLoadingPolicy = true;
policyFollows = [];
// Parse current policy to get admin list
let admins = [];
try {
const config = JSON.parse(policyJson || "{}");
admins = config.policy_admins || [];
} catch (e) {
showPolicyMessage("Cannot parse policy JSON to get admins", "error");
return;
}
if (admins.length === 0) {
showPolicyMessage("No policy admins configured", "warning");
return;
}
// Query kind 3 events from policy admins
const filter = {
kinds: [3],
authors: admins,
limit: admins.length
};
const events = await queryEvents(filter);
// Extract p-tags from all follow lists
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]);
}
}
}
}
policyFollows = Array.from(followsSet);
showPolicyMessage(`Loaded ${policyFollows.length} follows from ${events.length} admin(s)`, "success");
} catch (error) {
showPolicyMessage(`Error loading follows: ${error.message}`, "error");
} finally {
isLoadingPolicy = false;
}
}
function handleSprocketFileSelect(event) {
sprocketUploadFile = event.target.files[0];
}
@ -1522,6 +1876,7 @@ @@ -1522,6 +1876,7 @@
requiresOwner: true,
},
{ id: "sprocket", icon: "⚙", label: "Sprocket", requiresOwner: true },
{ id: "policy", icon: "📜", label: "Policy", requiresOwner: true },
];
// Filter tabs based on current effective role (including view-as setting)
@ -2671,6 +3026,27 @@ @@ -2671,6 +3026,27 @@
on:deleteVersion={(e) => deleteVersion(e.detail)}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "policy"}
<PolicyView
{isLoggedIn}
{userRole}
{isPolicyAdmin}
{policyEnabled}
bind:policyJson
{isLoadingPolicy}
{policyMessage}
{policyMessageType}
validationErrors={policyValidationErrors}
{policyFollows}
on:loadPolicy={loadPolicy}
on:validatePolicy={validatePolicy}
on:savePolicy={savePolicy}
on:formatJson={formatPolicyJson}
on:addPolicyAdmin={addPolicyAdmin}
on:removePolicyAdmin={removePolicyAdmin}
on:refreshFollows={refreshFollows}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "recovery"}
<div class="recovery-tab">
<div>

734
app/web/src/PolicyView.svelte

@ -0,0 +1,734 @@ @@ -0,0 +1,734 @@
<script>
export let isLoggedIn = false;
export let userRole = "";
export let isPolicyAdmin = false;
export let policyEnabled = false;
export let policyJson = "";
export let isLoadingPolicy = false;
export let policyMessage = "";
export let policyMessageType = "";
export let validationErrors = [];
export let policyAdmins = [];
export let policyFollows = [];
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
// New admin input
let newAdminInput = "";
function loadPolicy() {
dispatch("loadPolicy");
}
function validatePolicy() {
dispatch("validatePolicy");
}
function savePolicy() {
dispatch("savePolicy");
}
function formatJson() {
dispatch("formatJson");
}
function openLoginModal() {
dispatch("openLoginModal");
}
function refreshFollows() {
dispatch("refreshFollows");
}
function addPolicyAdmin() {
if (newAdminInput.trim()) {
dispatch("addPolicyAdmin", newAdminInput.trim());
newAdminInput = "";
}
}
function removePolicyAdmin(pubkey) {
dispatch("removePolicyAdmin", pubkey);
}
// Parse admins from current policy JSON for display
$: {
try {
if (policyJson) {
const parsed = JSON.parse(policyJson);
policyAdmins = parsed.policy_admins || [];
}
} catch (e) {
// Ignore parse errors
}
}
// Pretty-print example policy for reference
const examplePolicy = `{
"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": ["<your-hex-pubkey>"],
"policy_follow_whitelist_enabled": true
}`;
</script>
<div class="policy-view">
<h2>Policy Configuration</h2>
{#if isLoggedIn && (userRole === "owner" || isPolicyAdmin)}
<div class="policy-section">
<div class="policy-header">
<h3>Policy Editor</h3>
<div class="policy-status">
<span class="status-badge" class:enabled={policyEnabled}>
{policyEnabled ? "Policy Enabled" : "Policy Disabled"}
</span>
{#if isPolicyAdmin}
<span class="admin-badge">Policy Admin</span>
{/if}
</div>
</div>
<div class="policy-info">
<p>
Edit the policy JSON below and click "Save & Publish" to update the relay's policy configuration.
Changes are applied immediately after validation.
</p>
<p class="info-note">
Policy updates are published as kind 12345 events and require policy admin permissions.
</p>
</div>
<div class="editor-container">
<textarea
class="policy-editor"
bind:value={policyJson}
placeholder="Loading policy configuration..."
disabled={isLoadingPolicy}
spellcheck="false"
></textarea>
</div>
{#if validationErrors.length > 0}
<div class="validation-errors">
<h4>Validation Errors:</h4>
<ul>
{#each validationErrors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
<div class="policy-actions">
<button
class="policy-btn load-btn"
on:click={loadPolicy}
disabled={isLoadingPolicy}
>
Load Current
</button>
<button
class="policy-btn format-btn"
on:click={formatJson}
disabled={isLoadingPolicy}
>
Format JSON
</button>
<button
class="policy-btn validate-btn"
on:click={validatePolicy}
disabled={isLoadingPolicy}
>
Validate
</button>
<button
class="policy-btn save-btn"
on:click={savePolicy}
disabled={isLoadingPolicy}
>
Save & Publish
</button>
</div>
{#if policyMessage}
<div
class="policy-message"
class:error={policyMessageType === "error"}
class:success={policyMessageType === "success"}
>
{policyMessage}
</div>
{/if}
</div>
<!-- Policy Admins Section -->
<div class="policy-section">
<h3>Policy Administrators</h3>
<div class="policy-info">
<p>
Policy admins can update the relay's policy configuration via kind 12345 events.
Their follows get whitelisted if <code>policy_follow_whitelist_enabled</code> is true in the policy.
</p>
<p class="info-note">
<strong>Note:</strong> Policy admins are separate from relay admins (ORLY_ADMINS).
Changes here update the JSON editor - click "Save & Publish" to apply.
</p>
</div>
<div class="admin-list">
{#if policyAdmins.length === 0}
<p class="no-items">No policy admins configured</p>
{:else}
{#each policyAdmins as admin}
<div class="admin-item">
<span class="admin-pubkey" title={admin}>{admin.substring(0, 16)}...{admin.substring(admin.length - 8)}</span>
<button
class="remove-btn"
on:click={() => removePolicyAdmin(admin)}
disabled={isLoadingPolicy}
title="Remove admin"
>
</button>
</div>
{/each}
{/if}
</div>
<div class="add-admin">
<input
type="text"
placeholder="npub or hex pubkey"
bind:value={newAdminInput}
disabled={isLoadingPolicy}
on:keydown={(e) => e.key === "Enter" && addPolicyAdmin()}
/>
<button
class="policy-btn add-btn"
on:click={addPolicyAdmin}
disabled={isLoadingPolicy || !newAdminInput.trim()}
>
+ Add Admin
</button>
</div>
</div>
<!-- Policy Follow Whitelist Section -->
<div class="policy-section">
<h3>Policy Follow Whitelist</h3>
<div class="policy-info">
<p>
Pubkeys followed by policy admins (kind 3 events).
These get automatic read+write access when rules have <code>write_allow_follows: true</code>.
</p>
</div>
<div class="follows-header">
<span class="follows-count">{policyFollows.length} pubkey(s) in whitelist</span>
<button
class="policy-btn refresh-btn"
on:click={refreshFollows}
disabled={isLoadingPolicy}
>
🔄 Refresh Follows
</button>
</div>
<div class="follows-list">
{#if policyFollows.length === 0}
<p class="no-items">No follows loaded. Click "Refresh Follows" to load from database.</p>
{:else}
<div class="follows-grid">
{#each policyFollows as follow}
<div class="follow-item" title={follow}>
{follow.substring(0, 12)}...{follow.substring(follow.length - 6)}
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="policy-section">
<h3>Policy Reference</h3>
<div class="reference-content">
<h4>Structure Overview</h4>
<ul class="field-list">
<li><code>kind.whitelist</code> - Only allow these event kinds (takes precedence)</li>
<li><code>kind.blacklist</code> - Deny these event kinds (if no whitelist)</li>
<li><code>global</code> - Rules applied to all events</li>
<li><code>rules</code> - Per-kind rules (keyed by kind number as string)</li>
<li><code>default_policy</code> - "allow" or "deny" when no rules match</li>
<li><code>policy_admins</code> - Hex pubkeys that can update policy</li>
<li><code>policy_follow_whitelist_enabled</code> - Enable follow-based access</li>
</ul>
<h4>Rule Fields</h4>
<ul class="field-list">
<li><code>description</code> - Human-readable rule description</li>
<li><code>write_allow</code> / <code>write_deny</code> - Pubkey lists for write access</li>
<li><code>read_allow</code> / <code>read_deny</code> - Pubkey lists for read access</li>
<li><code>write_allow_follows</code> - Grant access to policy admin follows</li>
<li><code>size_limit</code> - Max total event size in bytes</li>
<li><code>content_limit</code> - Max content field size in bytes</li>
<li><code>max_expiry</code> - Max expiry offset in seconds</li>
<li><code>max_age_of_event</code> - Max age of created_at in seconds</li>
<li><code>max_age_event_in_future</code> - Max future offset in seconds</li>
<li><code>must_have_tags</code> - Required tag letters (e.g., ["d", "t"])</li>
<li><code>tag_validation</code> - Regex patterns for tag values</li>
<li><code>script</code> - Path to external validation script</li>
</ul>
<h4>Example Policy</h4>
<pre class="example-json">{examplePolicy}</pre>
</div>
</div>
{:else if isLoggedIn}
<div class="permission-denied">
<p>Policy configuration requires owner or policy admin permissions.</p>
<p>
To become a policy admin, ask an existing policy admin to add your pubkey
to the <code>policy_admins</code> list.
</p>
<p>
Current user role: <strong>{userRole || "none"}</strong>
</p>
</div>
{:else}
<div class="login-prompt">
<p>Please log in to access policy configuration.</p>
<button class="login-btn" on:click={openLoginModal}>Log In</button>
</div>
{/if}
</div>
<style>
.policy-view {
width: 100%;
max-width: 1200px;
margin: 0;
padding: 20px;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.policy-view h2 {
margin: 0 0 1.5rem 0;
color: var(--text-color);
font-size: 1.8rem;
font-weight: 600;
}
.policy-section {
background-color: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
.policy-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.policy-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 600;
}
.policy-status {
display: flex;
gap: 0.5rem;
}
.status-badge {
padding: 0.25em 0.75em;
border-radius: 1rem;
font-size: 0.8em;
font-weight: 600;
background: var(--danger);
color: white;
}
.status-badge.enabled {
background: var(--success);
}
.admin-badge {
padding: 0.25em 0.75em;
border-radius: 1rem;
font-size: 0.8em;
font-weight: 600;
background: var(--primary);
color: white;
}
.policy-info {
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-color);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.policy-info p {
margin: 0 0 0.5rem 0;
line-height: 1.5;
}
.policy-info p:last-child {
margin-bottom: 0;
}
.info-note {
font-size: 0.9em;
opacity: 0.8;
}
.editor-container {
margin-bottom: 1rem;
}
.policy-editor {
width: 100%;
height: 400px;
padding: 1em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85em;
line-height: 1.5;
resize: vertical;
tab-size: 2;
}
.policy-editor:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.validation-errors {
margin-bottom: 1rem;
padding: 1rem;
background: var(--danger-bg, rgba(220, 53, 69, 0.1));
border: 1px solid var(--danger);
border-radius: 4px;
}
.validation-errors h4 {
margin: 0 0 0.5rem 0;
color: var(--danger);
font-size: 1rem;
}
.validation-errors ul {
margin: 0;
padding-left: 1.5rem;
}
.validation-errors li {
color: var(--danger);
margin-bottom: 0.25rem;
}
.policy-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.policy-btn {
background: var(--primary);
color: white;
border: none;
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s, filter 0.2s;
display: flex;
align-items: center;
gap: 0.25em;
}
.policy-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.policy-btn:disabled {
background: var(--secondary);
cursor: not-allowed;
}
.load-btn {
background: var(--info);
}
.format-btn {
background: var(--secondary);
}
.validate-btn {
background: var(--warning);
}
.save-btn {
background: var(--success);
}
.policy-message {
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
background: var(--info-bg, rgba(23, 162, 184, 0.1));
color: var(--info-text, var(--text-color));
border: 1px solid var(--info);
}
.policy-message.error {
background: var(--danger-bg, rgba(220, 53, 69, 0.1));
color: var(--danger-text, var(--danger));
border: 1px solid var(--danger);
}
.policy-message.success {
background: var(--success-bg, rgba(40, 167, 69, 0.1));
color: var(--success-text, var(--success));
border: 1px solid var(--success);
}
.reference-content h4 {
margin: 1rem 0 0.5rem 0;
color: var(--text-color);
font-size: 1rem;
}
.reference-content h4:first-child {
margin-top: 0;
}
.field-list {
margin: 0 0 1rem 0;
padding-left: 1.5rem;
}
.field-list li {
margin-bottom: 0.25rem;
line-height: 1.5;
}
.field-list code {
background: var(--code-bg, rgba(0, 0, 0, 0.1));
padding: 0.1em 0.4em;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9em;
}
.example-json {
background: var(--input-bg);
color: var(--input-text-color);
padding: 1rem;
border-radius: 4px;
border: 1px solid var(--border-color);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8em;
line-height: 1.4;
overflow-x: auto;
white-space: pre;
margin: 0;
}
.permission-denied,
.login-prompt {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.permission-denied p,
.login-prompt p {
margin: 0 0 1rem 0;
line-height: 1.4;
}
.permission-denied code {
background: var(--code-bg, rgba(0, 0, 0, 0.1));
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.9em;
}
.login-btn {
background: var(--primary);
color: white;
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background-color 0.2s;
}
.login-btn:hover {
filter: brightness(1.1);
}
/* Admin list styles */
.admin-list {
margin-bottom: 1rem;
}
.admin-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em 0.75em;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.admin-pubkey {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85em;
color: var(--text-color);
}
.remove-btn {
background: var(--danger);
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-size: 0.8em;
display: flex;
align-items: center;
justify-content: center;
transition: filter 0.2s;
}
.remove-btn:hover:not(:disabled) {
filter: brightness(0.9);
}
.remove-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.add-admin {
display: flex;
gap: 0.5rem;
}
.add-admin input {
flex: 1;
padding: 0.5em 0.75em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85em;
}
.add-btn {
background: var(--success);
white-space: nowrap;
}
.no-items {
color: var(--text-color);
opacity: 0.6;
font-style: italic;
padding: 1rem;
text-align: center;
}
/* Follow list styles */
.follows-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.follows-count {
font-weight: 600;
color: var(--text-color);
}
.refresh-btn {
background: var(--info);
}
.follows-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
}
.follows-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
padding: 0.75rem;
}
.follow-item {
padding: 0.4em 0.6em;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.75em;
color: var(--text-color);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
</style>

86
docs/POLICY_USAGE_GUIDE.md

@ -880,6 +880,92 @@ Check logs for policy decisions and errors. @@ -880,6 +880,92 @@ Check logs for policy decisions and errors.
3. **Permission errors**: Fix file permissions on policy files and scripts
4. **Configuration errors**: Validate JSON syntax and field names
## Dynamic Policy Configuration via Kind 12345
Policy administrators can update the relay policy dynamically by publishing kind 12345 events. This enables runtime policy changes without relay restarts.
### Enabling Dynamic Policy Updates
1. Add yourself as a policy admin in the initial policy.json:
```json
{
"default_policy": "allow",
"policy_admins": ["YOUR_HEX_PUBKEY_HERE"],
"policy_follow_whitelist_enabled": false
}
```
2. Ensure policy is enabled:
```bash
export ORLY_POLICY_ENABLED=true
```
### Publishing a Policy Update
Send a kind 12345 event with the new policy configuration as JSON content:
```json
{
"kind": 12345,
"content": "{\"default_policy\": \"deny\", \"kind\": {\"whitelist\": [1,3,7]}, \"policy_admins\": [\"YOUR_HEX_PUBKEY\"]}",
"tags": [],
"created_at": 1234567890
}
```
### Policy Admin Follow List Whitelisting
When `policy_follow_whitelist_enabled` is `true`, the relay automatically grants access to all pubkeys followed by policy admins.
```json
{
"policy_admins": ["ADMIN_PUBKEY_HEX"],
"policy_follow_whitelist_enabled": true
}
```
- When an admin updates their follow list (kind 3), the relay automatically refreshes the whitelist
- The `write_allow_follows` rule option grants both read AND write access to follows
- This enables community-based access control without manual pubkey management
### Security Considerations
- Only pubkeys listed in `policy_admins` can update the policy
- Policy updates are validated before applying (invalid JSON or pubkeys are rejected)
- Failed updates preserve the existing policy (no corruption)
- All policy updates are logged for audit purposes
## Testing the Policy System
### Edge Cases Discovered During Testing
When writing tests for the policy system, the following edge cases were discovered:
1. **Config File Requirement**: `NewWithManager()` with `enabled=true` requires the XDG config file (`~/.config/APP_NAME/policy.json`) to exist before initialization. Tests must create this file first.
2. **Error Message Format**: Validation errors use underscores in field names (e.g., `invalid policy_admin pubkey`) - tests should match this exact format.
3. **Binary Tag Storage**: When comparing pubkeys from e/p tags, always use `tag.ValueHex()` instead of `tag.Value()` due to binary optimization.
4. **Concurrent Access**: The policy system uses `sync.RWMutex` for thread-safe access to the follows list during updates.
5. **Message Processing Pause**: Policy updates pause message processing with an exclusive lock to ensure atomic updates.
### Running Policy Tests
```bash
# Run all policy package tests
CGO_ENABLED=0 go test -v ./pkg/policy/...
# Run handler tests for kind 12345
CGO_ENABLED=0 go test -v ./app/... -run "PolicyConfig|PolicyAdmin"
# Run specific test categories
CGO_ENABLED=0 go test -v ./pkg/policy/... -run "ValidateJSON|Reload|Follow|TagValidation"
```
## Advanced Configuration
### Multiple Policies

2
go.mod

@ -83,4 +83,6 @@ require ( @@ -83,4 +83,6 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace git.mleku.dev/mleku/nostr => /home/mleku/src/git.mleku.dev/mleku/nostr
retract v1.0.3

2
go.sum

@ -1,6 +1,4 @@ @@ -1,6 +1,4 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
git.mleku.dev/mleku/nostr v1.0.3 h1:dWpGVzIOrjeWVnDnrX039s2LvcfHwDIo47NyyO1CBbs=
git.mleku.dev/mleku/nostr v1.0.3/go.mod h1:swI7bWLc7yU1jd7PLCCIrIcUR3Ug5O+GPvpub/w6eTY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=

339
pkg/policy/follows_test.go

@ -0,0 +1,339 @@ @@ -0,0 +1,339 @@
package policy
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/adrg/xdg"
"git.mleku.dev/mleku/nostr/encoders/hex"
)
// setupTestPolicy creates a policy manager with a temporary config file.
// Returns the policy and a cleanup function.
func setupTestPolicy(t *testing.T, appName string) (*P, func()) {
t.Helper()
// Create config directory at XDG path
configDir := filepath.Join(xdg.ConfigHome, appName)
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}
// Create default policy.json
configPath := filepath.Join(configDir, "policy.json")
defaultPolicy := []byte(`{"default_policy": "allow"}`)
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
t.Fatalf("Failed to write policy file: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
policy := NewWithManager(ctx, appName, true)
if policy == nil {
cancel()
os.RemoveAll(configDir)
t.Fatal("Failed to create policy manager")
}
cleanup := func() {
cancel()
os.RemoveAll(configDir)
}
return policy, cleanup
}
// TestIsPolicyAdmin tests the IsPolicyAdmin method
func TestIsPolicyAdmin(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-policy-admin")
defer cleanup()
// Set up policy with admins
admin1Hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
nonAdminHex := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
policyJSON := []byte(`{
"policy_admins": [
"` + admin1Hex + `",
"` + admin2Hex + `"
]
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
// Convert hex to bytes for testing
admin1Bin, _ := hex.Dec(admin1Hex)
admin2Bin, _ := hex.Dec(admin2Hex)
nonAdminBin, _ := hex.Dec(nonAdminHex)
tests := []struct {
name string
pubkey []byte
expected bool
}{
{
name: "first admin is recognized",
pubkey: admin1Bin,
expected: true,
},
{
name: "second admin is recognized",
pubkey: admin2Bin,
expected: true,
},
{
name: "non-admin is not recognized",
pubkey: nonAdminBin,
expected: false,
},
{
name: "nil pubkey returns false",
pubkey: nil,
expected: false,
},
{
name: "empty pubkey returns false",
pubkey: []byte{},
expected: false,
},
{
name: "wrong length pubkey returns false",
pubkey: []byte{0x01, 0x02, 0x03},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := policy.IsPolicyAdmin(tt.pubkey)
if result != tt.expected {
t.Errorf("IsPolicyAdmin() = %v, expected %v", result, tt.expected)
}
})
}
}
// TestIsPolicyFollow tests the IsPolicyFollow method
func TestIsPolicyFollow(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-policy-follow")
defer cleanup()
// Set up some follows
follow1Hex := "1111111111111111111111111111111111111111111111111111111111111111"
follow2Hex := "2222222222222222222222222222222222222222222222222222222222222222"
nonFollowHex := "3333333333333333333333333333333333333333333333333333333333333333"
follow1Bin, _ := hex.Dec(follow1Hex)
follow2Bin, _ := hex.Dec(follow2Hex)
nonFollowBin, _ := hex.Dec(nonFollowHex)
// Update policy follows directly
policy.UpdatePolicyFollows([][]byte{follow1Bin, follow2Bin})
tests := []struct {
name string
pubkey []byte
expected bool
}{
{
name: "first follow is recognized",
pubkey: follow1Bin,
expected: true,
},
{
name: "second follow is recognized",
pubkey: follow2Bin,
expected: true,
},
{
name: "non-follow is not recognized",
pubkey: nonFollowBin,
expected: false,
},
{
name: "nil pubkey returns false",
pubkey: nil,
expected: false,
},
{
name: "empty pubkey returns false",
pubkey: []byte{},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := policy.IsPolicyFollow(tt.pubkey)
if result != tt.expected {
t.Errorf("IsPolicyFollow() = %v, expected %v", result, tt.expected)
}
})
}
}
// TestUpdatePolicyFollows tests the UpdatePolicyFollows method
func TestUpdatePolicyFollows(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-update-follows")
defer cleanup()
// Initially no follows
testPubkey, _ := hex.Dec("1111111111111111111111111111111111111111111111111111111111111111")
if policy.IsPolicyFollow(testPubkey) {
t.Error("Expected no follows initially")
}
// Add follows
follows := [][]byte{testPubkey}
policy.UpdatePolicyFollows(follows)
if !policy.IsPolicyFollow(testPubkey) {
t.Error("Expected pubkey to be a follow after update")
}
// Update with empty list
policy.UpdatePolicyFollows([][]byte{})
if policy.IsPolicyFollow(testPubkey) {
t.Error("Expected pubkey to not be a follow after clearing")
}
// Update with nil
policy.UpdatePolicyFollows(nil)
if policy.IsPolicyFollow(testPubkey) {
t.Error("Expected pubkey to not be a follow after nil update")
}
}
// TestIsPolicyFollowWhitelistEnabled tests the IsPolicyFollowWhitelistEnabled method
func TestIsPolicyFollowWhitelistEnabled(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-whitelist-enabled")
defer cleanup()
tmpDir := t.TempDir()
// Test with disabled
policyJSON := []byte(`{"policy_follow_whitelist_enabled": false}`)
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
if policy.IsPolicyFollowWhitelistEnabled() {
t.Error("Expected follow whitelist to be disabled")
}
// Test with enabled
policyJSON = []byte(`{"policy_follow_whitelist_enabled": true}`)
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
if !policy.IsPolicyFollowWhitelistEnabled() {
t.Error("Expected follow whitelist to be enabled")
}
}
// TestGetPolicyAdminsBin tests the GetPolicyAdminsBin method
func TestGetPolicyAdminsBin(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-get-admins-bin")
defer cleanup()
admin1Hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
policyJSON := []byte(`{
"policy_admins": ["` + admin1Hex + `", "` + admin2Hex + `"]
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
admins := policy.GetPolicyAdminsBin()
if len(admins) != 2 {
t.Errorf("Expected 2 admins, got %d", len(admins))
}
// Verify it's a copy (modification shouldn't affect original)
if len(admins) > 0 {
admins[0][0] = 0xFF
originalAdmins := policy.GetPolicyAdminsBin()
if originalAdmins[0][0] == 0xFF {
t.Error("GetPolicyAdminsBin should return a copy, not the original slice")
}
}
}
// TestFollowListConcurrency tests concurrent access to follow list
func TestFollowListConcurrency(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-concurrency")
defer cleanup()
testPubkey, _ := hex.Dec("1111111111111111111111111111111111111111111111111111111111111111")
// Run concurrent reads and writes
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 100; j++ {
policy.UpdatePolicyFollows([][]byte{testPubkey})
_ = policy.IsPolicyFollow(testPubkey)
_ = policy.IsPolicyAdmin(testPubkey)
}
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
}
// TestPolicyAdminAndFollowInteraction tests the interaction between admin and follow checks
func TestPolicyAdminAndFollowInteraction(t *testing.T) {
policy, cleanup := setupTestPolicy(t, "test-admin-follow-interaction")
defer cleanup()
// An admin who is also followed
adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
adminBin, _ := hex.Dec(adminHex)
policyJSON := []byte(`{
"policy_admins": ["` + adminHex + `"],
"policy_follow_whitelist_enabled": true
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
// Admin should be recognized as admin
if !policy.IsPolicyAdmin(adminBin) {
t.Error("Expected admin to be recognized as admin")
}
// Admin is not automatically a follow
if policy.IsPolicyFollow(adminBin) {
t.Error("Admin should not automatically be a follow")
}
// Now add admin as a follow
policy.UpdatePolicyFollows([][]byte{adminBin})
// Should be both admin and follow
if !policy.IsPolicyAdmin(adminBin) {
t.Error("Expected admin to still be recognized as admin")
}
if !policy.IsPolicyFollow(adminBin) {
t.Error("Expected admin to now be recognized as follow")
}
}

403
pkg/policy/hotreload_test.go

@ -0,0 +1,403 @@ @@ -0,0 +1,403 @@
package policy
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/adrg/xdg"
)
// setupHotreloadTestPolicy creates a policy manager with a temporary config file for hotreload tests.
func setupHotreloadTestPolicy(t *testing.T, appName string) (*P, func()) {
t.Helper()
configDir := filepath.Join(xdg.ConfigHome, appName)
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "policy.json")
defaultPolicy := []byte(`{"default_policy": "allow"}`)
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
t.Fatalf("Failed to write policy file: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
policy := NewWithManager(ctx, appName, true)
if policy == nil {
cancel()
os.RemoveAll(configDir)
t.Fatal("Failed to create policy manager")
}
cleanup := func() {
cancel()
os.RemoveAll(configDir)
}
return policy, cleanup
}
// TestValidateJSON tests the ValidateJSON method with various inputs
func TestValidateJSON(t *testing.T) {
policy, cleanup := setupHotreloadTestPolicy(t, "test-validate-json")
defer cleanup()
tests := []struct {
name string
json []byte
expectError bool
errorSubstr string
}{
{
name: "valid empty policy",
json: []byte(`{}`),
expectError: false,
},
{
name: "valid complete policy",
json: []byte(`{
"kind": {"whitelist": [1, 3, 7]},
"global": {"size_limit": 65536},
"rules": {
"1": {"description": "Short text notes", "content_limit": 8192}
},
"default_policy": "allow",
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
"policy_follow_whitelist_enabled": true
}`),
expectError: false,
},
{
name: "invalid JSON syntax",
json: []byte(`{"invalid": json}`),
expectError: true,
errorSubstr: "invalid character",
},
{
name: "invalid JSON - missing closing brace",
json: []byte(`{"kind": {"whitelist": [1]}`),
expectError: true,
},
{
name: "invalid policy_admins - wrong length",
json: []byte(`{
"policy_admins": ["not-64-chars"]
}`),
expectError: true,
errorSubstr: "invalid policy_admin pubkey",
},
{
name: "invalid policy_admins - non-hex characters",
json: []byte(`{
"policy_admins": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
}`),
expectError: true,
errorSubstr: "invalid policy_admin pubkey",
},
{
name: "valid policy_admins - multiple admins",
json: []byte(`{
"policy_admins": [
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
]
}`),
expectError: false,
},
{
name: "invalid tag_validation regex",
json: []byte(`{
"rules": {
"30023": {
"tag_validation": {
"d": "[invalid(regex"
}
}
}
}`),
expectError: true,
errorSubstr: "invalid regex",
},
{
name: "valid tag_validation regex",
json: []byte(`{
"rules": {
"30023": {
"tag_validation": {
"d": "^[a-z0-9-]{1,64}$",
"t": "^[a-z0-9-]{1,32}$"
}
}
}
}`),
expectError: false,
},
{
name: "invalid default_policy",
json: []byte(`{
"default_policy": "invalid"
}`),
expectError: true,
errorSubstr: "default_policy",
},
{
name: "valid default_policy allow",
json: []byte(`{
"default_policy": "allow"
}`),
expectError: false,
},
{
name: "valid default_policy deny",
json: []byte(`{
"default_policy": "deny"
}`),
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := policy.ValidateJSON(tt.json)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
if tt.errorSubstr != "" && !containsSubstring(err.Error(), tt.errorSubstr) {
t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
// TestReload tests the Reload method
func TestReload(t *testing.T) {
policy, cleanup := setupHotreloadTestPolicy(t, "test-reload")
defer cleanup()
// Create temp directory for policy files
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "policy.json")
tests := []struct {
name string
initialJSON []byte
reloadJSON []byte
expectError bool
checkAfter func(t *testing.T, p *P)
}{
{
name: "reload with valid policy",
initialJSON: []byte(`{"default_policy": "allow"}`),
reloadJSON: []byte(`{
"default_policy": "deny",
"kind": {"whitelist": [1, 3]},
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
}`),
expectError: false,
checkAfter: func(t *testing.T, p *P) {
if p.DefaultPolicy != "deny" {
t.Errorf("Expected default_policy to be 'deny', got %q", p.DefaultPolicy)
}
if len(p.Kind.Whitelist) != 2 {
t.Errorf("Expected 2 whitelisted kinds, got %d", len(p.Kind.Whitelist))
}
if len(p.PolicyAdmins) != 1 {
t.Errorf("Expected 1 policy admin, got %d", len(p.PolicyAdmins))
}
},
},
{
name: "reload with invalid JSON fails without changes",
initialJSON: []byte(`{"default_policy": "allow"}`),
reloadJSON: []byte(`{"invalid json`),
expectError: true,
checkAfter: func(t *testing.T, p *P) {
// Policy should remain unchanged
if p.DefaultPolicy != "allow" {
t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
}
},
},
{
name: "reload with invalid admin pubkey fails without changes",
initialJSON: []byte(`{"default_policy": "allow"}`),
reloadJSON: []byte(`{
"default_policy": "deny",
"policy_admins": ["invalid-pubkey"]
}`),
expectError: true,
checkAfter: func(t *testing.T, p *P) {
// Policy should remain unchanged
if p.DefaultPolicy != "allow" {
t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Initialize policy with initial JSON
if tt.initialJSON != nil {
if err := policy.Reload(tt.initialJSON, configPath); err != nil {
t.Fatalf("Failed to set initial policy: %v", err)
}
}
// Attempt reload
err := policy.Reload(tt.reloadJSON, configPath)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
// Run post-reload checks
if tt.checkAfter != nil {
tt.checkAfter(t, policy)
}
})
}
}
// TestSaveToFile tests atomic file writing
func TestSaveToFile(t *testing.T) {
policy, cleanup := setupHotreloadTestPolicy(t, "test-save-file")
defer cleanup()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "policy.json")
// Load a policy
policyJSON := []byte(`{
"default_policy": "allow",
"kind": {"whitelist": [1, 3, 7]},
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
}`)
if err := policy.Reload(policyJSON, configPath); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
// Verify file was saved
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Errorf("Policy file was not created at %s", configPath)
}
// Read and verify contents
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read policy file: %v", err)
}
if len(data) == 0 {
t.Error("Policy file is empty")
}
// Verify it's valid JSON
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Errorf("Policy file contains invalid JSON: %v", err)
}
}
// TestPauseResume tests the Pause and Resume methods
func TestPauseResume(t *testing.T) {
policy, cleanup := setupHotreloadTestPolicy(t, "test-pause-resume")
defer cleanup()
// Test Pause
if err := policy.Pause(); err != nil {
t.Errorf("Pause failed: %v", err)
}
// Test Resume
if err := policy.Resume(); err != nil {
t.Errorf("Resume failed: %v", err)
}
// Test multiple pause/resume cycles
for i := 0; i < 3; i++ {
if err := policy.Pause(); err != nil {
t.Errorf("Pause %d failed: %v", i, err)
}
if err := policy.Resume(); err != nil {
t.Errorf("Resume %d failed: %v", i, err)
}
}
}
// TestReloadPreservesExistingOnFailure verifies that failed reloads don't corrupt state
func TestReloadPreservesExistingOnFailure(t *testing.T) {
policy, cleanup := setupHotreloadTestPolicy(t, "test-reload-preserve")
defer cleanup()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "policy.json")
// Set up initial valid policy
initialJSON := []byte(`{
"default_policy": "allow",
"kind": {"whitelist": [1, 3, 7]},
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
"policy_follow_whitelist_enabled": true
}`)
if err := policy.Reload(initialJSON, configPath); err != nil {
t.Fatalf("Failed to set initial policy: %v", err)
}
// Store initial state
initialDefaultPolicy := policy.DefaultPolicy
initialKindWhitelist := len(policy.Kind.Whitelist)
initialAdminCount := len(policy.PolicyAdmins)
initialFollowEnabled := policy.PolicyFollowWhitelistEnabled
// Attempt to reload with invalid JSON
invalidJSON := []byte(`{"policy_admins": ["invalid"]}`)
err := policy.Reload(invalidJSON, configPath)
if err == nil {
t.Fatal("Expected error for invalid policy_admins but got none")
}
// Verify state is preserved
if policy.DefaultPolicy != initialDefaultPolicy {
t.Errorf("DefaultPolicy changed from %q to %q after failed reload",
initialDefaultPolicy, policy.DefaultPolicy)
}
if len(policy.Kind.Whitelist) != initialKindWhitelist {
t.Errorf("Kind.Whitelist length changed from %d to %d after failed reload",
initialKindWhitelist, len(policy.Kind.Whitelist))
}
if len(policy.PolicyAdmins) != initialAdminCount {
t.Errorf("PolicyAdmins length changed from %d to %d after failed reload",
initialAdminCount, len(policy.PolicyAdmins))
}
if policy.PolicyFollowWhitelistEnabled != initialFollowEnabled {
t.Errorf("PolicyFollowWhitelistEnabled changed from %v to %v after failed reload",
initialFollowEnabled, policy.PolicyFollowWhitelistEnabled)
}
}
// containsSubstring checks if a string contains a substring (case-insensitive)
func containsSubstring(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}

366
pkg/policy/policy.go

@ -10,6 +10,7 @@ import ( @@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
@ -72,6 +73,15 @@ type Rule struct { @@ -72,6 +73,15 @@ type Rule struct {
// MaxAgeEventInFuture is the offset in seconds that is the newest timestamp allowed for an event's created_at time ahead of the current time.
MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"`
// WriteAllowFollows grants BOTH read and write access to policy admin follows when enabled.
// Requires PolicyFollowWhitelistEnabled=true at the policy level.
WriteAllowFollows bool `json:"write_allow_follows,omitempty"`
// TagValidation is a map of tag_name -> regex pattern for validating tag values.
// Each tag present in the event must match its corresponding regex pattern.
// Example: {"d": "^[a-z0-9-]{1,64}$", "t": "^[a-z0-9-]{1,32}$"}
TagValidation map[string]string `json:"tag_validation,omitempty"`
// Binary caches for faster comparison (populated from hex strings above)
// These are not exported and not serialized to JSON
writeAllowBin [][]byte
@ -90,7 +100,8 @@ func (r *Rule) hasAnyRules() bool { @@ -90,7 +100,8 @@ func (r *Rule) hasAnyRules() bool {
r.SizeLimit != nil || r.ContentLimit != nil ||
r.MaxAgeOfEvent != nil || r.MaxAgeEventInFuture != nil ||
r.MaxExpiry != nil || len(r.MustHaveTags) > 0 ||
r.Script != "" || r.Privileged
r.Script != "" || r.Privileged ||
r.WriteAllowFollows || len(r.TagValidation) > 0
}
// populateBinaryCache converts hex-encoded pubkey strings to binary for faster comparison.
@ -253,6 +264,19 @@ type P struct { @@ -253,6 +264,19 @@ type P struct {
Global Rule `json:"global"`
// DefaultPolicy determines the default behavior when no rules deny an event ("allow" or "deny", defaults to "allow")
DefaultPolicy string `json:"default_policy"`
// PolicyAdmins is a list of hex-encoded pubkeys that can update policy configuration via kind 12345 events.
// These are SEPARATE from ACL relay admins - policy admins manage policy only.
PolicyAdmins []string `json:"policy_admins,omitempty"`
// PolicyFollowWhitelistEnabled enables automatic whitelisting of pubkeys followed by policy admins.
// When true and a rule has WriteAllowFollows=true, policy admin follows get read+write access.
PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"`
// Unexported binary caches for faster comparison (populated from hex strings above)
policyAdminsBin [][]byte // Binary cache for policy admin pubkeys
policyFollows [][]byte // Cached follow list from policy admins (kind 3 events)
policyFollowsMx sync.RWMutex // Protect follows list access
// manager handles policy script execution.
// Unexported to enforce use of public API methods (CheckPolicy, IsEnabled).
manager *PolicyManager
@ -260,10 +284,12 @@ type P struct { @@ -260,10 +284,12 @@ type P struct {
// pJSON is a shadow struct for JSON unmarshalling with exported fields.
type pJSON struct {
Kind Kinds `json:"kind"`
Rules map[int]Rule `json:"rules"`
Global Rule `json:"global"`
DefaultPolicy string `json:"default_policy"`
Kind Kinds `json:"kind"`
Rules map[int]Rule `json:"rules"`
Global Rule `json:"global"`
DefaultPolicy string `json:"default_policy"`
PolicyAdmins []string `json:"policy_admins,omitempty"`
PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"`
}
// UnmarshalJSON implements custom JSON unmarshalling to handle unexported fields.
@ -276,6 +302,22 @@ func (p *P) UnmarshalJSON(data []byte) error { @@ -276,6 +302,22 @@ func (p *P) UnmarshalJSON(data []byte) error {
p.rules = shadow.Rules
p.Global = shadow.Global
p.DefaultPolicy = shadow.DefaultPolicy
p.PolicyAdmins = shadow.PolicyAdmins
p.PolicyFollowWhitelistEnabled = shadow.PolicyFollowWhitelistEnabled
// Populate binary cache for policy admins
if len(p.PolicyAdmins) > 0 {
p.policyAdminsBin = make([][]byte, 0, len(p.PolicyAdmins))
for _, hexPubkey := range p.PolicyAdmins {
binPubkey, err := hex.Dec(hexPubkey)
if err != nil {
log.W.F("failed to decode PolicyAdmin pubkey %q: %v", hexPubkey, err)
continue
}
p.policyAdminsBin = append(p.policyAdminsBin, binPubkey)
}
}
return nil
}
@ -1117,6 +1159,38 @@ func (p *P) checkRulePolicy( @@ -1117,6 +1159,38 @@ func (p *P) checkRulePolicy(
}
}
// Check tag validation rules (regex patterns)
// Only apply for write access - we validate what goes in, not what comes out
if access == "write" && len(rule.TagValidation) > 0 {
for tagName, regexPattern := range rule.TagValidation {
// Compile regex pattern (errors should have been caught in ValidateJSON)
regex, compileErr := regexp.Compile(regexPattern)
if compileErr != nil {
log.E.F("invalid regex pattern for tag %q: %v (skipping validation)", tagName, compileErr)
continue
}
// Get all tags with this name
tags := ev.Tags.GetAll([]byte(tagName))
// If no tags found and rule requires this tag, validation fails
if len(tags) == 0 {
log.D.F("tag validation failed: required tag %q not found", tagName)
return false, nil
}
// Validate each tag value against regex
for _, t := range tags {
value := string(t.Value())
if !regex.MatchString(value) {
log.D.F("tag validation failed: tag %q value %q does not match pattern %q",
tagName, value, regexPattern)
return false, nil
}
}
}
}
// ===================================================================
// STEP 2: Explicit Denials (highest priority blacklist)
// ===================================================================
@ -1157,6 +1231,19 @@ func (p *P) checkRulePolicy( @@ -1157,6 +1231,19 @@ func (p *P) checkRulePolicy(
}
}
// ===================================================================
// STEP 2.5: Write Allow Follows (grants BOTH read AND write access)
// ===================================================================
// WriteAllowFollows grants both read and write access to policy admin follows
// This check applies to BOTH read and write access types
if rule.WriteAllowFollows && p.PolicyFollowWhitelistEnabled {
if p.IsPolicyFollow(loggedInPubkey) {
log.D.F("policy admin follow granted %s access for kind %d", access, ev.Kind)
return true, nil // Allow access from policy admin follow
}
}
// ===================================================================
// STEP 3: Check Read Access with OR Logic (Allow List OR Privileged)
// ===================================================================
@ -1447,3 +1534,272 @@ func (pm *PolicyManager) Shutdown() { @@ -1447,3 +1534,272 @@ func (pm *PolicyManager) Shutdown() {
// Clear runners map
pm.runners = make(map[string]*ScriptRunner)
}
// =============================================================================
// Policy Hot Reload Methods
// =============================================================================
// ValidateJSON validates policy JSON without applying changes.
// This is called BEFORE any modifications to ensure JSON is valid.
// Returns error if validation fails - no changes are made to current policy.
func (p *P) ValidateJSON(policyJSON []byte) error {
// Try to unmarshal into a temporary policy struct
tempPolicy := &P{}
if err := json.Unmarshal(policyJSON, tempPolicy); err != nil {
return fmt.Errorf("invalid JSON syntax: %v", err)
}
// Validate policy_admins are valid hex pubkeys (64 characters)
for _, admin := range tempPolicy.PolicyAdmins {
if len(admin) != 64 {
return fmt.Errorf("invalid policy_admin pubkey length: %q (expected 64 hex characters)", admin)
}
if _, err := hex.Dec(admin); err != nil {
return fmt.Errorf("invalid policy_admin pubkey format: %q: %v", admin, err)
}
}
// Validate regex patterns in tag_validation rules
for kind, rule := range tempPolicy.rules {
for tagName, pattern := range rule.TagValidation {
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("invalid regex pattern for tag %q in kind %d: %v", tagName, kind, err)
}
}
}
// Validate global rule tag_validation patterns
for tagName, pattern := range tempPolicy.Global.TagValidation {
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("invalid regex pattern for tag %q in global rule: %v", tagName, err)
}
}
// Validate default_policy value
if tempPolicy.DefaultPolicy != "" && tempPolicy.DefaultPolicy != "allow" && tempPolicy.DefaultPolicy != "deny" {
return fmt.Errorf("invalid default_policy value: %q (must be \"allow\" or \"deny\")", tempPolicy.DefaultPolicy)
}
log.D.F("policy JSON validation passed")
return nil
}
// Reload loads policy from JSON bytes and applies it to the existing policy instance.
// This validates JSON FIRST, then pauses the policy manager, updates configuration, and resumes.
// Returns error if validation fails - no changes are made on validation failure.
func (p *P) Reload(policyJSON []byte, configPath string) error {
// Step 1: Validate JSON FIRST (before making any changes)
if err := p.ValidateJSON(policyJSON); err != nil {
return fmt.Errorf("validation failed: %v", err)
}
// Step 2: Pause policy manager (stop script runners)
if err := p.Pause(); err != nil {
log.W.F("failed to pause policy manager (continuing anyway): %v", err)
}
// Step 3: Unmarshal JSON into a temporary struct
tempPolicy := &P{}
if err := json.Unmarshal(policyJSON, tempPolicy); err != nil {
// Resume before returning error
p.Resume()
return fmt.Errorf("failed to unmarshal policy JSON: %v", err)
}
// Step 4: Apply the new configuration (preserve manager reference)
p.policyFollowsMx.Lock()
p.Kind = tempPolicy.Kind
p.rules = tempPolicy.rules
p.Global = tempPolicy.Global
p.DefaultPolicy = tempPolicy.DefaultPolicy
p.PolicyAdmins = tempPolicy.PolicyAdmins
p.PolicyFollowWhitelistEnabled = tempPolicy.PolicyFollowWhitelistEnabled
p.policyAdminsBin = tempPolicy.policyAdminsBin
// Note: policyFollows is NOT reset here - it will be refreshed separately
p.policyFollowsMx.Unlock()
// Step 5: Populate binary caches for all rules
p.Global.populateBinaryCache()
for kind := range p.rules {
rule := p.rules[kind]
rule.populateBinaryCache()
p.rules[kind] = rule
}
// Step 6: Save to file (atomic write)
if err := p.SaveToFile(configPath); err != nil {
log.E.F("failed to persist policy to disk: %v (policy was updated in memory)", err)
// Continue anyway - policy is loaded in memory
}
// Step 7: Resume policy manager (restart script runners)
if err := p.Resume(); err != nil {
log.W.F("failed to resume policy manager: %v", err)
}
log.I.F("policy configuration reloaded successfully")
return nil
}
// Pause pauses the policy manager and stops all script runners.
func (p *P) Pause() error {
if p.manager == nil {
return fmt.Errorf("policy manager is not initialized")
}
p.manager.mutex.Lock()
defer p.manager.mutex.Unlock()
// Stop all running scripts
for path, runner := range p.manager.runners {
if runner.IsRunning() {
log.I.F("pausing policy script: %s", path)
if err := runner.Stop(); err != nil {
log.W.F("failed to stop runner %s: %v", path, err)
}
}
}
log.I.F("policy manager paused")
return nil
}
// Resume resumes the policy manager and restarts script runners.
func (p *P) Resume() error {
if p.manager == nil {
return fmt.Errorf("policy manager is not initialized")
}
// Restart the default policy script if it exists
go p.manager.startPolicyIfExists()
// Restart rule-specific scripts
for _, rule := range p.rules {
if rule.Script != "" {
if _, err := os.Stat(rule.Script); err == nil {
runner := p.manager.getOrCreateRunner(rule.Script)
go func(r *ScriptRunner, script string) {
if err := r.Start(); err != nil {
log.W.F("failed to restart policy script %s: %v", script, err)
}
}(runner, rule.Script)
}
}
}
log.I.F("policy manager resumed")
return nil
}
// SaveToFile persists the current policy configuration to disk using atomic write.
// Uses temp file + rename pattern to ensure atomic writes.
func (p *P) SaveToFile(configPath string) error {
// Create shadow struct for JSON marshalling
shadow := pJSON{
Kind: p.Kind,
Rules: p.rules,
Global: p.Global,
DefaultPolicy: p.DefaultPolicy,
PolicyAdmins: p.PolicyAdmins,
PolicyFollowWhitelistEnabled: p.PolicyFollowWhitelistEnabled,
}
// Marshal to JSON with indentation for readability
jsonData, err := json.MarshalIndent(shadow, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal policy to JSON: %v", err)
}
// Write to temp file first (atomic write pattern)
tempPath := configPath + ".tmp"
if err := os.WriteFile(tempPath, jsonData, 0644); err != nil {
return fmt.Errorf("failed to write temp file: %v", err)
}
// Rename temp file to actual config file (atomic on most filesystems)
if err := os.Rename(tempPath, configPath); err != nil {
// Clean up temp file on failure
os.Remove(tempPath)
return fmt.Errorf("failed to rename temp file: %v", err)
}
log.I.F("policy configuration saved to %s", configPath)
return nil
}
// =============================================================================
// Policy Admin and Follow Checking Methods
// =============================================================================
// IsPolicyAdmin checks if the given pubkey is in the policy_admins list.
// The pubkey parameter should be binary ([]byte), not hex-encoded.
func (p *P) IsPolicyAdmin(pubkey []byte) bool {
if len(pubkey) == 0 {
return false
}
p.policyFollowsMx.RLock()
defer p.policyFollowsMx.RUnlock()
for _, admin := range p.policyAdminsBin {
if utils.FastEqual(admin, pubkey) {
return true
}
}
return false
}
// IsPolicyFollow checks if the given pubkey is in the policy admin follows list.
// The pubkey parameter should be binary ([]byte), not hex-encoded.
func (p *P) IsPolicyFollow(pubkey []byte) bool {
if len(pubkey) == 0 {
return false
}
p.policyFollowsMx.RLock()
defer p.policyFollowsMx.RUnlock()
for _, follow := range p.policyFollows {
if utils.FastEqual(pubkey, follow) {
return true
}
}
return false
}
// UpdatePolicyFollows replaces the policy follows list with a new set of pubkeys.
// This is called when policy admins update their follow lists (kind 3 events).
// The pubkeys should be binary ([]byte), not hex-encoded.
func (p *P) UpdatePolicyFollows(follows [][]byte) {
p.policyFollowsMx.Lock()
defer p.policyFollowsMx.Unlock()
p.policyFollows = follows
log.I.F("policy follows list updated with %d pubkeys", len(follows))
}
// GetPolicyAdminsBin returns a copy of the binary policy admin pubkeys.
// Used for checking if an event author is a policy admin.
func (p *P) GetPolicyAdminsBin() [][]byte {
p.policyFollowsMx.RLock()
defer p.policyFollowsMx.RUnlock()
// Return a copy to prevent external modification
result := make([][]byte, len(p.policyAdminsBin))
for i, admin := range p.policyAdminsBin {
adminCopy := make([]byte, len(admin))
copy(adminCopy, admin)
result[i] = adminCopy
}
return result
}
// IsPolicyFollowWhitelistEnabled returns whether the policy follow whitelist feature is enabled.
// When enabled, pubkeys followed by policy admins are automatically whitelisted for access
// when rules have WriteAllowFollows=true.
func (p *P) IsPolicyFollowWhitelistEnabled() bool {
if p == nil {
return false
}
return p.PolicyFollowWhitelistEnabled
}

481
pkg/policy/tag_validation_test.go

@ -0,0 +1,481 @@ @@ -0,0 +1,481 @@
package policy
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/adrg/xdg"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"lol.mleku.dev/chk"
)
// setupTagValidationTestPolicy creates a policy manager with a temporary config file for tag validation tests.
func setupTagValidationTestPolicy(t *testing.T, appName string) (*P, func()) {
t.Helper()
configDir := filepath.Join(xdg.ConfigHome, appName)
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "policy.json")
defaultPolicy := []byte(`{"default_policy": "allow"}`)
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
t.Fatalf("Failed to write policy file: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
policy := NewWithManager(ctx, appName, true)
if policy == nil {
cancel()
os.RemoveAll(configDir)
t.Fatal("Failed to create policy manager")
}
cleanup := func() {
cancel()
os.RemoveAll(configDir)
}
return policy, cleanup
}
// createSignedTestEvent creates a signed event for testing
func createSignedTestEvent(t *testing.T, kind uint16, content string) (*event.E, *p8k.Signer) {
signer := p8k.MustNew()
if err := signer.Generate(); chk.E(err) {
t.Fatalf("Failed to generate keypair: %v", err)
}
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = kind
ev.Content = []byte(content)
ev.Tags = tag.NewS()
if err := ev.Sign(signer); chk.E(err) {
t.Fatalf("Failed to sign event: %v", err)
}
return ev, signer
}
// addTagToEvent adds a tag to an event
func addTagToEvent(ev *event.E, key, value string) {
tagItem := tag.NewFromAny(key, value)
ev.Tags.Append(tagItem)
}
// TestTagValidationBasic tests basic tag validation with regex patterns
func TestTagValidationBasic(t *testing.T) {
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-basic")
defer cleanup()
// Policy with tag validation for kind 30023 (long-form content)
policyJSON := []byte(`{
"default_policy": "allow",
"rules": {
"30023": {
"description": "Long-form content with tag validation",
"tag_validation": {
"d": "^[a-z0-9-]{1,64}$",
"t": "^[a-z0-9-]{1,32}$"
}
}
}
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
tests := []struct {
name string
kind uint16
tags map[string]string
expectAllow bool
}{
{
name: "valid d tag",
kind: 30023,
tags: map[string]string{
"d": "my-article-slug",
},
expectAllow: true,
},
{
name: "valid d and t tags",
kind: 30023,
tags: map[string]string{
"d": "my-article-slug",
"t": "nostr",
},
expectAllow: true,
},
{
name: "invalid d tag - contains uppercase",
kind: 30023,
tags: map[string]string{
"d": "My-Article-Slug",
},
expectAllow: false,
},
{
name: "invalid d tag - contains spaces",
kind: 30023,
tags: map[string]string{
"d": "my article slug",
},
expectAllow: false,
},
{
name: "invalid d tag - too long",
kind: 30023,
tags: map[string]string{
"d": "this-is-a-very-long-slug-that-exceeds-the-sixty-four-character-limit-set-in-policy",
},
expectAllow: false,
},
{
name: "invalid t tag - contains special chars",
kind: 30023,
tags: map[string]string{
"d": "valid-slug",
"t": "nostr@tag",
},
expectAllow: false,
},
{
name: "kind without tag validation - any tags allowed",
kind: 1, // Kind 1 has no tag validation rules
tags: map[string]string{
"d": "ANYTHING_GOES!!!",
"t": "spaces and Special Chars",
},
expectAllow: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, signer := createSignedTestEvent(t, tt.kind, "test content")
// Add tags to event
for key, value := range tt.tags {
addTagToEvent(ev, key, value)
}
// Re-sign after adding tags
if err := ev.Sign(signer); chk.E(err) {
t.Fatalf("Failed to re-sign event: %v", err)
}
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("CheckPolicy returned error: %v", err)
}
if allowed != tt.expectAllow {
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow)
}
})
}
}
// TestTagValidationMultipleSameTag tests validation when multiple tags have the same name
func TestTagValidationMultipleSameTag(t *testing.T) {
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-multi")
defer cleanup()
policyJSON := []byte(`{
"default_policy": "allow",
"rules": {
"30023": {
"tag_validation": {
"t": "^[a-z0-9-]+$"
}
}
}
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
tests := []struct {
name string
tags []string // Multiple t tags
expectAllow bool
}{
{
name: "all tags valid",
tags: []string{"nostr", "bitcoin", "lightning"},
expectAllow: true,
},
{
name: "one invalid tag among valid ones",
tags: []string{"nostr", "INVALID", "lightning"},
expectAllow: false,
},
{
name: "first tag invalid",
tags: []string{"INVALID", "nostr", "bitcoin"},
expectAllow: false,
},
{
name: "last tag invalid",
tags: []string{"nostr", "bitcoin", "INVALID"},
expectAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev, signer := createSignedTestEvent(t, 30023, "test content")
// Add multiple t tags
for _, value := range tt.tags {
addTagToEvent(ev, "t", value)
}
// Re-sign
if err := ev.Sign(signer); chk.E(err) {
t.Fatalf("Failed to re-sign event: %v", err)
}
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("CheckPolicy returned error: %v", err)
}
if allowed != tt.expectAllow {
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow)
}
})
}
}
// TestTagValidationInvalidRegex tests that invalid regex patterns are caught during validation
func TestTagValidationInvalidRegex(t *testing.T) {
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-invalid-regex")
defer cleanup()
invalidRegexPolicies := []struct {
name string
policy []byte
}{
{
name: "unclosed bracket",
policy: []byte(`{
"rules": {
"30023": {
"tag_validation": {
"d": "[invalid"
}
}
}
}`),
},
{
name: "unclosed parenthesis",
policy: []byte(`{
"rules": {
"30023": {
"tag_validation": {
"d": "(unclosed"
}
}
}
}`),
},
{
name: "invalid escape sequence",
policy: []byte(`{
"rules": {
"30023": {
"tag_validation": {
"d": "\\k"
}
}
}
}`),
},
}
for _, tt := range invalidRegexPolicies {
t.Run(tt.name, func(t *testing.T) {
err := policy.ValidateJSON(tt.policy)
if err == nil {
t.Error("Expected validation error for invalid regex, got none")
}
})
}
}
// TestTagValidationEmptyTag tests behavior when a tag has no value
func TestTagValidationEmptyTag(t *testing.T) {
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-empty")
defer cleanup()
policyJSON := []byte(`{
"default_policy": "allow",
"rules": {
"30023": {
"tag_validation": {
"d": "^[a-z0-9-]+$"
}
}
}
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
// Create event with empty d tag value
ev, signer := createSignedTestEvent(t, 30023, "test content")
addTagToEvent(ev, "d", "")
if err := ev.Sign(signer); chk.E(err) {
t.Fatalf("Failed to sign event: %v", err)
}
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("CheckPolicy returned error: %v", err)
}
// Empty string doesn't match ^[a-z0-9-]+$ (+ requires at least one char)
if allowed {
t.Error("Expected empty tag value to be rejected")
}
}
// TestTagValidationWithWriteAllowFollows tests interaction between tag validation and follow whitelist
func TestTagValidationWithWriteAllowFollows(t *testing.T) {
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-follows")
defer cleanup()
// Create a test signer who will be a "follow"
signer := p8k.MustNew()
if err := signer.Generate(); chk.E(err) {
t.Fatalf("Failed to generate keypair: %v", err)
}
// Set up policy with tag validation AND write_allow_follows
adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
policyJSON := []byte(`{
"default_policy": "deny",
"policy_admins": ["` + adminHex + `"],
"policy_follow_whitelist_enabled": true,
"rules": {
"30023": {
"write_allow_follows": true,
"tag_validation": {
"d": "^[a-z0-9-]+$"
}
}
}
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
// Add the signer as a follow
policy.UpdatePolicyFollows([][]byte{signer.Pub()})
// Test: Follow with valid tag should be allowed
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = 30023
ev.Content = []byte("test content")
ev.Tags = tag.NewS()
addTagToEvent(ev, "d", "valid-slug")
if err := ev.Sign(signer); chk.E(err) {
t.Fatalf("Failed to sign event: %v", err)
}
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("CheckPolicy returned error: %v", err)
}
if !allowed {
t.Error("Expected follow with valid tag to be allowed")
}
// Test: Follow with invalid tag should still be rejected (tag validation applies)
ev2 := event.New()
ev2.CreatedAt = time.Now().Unix()
ev2.Kind = 30023
ev2.Content = []byte("test content")
ev2.Tags = tag.NewS()
addTagToEvent(ev2, "d", "INVALID_SLUG")
if err := ev2.Sign(signer); chk.E(err) {
t.Fatalf("Failed to sign event: %v", err)
}
allowed2, err := policy.CheckPolicy("write", ev2, signer.Pub(), "127.0.0.1")
if err != nil {
t.Fatalf("CheckPolicy returned error: %v", err)
}
if allowed2 {
t.Error("Expected follow with invalid tag to be rejected (tag validation should still apply)")
}
}
// TestTagValidationGlobalRule tests tag validation in global rules
func TestTagValidationGlobalRule(t *testing.T) {
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-global")
defer cleanup()
// Policy with global tag validation (applies to all kinds)
policyJSON := []byte(`{
"default_policy": "allow",
"global": {
"tag_validation": {
"e": "^[a-f0-9]{64}$"
}
}
}`)
tmpDir := t.TempDir()
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
t.Fatalf("Failed to reload policy: %v", err)
}
// Valid e tag (64 hex chars)
ev1, signer1 := createSignedTestEvent(t, 1, "test")
addTagToEvent(ev1, "e", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
if err := ev1.Sign(signer1); chk.E(err) {
t.Fatalf("Failed to sign event: %v", err)
}
allowed1, _ := policy.CheckPolicy("write", ev1, signer1.Pub(), "127.0.0.1")
if !allowed1 {
t.Error("Expected valid e tag to be allowed")
}
// Invalid e tag (not 64 hex chars)
ev2, signer2 := createSignedTestEvent(t, 1, "test")
addTagToEvent(ev2, "e", "not-a-valid-event-id")
if err := ev2.Sign(signer2); chk.E(err) {
t.Fatalf("Failed to sign event: %v", err)
}
allowed2, _ := policy.CheckPolicy("write", ev2, signer2.Pub(), "127.0.0.1")
if allowed2 {
t.Error("Expected invalid e tag to be rejected")
}
}
Loading…
Cancel
Save