diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dc762bc..99d0aaa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/.plan/issue-7-directory-spider.md b/.plan/issue-7-directory-spider.md new file mode 100644 index 0000000..f59a10d --- /dev/null +++ b/.plan/issue-7-directory-spider.md @@ -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 diff --git a/.plan/policy-hot-reload-implementation.md b/.plan/policy-hot-reload-implementation.md new file mode 100644 index 0000000..bb147fa --- /dev/null +++ b/.plan/policy-hot-reload-implementation.md @@ -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//` 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 + + +
+

Policy Configuration Management

+ + {#if isLoggedIn && (userRole === "owner" || userRole === "admin")} + +
+

Policy Configuration

+
+ + + +
+ + +
+ + {#if validationErrors.length > 0} +
+

Validation Errors:

+
    + {#each validationErrors as error} +
  • {error}
  • + {/each} +
+
+ {/if} + +
+ + + + +
+ + {#if policyMessage} +
+ {policyMessage} +
+ {/if} +
+ + +
+

Policy Administrators

+
+

+ Policy admins can update the relay's policy configuration via kind 12345 events. + Their follows get whitelisted if policy_follow_whitelist_enabled is true in the policy. +

+

+ Note: Policy admins are separate from relay admins (ORLY_ADMINS). + Changes here update the JSON editor - click "Save & Publish" to apply. +

+
+ +
+ {#if policyAdmins.length === 0} +

No policy admins configured

+ {:else} + {#each policyAdmins as admin} +
+ {admin.substring(0, 16)}...{admin.substring(admin.length - 8)} + +
+ {/each} + {/if} +
+ +
+ e.key === "Enter" && addPolicyAdmin()} + /> + +
+
+ + +
+

Policy Follow Whitelist

+
+

+ Pubkeys followed by policy admins (kind 3 events). + These get automatic read+write access when rules have write_allow_follows: true. +

+
+ +
+ {policyFollows.length} pubkey(s) in whitelist + +
+ +
+ {#if policyFollows.length === 0} +

No follows loaded. Click "Refresh Follows" to load from database.

+ {:else} +
+ {#each policyFollows as follow} + + {/each} +
+ {/if} +
+
+ +
+

Policy Reference

+
+

Structure Overview

+
    +
  • kind.whitelist - Only allow these event kinds (takes precedence)
  • +
  • kind.blacklist - Deny these event kinds (if no whitelist)
  • +
  • global - Rules applied to all events
  • +
  • rules - Per-kind rules (keyed by kind number as string)
  • +
  • default_policy - "allow" or "deny" when no rules match
  • +
  • policy_admins - Hex pubkeys that can update policy
  • +
  • policy_follow_whitelist_enabled - Enable follow-based access
  • +
+ +

Rule Fields

+
    +
  • description - Human-readable rule description
  • +
  • write_allow / write_deny - Pubkey lists for write access
  • +
  • read_allow / read_deny - Pubkey lists for read access
  • +
  • write_allow_follows - Grant access to policy admin follows
  • +
  • size_limit - Max total event size in bytes
  • +
  • content_limit - Max content field size in bytes
  • +
  • max_expiry - Max expiry offset in seconds
  • +
  • max_age_of_event - Max age of created_at in seconds
  • +
  • max_age_event_in_future - Max future offset in seconds
  • +
  • must_have_tags - Required tag letters (e.g., ["d", "t"])
  • +
  • tag_validation - Regex patterns for tag values
  • +
  • script - Path to external validation script
  • +
+ +

Example Policy

+
{examplePolicy}
+
+
+ {:else if isLoggedIn} +
+

Policy configuration requires owner or policy admin permissions.

+

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

+

+ Current user role: {userRole || "none"} +

+
+ {:else} + + {/if} + + + diff --git a/docs/POLICY_USAGE_GUIDE.md b/docs/POLICY_USAGE_GUIDE.md index b2063bd..29bcd54 100644 --- a/docs/POLICY_USAGE_GUIDE.md +++ b/docs/POLICY_USAGE_GUIDE.md @@ -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 diff --git a/go.mod b/go.mod index 326291a..2c888dd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 0da475b..2155448 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/policy/follows_test.go b/pkg/policy/follows_test.go new file mode 100644 index 0000000..05b5517 --- /dev/null +++ b/pkg/policy/follows_test.go @@ -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") + } +} diff --git a/pkg/policy/hotreload_test.go b/pkg/policy/hotreload_test.go new file mode 100644 index 0000000..cfdfe39 --- /dev/null +++ b/pkg/policy/hotreload_test.go @@ -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)) +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index bd7588b..694423b 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "time" @@ -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 { 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 { 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 { // 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 { 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( } } + // 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( } } + // =================================================================== + // 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() { // 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 +} diff --git a/pkg/policy/tag_validation_test.go b/pkg/policy/tag_validation_test.go new file mode 100644 index 0000000..f39296d --- /dev/null +++ b/pkg/policy/tag_validation_test.go @@ -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") + } +}