Issue: https://git.nostrdev.com/mleku/next.orly.dev/issues/6
This plan implements three interconnected features for ORLY's policy system:
Policy System (pkg/policy/policy.go):
~/.config/ORLY/policy.json at startupP struct with unexported rules field (map[int]Rule)PolicyManager manages script runners for external policy scriptsLoadFromFile() method exists for loading policy from diskACL System (pkg/acl/follows.go):
Event Handling (app/handle-event.go):213-226
Web UI:
/api/<feature>/<action> patternEvent 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:
content field~/.config/ORLY/policy.jsonFile: Create pkg/protocol/policyconfig/policyconfig.go
package policyconfig
const (
// KindPolicyConfig is a relay-internal event for policy configuration updates
// Only visible to admins and owners
KindPolicyConfig uint16 = 12345
)
File: pkg/policy/policy.go
Add methods to P struct:
// 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)
File: app/handle-event.go
Add handling after NIP-43 special events (after line 226):
// 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
// 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:
ev.Pubkey is in admins or owners listFile: app/handle-req.go
Add filter to hide kind 12345 from non-admins:
// 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)
}
}
Purpose: Enable policy-based follow list whitelisting (separate from ACL follows) Use Case: Policy admins can designate follows who get special policy privileges Configuration:
{
"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
}
}
}
File: pkg/policy/policy.go
Extend P struct:
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:
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.
File: pkg/policy/policy.go
Add methods:
// 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
}
File: pkg/policy/policy.go
Update checkRulePolicy() method (around line 1062):
// 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...
}
File: pkg/policy/policy.go
Add to NewWithManager():
// Start periodic follow list refresh for policy admins
if len(policy.PolicyAdmins) > 0 && policy.PolicyFollowWhitelistEnabled {
go policy.startPeriodicFollowRefresh(ctx)
}
Add method:
// 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:
func NewWithManager(ctx context.Context, appName string, enabled bool, db *database.D) *P
Components:
PolicyView.svelte - Main policy management UIUI Features:
File: app/web/src/PolicyView.svelte
Structure:
<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>
File: app/web/src/App.svelte
Add state variables (around line 94):
// 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):
if (isLoggedIn && (userRole === "owner" || userRole === "admin")) {
tabs.push({
id: "policy",
label: "Policy",
icon: "🛡️",
isSearchTab: false
});
}
Add component import:
import PolicyView from "./PolicyView.svelte";
Add view in main content area (look for {#if selectedTab === "sprocket"}):
{: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:
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";
}
}
File: app/server.go
Add to route registration (around line 245):
// 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
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:
// 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
}
pkg/policy/policy_test.go): - Test Reload() with valid JSON
- Test Reload() with invalid JSON
- Test Pause() and Resume() functionality
- Test SaveToFile() atomic write
pkg/policy/follows_test.go): - Test FetchPolicyFollows() with mock database
- Test IsPolicyFollow() with various inputs
- Test follow list caching and expiry
app/handle-policy-config_test.go):- Test kind 12345 handling with admin pubkey - Test kind 12345 rejection from non-admin - Test JSON validation errors
- Publish kind 12345 event as admin - Verify policy reloaded - Verify new policy enforced - Verify policy persisted to disk
- 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
- Load policy via API - Edit and publish via UI - Verify changes applied - Check follow list display
- Only admins/owners can publish kind 12345 - Only admins/owners can access policy API endpoints - Policy events only visible to admins/owners in queries
- Strict JSON schema validation before applying - Rollback mechanism if policy fails to load - Catch all parsing errors
- Log all policy update attempts - Store kind 12345 events in database for audit - Include who changed what and when
- Pause-update-resume must be atomic - File writes must be atomic (temp file + rename) - No partial updates on failure
- Should policyadmins be separate from ORLYADMINS? - Recommendation: Yes, separate. Policy admins manage policy, relay admins manage relay.
- How often to refresh policy admin follows? - Recommendation: 15 minutes (configurable via ORLYPOLICYFOLLOW_REFRESH)
- What happens to relays without policy_admins field? - Recommendation: Fall back to empty list, disabled by default
- Policy needs database reference for follow queries - Recommendation: Pass database to NewWithManager()
- Should failed reload keep old policy or disable policy? - Recommendation: Keep old policy, log error, return error to client
~/.config/ORLY/policy.json- Policy reload methods: 3-4 hours - Follow list support: 3-4 hours - Event handling: 2-3 hours - Testing: 2-3 hours
- Route setup: 1 hour - Handler implementation: 1-2 hours - Testing: 1 hour
- PolicyView component: 3-4 hours - App integration: 2-3 hours - Styling and UX: 2-3 hours - Testing: 2 hours
- Unit tests: 2-3 hours - Integration tests: 2-3 hours - Documentation: 2 hours
Total: 20-29 hours