config.go raw

   1  // Package config provides a go-simpler.org/env configuration table and helpers
   2  // for working with the list of key/value lists stored in .env files.
   3  //
   4  // IMPORTANT: This file is the SINGLE SOURCE OF TRUTH for all environment variables.
   5  // All configuration options MUST be defined here with proper `env` struct tags.
   6  // Never use os.Getenv() directly in other packages - pass configuration via structs.
   7  // This ensures all options appear in `./orly help` output and are documented.
   8  //
   9  // For database backends, use GetDatabaseConfigValues() to extract database-specific
  10  // settings, then construct a database.DatabaseConfig in the caller (e.g., main.go).
  11  package config
  12  
  13  import (
  14  	"bufio"
  15  	"fmt"
  16  	"io"
  17  	"os"
  18  	"path/filepath"
  19  	"reflect"
  20  	"sort"
  21  	"strings"
  22  	"time"
  23  
  24  	"github.com/adrg/xdg"
  25  	"go-simpler.org/env"
  26  	lol "next.orly.dev/pkg/lol"
  27  	"next.orly.dev/pkg/lol/chk"
  28  	"next.orly.dev/pkg/lol/log"
  29  	"next.orly.dev/pkg/logbuffer"
  30  	"next.orly.dev/pkg/version"
  31  )
  32  
  33  // C holds application configuration settings loaded from environment variables
  34  // and default values. It defines parameters for app behaviour, storage
  35  // locations, logging, and network settings used across the relay service.
  36  type C struct {
  37  	AppName             string        `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
  38  	DataDir             string        `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/ORLY"`
  39  	Listen              string        `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
  40  	Port                int           `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
  41  	HealthPort          int           `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
  42  	EnableShutdown      bool          `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
  43  	LogLevel            string        `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
  44  	DBLogLevel          string        `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
  45  	DBBlockCacheMB      int           `env:"ORLY_DB_BLOCK_CACHE_MB" default:"1024" usage:"Badger block cache size in MB (higher improves read hit ratio, increase for large archives)"`
  46  	DBIndexCacheMB      int           `env:"ORLY_DB_INDEX_CACHE_MB" default:"512" usage:"Badger index cache size in MB (improves index lookup performance, increase for large archives)"`
  47  	DBZSTDLevel         int           `env:"ORLY_DB_ZSTD_LEVEL" default:"3" usage:"Badger ZSTD compression level (1=fast/500MB/s, 3=balanced, 9=best ratio/slower, 0=disable)"`
  48  	LogToStdout         bool          `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
  49  	LogBufferSize       int           `env:"ORLY_LOG_BUFFER_SIZE" default:"10000" usage:"number of log entries to keep in memory for web UI viewing (0 disables)"`
  50  	Pprof               string        `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
  51  	PprofPath           string        `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
  52  	PprofHTTP           bool          `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
  53  	IPWhitelist         []string      `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
  54  	IPBlacklist         []string      `env:"ORLY_IP_BLACKLIST" usage:"comma-separated list of IP addresses to block; matches on prefixes to allow subnets, e.g. 192.168 = 192.168.0.0/16"`
  55  	Admins              []string      `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
  56  	Owners              []string      `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
  57  	ACLMode             string        `env:"ORLY_ACL_MODE" usage:"ACL mode: follows, managed (nip-86), curating, none" default:"none"`
  58  	AuthRequired        bool          `env:"ORLY_AUTH_REQUIRED" usage:"require authentication for all requests (works with managed ACL)" default:"false"`
  59  	AuthToWrite         bool          `env:"ORLY_AUTH_TO_WRITE" usage:"require authentication only for write operations (EVENT), allow REQ/COUNT without auth" default:"false"`
  60  	NIP46BypassAuth     bool          `env:"ORLY_NIP46_BYPASS_AUTH" usage:"allow NIP-46 bunker events (kind 24133) through without authentication even when auth is required" default:"false"`
  61  	BootstrapRelays     []string      `env:"ORLY_BOOTSTRAP_RELAYS" usage:"comma-separated list of bootstrap relay URLs for initial sync"`
  62  	NWCUri              string        `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
  63  	SubscriptionEnabled bool          `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
  64  	MonthlyPriceSats    int64         `env:"ORLY_MONTHLY_PRICE_SATS" default:"6000" usage:"price in satoshis for one month subscription (default ~$2 USD)"`
  65  	RelayURL            string        `env:"ORLY_RELAY_URL" usage:"base URL for the relay dashboard (e.g., https://relay.example.com)"`
  66  	RelayAddresses      []string      `env:"ORLY_RELAY_ADDRESSES" usage:"comma-separated list of websocket addresses for this relay (e.g., wss://relay.example.com,wss://backup.example.com)"`
  67  	RelayPeers          []string      `env:"ORLY_RELAY_PEERS" usage:"comma-separated list of peer relay URLs for distributed synchronization (e.g., https://peer1.example.com,https://peer2.example.com)"`
  68  	RelayGroupAdmins    []string      `env:"ORLY_RELAY_GROUP_ADMINS" usage:"comma-separated list of npubs authorized to publish relay group configuration events"`
  69  	ClusterAdmins       []string      `env:"ORLY_CLUSTER_ADMINS" usage:"comma-separated list of npubs authorized to manage cluster membership"`
  70  	FollowListFrequency time.Duration `env:"ORLY_FOLLOW_LIST_FREQUENCY" usage:"how often to fetch admin follow lists (default: 1h)" default:"1h"`
  71  
  72  	// Progressive throttle for follows ACL mode - allows non-followed users to write with increasing delay
  73  	FollowsThrottleEnabled  bool          `env:"ORLY_FOLLOWS_THROTTLE" default:"false" usage:"enable progressive delay for non-followed users in follows ACL mode"`
  74  	FollowsThrottlePerEvent time.Duration `env:"ORLY_FOLLOWS_THROTTLE_INCREMENT" default:"25ms" usage:"delay added per event for non-followed users"`
  75  	FollowsThrottleMaxDelay time.Duration `env:"ORLY_FOLLOWS_THROTTLE_MAX" default:"60s" usage:"maximum throttle delay cap"`
  76  
  77  	// Social ACL mode - WoT graph topology with inbound-trust rate limiting
  78  	SocialThrottleD2Increment       time.Duration `env:"ORLY_SOCIAL_THROTTLE_D2_INCREMENT" default:"50ms" usage:"throttle increment per event for WoT depth-2 users"`
  79  	SocialThrottleD2Max             time.Duration `env:"ORLY_SOCIAL_THROTTLE_D2_MAX" default:"30s" usage:"max throttle delay for WoT depth-2 users"`
  80  	SocialThrottleD3Increment       time.Duration `env:"ORLY_SOCIAL_THROTTLE_D3_INCREMENT" default:"200ms" usage:"throttle increment per event for WoT depth-3 users"`
  81  	SocialThrottleD3Max             time.Duration `env:"ORLY_SOCIAL_THROTTLE_D3_MAX" default:"60s" usage:"max throttle delay for WoT depth-3 users"`
  82  	SocialThrottleOutsiderIncrement time.Duration `env:"ORLY_SOCIAL_THROTTLE_OUTSIDER_INCREMENT" default:"500ms" usage:"throttle increment per event for outsiders (beyond WoT)"`
  83  	SocialThrottleOutsiderMax       time.Duration `env:"ORLY_SOCIAL_THROTTLE_OUTSIDER_MAX" default:"120s" usage:"max throttle delay for outsiders"`
  84  	SocialWoTMaxDepth               int           `env:"ORLY_SOCIAL_WOT_DEPTH" default:"3" usage:"maximum WoT traversal depth (1-16)"`
  85  	SocialWoTRefreshInterval        time.Duration `env:"ORLY_SOCIAL_WOT_REFRESH" default:"1h" usage:"how often to recompute the WoT depth map"`
  86  
  87  	// GrapeVine WoT influence scoring API
  88  	GrapeVineEnabled         bool          `env:"ORLY_GRAPEVINE_ENABLED" default:"false" usage:"enable GrapeVine WoT influence scoring API (NIP-98 authenticated)"`
  89  	GrapeVineMaxDepth        int           `env:"ORLY_GRAPEVINE_MAX_DEPTH" default:"6" usage:"max BFS depth for follow graph traversal"`
  90  	GrapeVineCycles          int           `env:"ORLY_GRAPEVINE_CYCLES" default:"5" usage:"convergence iterations for influence scoring"`
  91  	GrapeVineAttenuation     float64       `env:"ORLY_GRAPEVINE_ATTENUATION" default:"0.8" usage:"weight decay factor per hop (0-1)"`
  92  	GrapeVineRigor           float64       `env:"ORLY_GRAPEVINE_RIGOR" default:"0.25" usage:"certainty curve steepness (0-1)"`
  93  	GrapeVineFollowConf      float64       `env:"ORLY_GRAPEVINE_FOLLOW_CONFIDENCE" default:"0.05" usage:"base confidence weight for a follow edge"`
  94  	GrapeVineObservers       []string      `env:"ORLY_GRAPEVINE_OBSERVERS" usage:"comma-separated hex pubkeys to auto-calculate scores for"`
  95  	GrapeVineRefresh         time.Duration `env:"ORLY_GRAPEVINE_REFRESH" default:"6h" usage:"recalculation interval for configured observers"`
  96  
  97  	// Blossom blob storage service settings
  98  	BlossomEnabled       bool   `env:"ORLY_BLOSSOM_ENABLED" default:"true" usage:"enable Blossom blob storage server (only works with Badger backend)"`
  99  	BlossomServiceLevels string `env:"ORLY_BLOSSOM_SERVICE_LEVELS" usage:"comma-separated list of service levels in format: name:storage_mb_per_sat_per_month (e.g., basic:1,premium:10)"`
 100  
 101  	// Blossom upload rate limiting (for non-followed users)
 102  	BlossomRateLimitEnabled bool  `env:"ORLY_BLOSSOM_RATE_LIMIT" default:"false" usage:"enable upload rate limiting for non-followed users"`
 103  	BlossomDailyLimitMB     int64 `env:"ORLY_BLOSSOM_DAILY_LIMIT_MB" default:"10" usage:"daily upload limit in MB for non-followed users (EMA averaged)"`
 104  	BlossomBurstLimitMB     int64 `env:"ORLY_BLOSSOM_BURST_LIMIT_MB" default:"50" usage:"max burst upload in MB (bucket cap)"`
 105  
 106  	// Blossom delete replay protection (proposed BUD enhancement)
 107  	BlossomDeleteRequireServerTag bool `env:"ORLY_BLOSSOM_DELETE_REQUIRE_SERVER_TAG" default:"false" usage:"require server tag in delete auth events to prevent cross-server replay attacks (not yet ratified in spec)"`
 108  
 109  	// Web UI and dev mode settings
 110  	WebDisableEmbedded bool   `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
 111  	WebDevProxyURL     string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`
 112  
 113  	// Branding/white-label settings
 114  	BrandingDir     string `env:"ORLY_BRANDING_DIR" usage:"directory containing branding assets and configuration (default: ~/.config/ORLY/branding)"`
 115  	BrandingEnabled bool   `env:"ORLY_BRANDING_ENABLED" default:"true" usage:"enable custom branding if branding directory exists"`
 116  	Theme           string `env:"ORLY_THEME" default:"auto" usage:"UI color theme: auto (follow system), light, dark"`
 117  
 118  	// CORS settings (for standalone dashboard mode)
 119  	CORSEnabled bool     `env:"ORLY_CORS_ENABLED" default:"false" usage:"enable CORS headers for API endpoints (required for standalone dashboard)"`
 120  	CORSOrigins []string `env:"ORLY_CORS_ORIGINS" usage:"allowed CORS origins (comma-separated, or * for all origins)"`
 121  
 122  	// Sprocket settings
 123  	SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`
 124  
 125  	// Spider settings
 126  	SpiderMode string `env:"ORLY_SPIDER_MODE" default:"none" usage:"spider mode for syncing events: none, follows"`
 127  
 128  	// Directory Spider settings
 129  	DirectorySpiderEnabled  bool          `env:"ORLY_DIRECTORY_SPIDER" default:"false" usage:"enable directory spider for metadata sync (kinds 0, 3, 10000, 10002)"`
 130  	DirectorySpiderInterval time.Duration `env:"ORLY_DIRECTORY_SPIDER_INTERVAL" default:"24h" usage:"how often to run directory spider"`
 131  	DirectorySpiderMaxHops  int           `env:"ORLY_DIRECTORY_SPIDER_HOPS" default:"3" usage:"maximum hops for relay discovery from seed users"`
 132  
 133  
 134  	// Corpus Crawler settings (relay discovery + negentropy full-event sync)
 135  	CrawlerEnabled           bool          `env:"ORLY_CRAWLER_ENABLED" default:"false" usage:"enable corpus crawler for relay discovery and full event sync via negentropy"`
 136  	CrawlerDiscoveryInterval time.Duration `env:"ORLY_CRAWLER_DISCOVERY_INTERVAL" default:"4h" usage:"how often to run relay discovery via kind 10002 hop expansion"`
 137  	CrawlerSyncInterval      time.Duration `env:"ORLY_CRAWLER_SYNC_INTERVAL" default:"30m" usage:"how often to re-sync known relays via negentropy"`
 138  	CrawlerMaxHops           int           `env:"ORLY_CRAWLER_MAX_HOPS" default:"5" usage:"maximum hops for relay discovery from seed pubkeys"`
 139  	CrawlerConcurrency       int           `env:"ORLY_CRAWLER_CONCURRENCY" default:"3" usage:"number of relays to sync concurrently"`
 140  
 141  	PolicyEnabled bool   `env:"ORLY_POLICY_ENABLED" default:"false" usage:"enable policy-based event processing (default config: $HOME/.config/ORLY/policy.json)"`
 142  	PolicyPath    string `env:"ORLY_POLICY_PATH" usage:"ABSOLUTE path to policy configuration file (MUST start with /); overrides default location; relative paths are rejected"`
 143  
 144  	// NIP-43 Relay Access Metadata and Requests
 145  	NIP43Enabled          bool          `env:"ORLY_NIP43_ENABLED" default:"false" usage:"enable NIP-43 relay access metadata and invite system"`
 146  	NIP43PublishEvents    bool          `env:"ORLY_NIP43_PUBLISH_EVENTS" default:"true" usage:"publish kind 8000/8001 events when members are added/removed"`
 147  	NIP43PublishMemberList bool         `env:"ORLY_NIP43_PUBLISH_MEMBER_LIST" default:"true" usage:"publish kind 13534 membership list events"`
 148  	NIP43InviteExpiry     time.Duration `env:"ORLY_NIP43_INVITE_EXPIRY" default:"24h" usage:"how long invite codes remain valid"`
 149  
 150  	// Database configuration
 151  	DBType              string `env:"ORLY_DB_TYPE" default:"badger" usage:"database backend to use: badger, neo4j, or grpc"`
 152  	QueryCacheDisabled  bool   `env:"ORLY_QUERY_CACHE_DISABLED" default:"true" usage:"disable query cache to reduce memory usage (trades memory for query performance)"`
 153  
 154  	// gRPC database client settings (only used when ORLY_DB_TYPE=grpc)
 155  	GRPCServerAddress  string        `env:"ORLY_GRPC_SERVER" default:"127.0.0.1:50051" usage:"address of remote gRPC database server (only used when ORLY_DB_TYPE=grpc)"`
 156  	GRPCConnectTimeout time.Duration `env:"ORLY_GRPC_CONNECT_TIMEOUT" default:"10s" usage:"gRPC connection timeout (only used when ORLY_DB_TYPE=grpc)"`
 157  
 158  	// gRPC ACL client settings (only used when ORLY_ACL_TYPE=grpc)
 159  	ACLType               string        `env:"ORLY_ACL_TYPE" default:"local" usage:"ACL backend: local (in-process) or grpc (remote ACL server)"`
 160  	GRPCACLServerAddress  string        `env:"ORLY_GRPC_ACL_SERVER" default:"127.0.0.1:50052" usage:"address of remote gRPC ACL server (only used when ORLY_ACL_TYPE=grpc)"`
 161  	GRPCACLConnectTimeout time.Duration `env:"ORLY_GRPC_ACL_TIMEOUT" default:"10s" usage:"gRPC ACL connection timeout (only used when ORLY_ACL_TYPE=grpc)"`
 162  
 163  	// gRPC Sync client settings (only used when ORLY_SYNC_TYPE=grpc)
 164  	SyncType                     string        `env:"ORLY_SYNC_TYPE" default:"local" usage:"sync backend: local (in-process) or grpc (remote sync services)"`
 165  	GRPCSyncDistributedAddress   string        `env:"ORLY_GRPC_SYNC_DISTRIBUTED" default:"127.0.0.1:50053" usage:"address of gRPC distributed sync server"`
 166  	GRPCSyncClusterAddress       string        `env:"ORLY_GRPC_SYNC_CLUSTER" default:"127.0.0.1:50054" usage:"address of gRPC cluster sync server"`
 167  	GRPCSyncRelayGroupAddress    string        `env:"ORLY_GRPC_SYNC_RELAYGROUP" default:"127.0.0.1:50055" usage:"address of gRPC relay group server"`
 168  	GRPCSyncNegentropyAddress    string        `env:"ORLY_GRPC_SYNC_NEGENTROPY" default:"127.0.0.1:50056" usage:"address of gRPC negentropy server"`
 169  	GRPCSyncConnectTimeout       time.Duration `env:"ORLY_GRPC_SYNC_TIMEOUT" default:"10s" usage:"gRPC sync connection timeout"`
 170  	NegentropyEnabled            bool          `env:"ORLY_NEGENTROPY_ENABLED" default:"false" usage:"enable NIP-77 negentropy set reconciliation"`
 171  	NegentropyFullSyncPubkeys    string        `env:"ORLY_NEGENTROPY_FULL_SYNC_PUBKEYS" default:"" usage:"comma-separated npubs or hex pubkeys allowed full negentropy sync (others get public only)"`
 172  
 173  	QueryCacheSizeMB    int    `env:"ORLY_QUERY_CACHE_SIZE_MB" default:"512" usage:"query cache size in MB (caches database query results for faster REQ responses)"`
 174  	QueryCacheMaxAge    string `env:"ORLY_QUERY_CACHE_MAX_AGE" default:"5m" usage:"maximum age for cached query results (e.g., 5m, 10m, 1h)"`
 175  
 176  	// Neo4j configuration (only used when ORLY_DB_TYPE=neo4j)
 177  	Neo4jURI      string `env:"ORLY_NEO4J_URI" default:"bolt://localhost:7687" usage:"Neo4j bolt URI (only used when ORLY_DB_TYPE=neo4j)"`
 178  	Neo4jUser     string `env:"ORLY_NEO4J_USER" default:"neo4j" usage:"Neo4j authentication username (only used when ORLY_DB_TYPE=neo4j)"`
 179  	Neo4jPassword string `env:"ORLY_NEO4J_PASSWORD" default:"password" usage:"Neo4j authentication password (only used when ORLY_DB_TYPE=neo4j)"`
 180  
 181  	// Neo4j driver tuning (memory and connection management)
 182  	Neo4jMaxConnPoolSize   int `env:"ORLY_NEO4J_MAX_CONN_POOL" default:"25" usage:"max Neo4j connection pool size (driver default: 100, lower reduces memory)"`
 183  	Neo4jFetchSize         int `env:"ORLY_NEO4J_FETCH_SIZE" default:"1000" usage:"max records per fetch batch (prevents memory overflow, -1=fetch all)"`
 184  	Neo4jMaxTxRetrySeconds int `env:"ORLY_NEO4J_MAX_TX_RETRY_SEC" default:"30" usage:"max seconds for retryable transaction attempts"`
 185  	Neo4jQueryResultLimit  int `env:"ORLY_NEO4J_QUERY_RESULT_LIMIT" default:"10000" usage:"max results returned per query (prevents unbounded memory usage, 0=unlimited)"`
 186  
 187  	// Neo4j Cypher query proxy (NIP-98 owner-gated HTTP endpoint)
 188  	Neo4jCypherEnabled    bool `env:"ORLY_NEO4J_CYPHER_ENABLED" default:"false" usage:"enable POST /api/neo4j/cypher endpoint for owner-gated read-only Cypher queries"`
 189  	Neo4jCypherTimeoutSec int  `env:"ORLY_NEO4J_CYPHER_TIMEOUT" default:"30" usage:"default timeout in seconds for Cypher queries (max 120)"`
 190  	Neo4jCypherMaxRows    int  `env:"ORLY_NEO4J_CYPHER_MAX_ROWS" default:"10000" usage:"max result rows returned per Cypher query (0=unlimited)"`
 191  
 192  	// Advanced database tuning (increase for large archives to reduce cache misses)
 193  	SerialCachePubkeys  int `env:"ORLY_SERIAL_CACHE_PUBKEYS" default:"250000" usage:"max pubkeys to cache for compact event storage (~8MB memory, increase for large archives)"`
 194  	SerialCacheEventIds int `env:"ORLY_SERIAL_CACHE_EVENT_IDS" default:"1000000" usage:"max event IDs to cache for compact event storage (~32MB memory, increase for large archives)"`
 195  
 196  	// Connection concurrency control
 197  	MaxHandlersPerConnection int `env:"ORLY_MAX_HANDLERS_PER_CONN" default:"100" usage:"max concurrent message handlers per WebSocket connection (limits goroutine growth under load)"`
 198  	MaxConnectionsPerIP      int `env:"ORLY_MAX_CONN_PER_IP" default:"10" usage:"max WebSocket connections per IP address (progressive delay applied as count increases)"`
 199  
 200  	// Connection storm mitigation (adaptive, works with PID rate limiter)
 201  	MaxGlobalConnections  int `env:"ORLY_MAX_GLOBAL_CONNECTIONS" default:"500" usage:"maximum total WebSocket connections before refusing new ones"`
 202  	ConnectionDelayMaxMs  int `env:"ORLY_CONN_DELAY_MAX_MS" default:"2000" usage:"maximum delay in ms for new connections under load"`
 203  	GoroutineWarningCount int `env:"ORLY_GOROUTINE_WARNING" default:"5000" usage:"goroutine count at which connection acceptance slows down"`
 204  	GoroutineMaxCount     int `env:"ORLY_GOROUTINE_MAX" default:"10000" usage:"goroutine count at which new connections are refused"`
 205  	MaxSubscriptions      int `env:"ORLY_MAX_SUBSCRIPTIONS" default:"10000" usage:"maximum total active subscriptions (reduced to 1000 in emergency mode)"`
 206  
 207  	// HTTP guard (application-level bot blocking + rate limiting for Cloudron/nginx-less deployments)
 208  	HTTPGuardEnabled  bool `env:"ORLY_HTTP_GUARD_ENABLED" default:"true" usage:"enable HTTP guard (bot blocking + per-IP rate limiting)"`
 209  	HTTPGuardRPM      int  `env:"ORLY_HTTP_GUARD_RPM" default:"120" usage:"max HTTP requests per minute per IP"`
 210  	HTTPGuardWSPerMin int  `env:"ORLY_HTTP_GUARD_WS_PER_MIN" default:"10" usage:"max WebSocket upgrade requests per minute per IP"`
 211  	HTTPGuardBotBlock bool `env:"ORLY_HTTP_GUARD_BOT_BLOCK" default:"true" usage:"block known scraper/bot User-Agents (SemrushBot, AhrefsBot, GPTBot, etc.)"`
 212  
 213  	// Query result limits (prevents memory exhaustion from unbounded queries)
 214  	QueryResultLimit int `env:"ORLY_QUERY_RESULT_LIMIT" default:"256" usage:"max events returned per REQ filter (prevents unbounded memory usage, 0=unlimited)"`
 215  
 216  	// Adaptive rate limiting (PID-controlled)
 217  	RateLimitEnabled            bool    `env:"ORLY_RATE_LIMIT_ENABLED" default:"true" usage:"enable adaptive PID-controlled rate limiting for database operations"`
 218  	RateLimitTargetMB           int     `env:"ORLY_RATE_LIMIT_TARGET_MB" default:"0" usage:"target memory limit in MB (0=auto-detect: 66% of available, min 500MB)"`
 219  	RateLimitWriteKp            float64 `env:"ORLY_RATE_LIMIT_WRITE_KP" default:"0.5" usage:"PID proportional gain for write operations"`
 220  	RateLimitWriteKi            float64 `env:"ORLY_RATE_LIMIT_WRITE_KI" default:"0.1" usage:"PID integral gain for write operations"`
 221  	RateLimitWriteKd            float64 `env:"ORLY_RATE_LIMIT_WRITE_KD" default:"0.05" usage:"PID derivative gain for write operations (filtered)"`
 222  	RateLimitReadKp             float64 `env:"ORLY_RATE_LIMIT_READ_KP" default:"0.3" usage:"PID proportional gain for read operations"`
 223  	RateLimitReadKi             float64 `env:"ORLY_RATE_LIMIT_READ_KI" default:"0.05" usage:"PID integral gain for read operations"`
 224  	RateLimitReadKd             float64 `env:"ORLY_RATE_LIMIT_READ_KD" default:"0.02" usage:"PID derivative gain for read operations (filtered)"`
 225  	RateLimitMaxWriteMs         int     `env:"ORLY_RATE_LIMIT_MAX_WRITE_MS" default:"1000" usage:"maximum delay for write operations in milliseconds"`
 226  	RateLimitMaxReadMs          int     `env:"ORLY_RATE_LIMIT_MAX_READ_MS" default:"500" usage:"maximum delay for read operations in milliseconds"`
 227  	RateLimitWriteTarget        float64 `env:"ORLY_RATE_LIMIT_WRITE_TARGET" default:"0.85" usage:"PID setpoint for writes (throttle when load exceeds this, 0.0-1.0)"`
 228  	RateLimitReadTarget         float64 `env:"ORLY_RATE_LIMIT_READ_TARGET" default:"0.90" usage:"PID setpoint for reads (throttle when load exceeds this, 0.0-1.0)"`
 229  	RateLimitEmergencyThreshold float64 `env:"ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD" default:"1.167" usage:"memory pressure ratio (target+1/6) to trigger emergency mode with aggressive throttling"`
 230  	RateLimitRecoveryThreshold  float64 `env:"ORLY_RATE_LIMIT_RECOVERY_THRESHOLD" default:"0.833" usage:"memory pressure ratio (target-1/6) below which emergency mode exits (hysteresis)"`
 231  	RateLimitEmergencyMaxMs     int     `env:"ORLY_RATE_LIMIT_EMERGENCY_MAX_MS" default:"5000" usage:"maximum delay for writes in emergency mode (milliseconds)"`
 232  
 233  	// TLS configuration
 234  	TLSDomains []string `env:"ORLY_TLS_DOMAINS" usage:"comma-separated list of domains to respond to for TLS"`
 235  	Certs      []string `env:"ORLY_CERTS" usage:"comma-separated list of paths to certificate root names (e.g., /path/to/cert will load /path/to/cert.pem and /path/to/cert.key)"`
 236  
 237  	// WireGuard VPN configuration (for secure bunker access)
 238  	WGEnabled  bool   `env:"ORLY_WG_ENABLED" default:"false" usage:"enable embedded WireGuard VPN server for private bunker access"`
 239  	WGPort     int    `env:"ORLY_WG_PORT" default:"51820" usage:"UDP port for WireGuard VPN server"`
 240  	WGEndpoint string `env:"ORLY_WG_ENDPOINT" usage:"public IP/domain for WireGuard endpoint (required if WG enabled)"`
 241  	WGNetwork  string `env:"ORLY_WG_NETWORK" default:"10.73.0.0/16" usage:"WireGuard internal network CIDR"`
 242  
 243  	// NIP-46 Bunker configuration (remote signing service)
 244  	BunkerEnabled bool `env:"ORLY_BUNKER_ENABLED" default:"false" usage:"enable NIP-46 bunker signing service (requires WireGuard)"`
 245  	BunkerPort    int  `env:"ORLY_BUNKER_PORT" default:"3335" usage:"internal port for bunker WebSocket (only accessible via WireGuard)"`
 246  
 247  	// Tor hidden service configuration (subprocess mode - runs tor binary automatically)
 248  	TorEnabled  bool   `env:"ORLY_TOR_ENABLED" default:"true" usage:"enable Tor hidden service (spawns tor subprocess; disable with false if tor not installed)"`
 249  	TorPort     int    `env:"ORLY_TOR_PORT" default:"3336" usage:"internal port for Tor hidden service traffic"`
 250  	TorDataDir  string `env:"ORLY_TOR_DATA_DIR" usage:"Tor data directory (default: $ORLY_DATA_DIR/tor)"`
 251  	TorBinary   string `env:"ORLY_TOR_BINARY" default:"tor" usage:"path to tor binary (default: search in PATH)"`
 252  	TorSOCKS    int    `env:"ORLY_TOR_SOCKS" default:"0" usage:"SOCKS port for outbound Tor connections (0=disabled)"`
 253  
 254  	// Nostr Relay Connect (NRC) configuration - tunnel private relay through public relay
 255  	NRCEnabled          bool   `env:"ORLY_NRC_ENABLED" default:"true" usage:"enable NRC bridge to expose this relay through a public rendezvous relay"`
 256  	NRCRendezvousURL    string `env:"ORLY_NRC_RENDEZVOUS_URL" usage:"WebSocket URL of the public relay to use as rendezvous point (e.g., wss://relay.example.com)"`
 257  	NRCAuthorizedKeys   string `env:"ORLY_NRC_AUTHORIZED_KEYS" usage:"comma-separated list of authorized client pubkeys (hex) for secret-based auth"`
 258  	NRCSessionTimeout   string `env:"ORLY_NRC_SESSION_TIMEOUT" default:"30m" usage:"inactivity timeout for NRC sessions"`
 259  
 260  	// Cluster replication configuration
 261  	ClusterPropagatePrivilegedEvents bool `env:"ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS" default:"true" usage:"propagate privileged events (DMs, gift wraps, etc.) to relay peers for replication"`
 262  
 263  	// Graph query configuration (NIP-XX)
 264  	GraphQueriesEnabled bool `env:"ORLY_GRAPH_QUERIES_ENABLED" default:"true" usage:"enable graph traversal queries (_graph filter extension)"`
 265  	GraphMaxDepth       int  `env:"ORLY_GRAPH_MAX_DEPTH" default:"16" usage:"maximum depth for graph traversal queries (1-16)"`
 266  	GraphMaxResults     int  `env:"ORLY_GRAPH_MAX_RESULTS" default:"10000" usage:"maximum pubkeys/events returned per graph query"`
 267  	GraphRateLimitRPM   int  `env:"ORLY_GRAPH_RATE_LIMIT_RPM" default:"60" usage:"graph queries per minute per connection (0=unlimited)"`
 268  
 269  	// Archive relay configuration (query augmentation from authoritative archives)
 270  	ArchiveEnabled     bool     `env:"ORLY_ARCHIVE_ENABLED" default:"false" usage:"enable archive relay query augmentation (fetch from archives, cache locally)"`
 271  	ArchiveRelays      []string `env:"ORLY_ARCHIVE_RELAYS" default:"wss://archive.orly.dev/" usage:"comma-separated list of archive relay URLs for query augmentation"`
 272  	ArchiveTimeoutSec  int      `env:"ORLY_ARCHIVE_TIMEOUT_SEC" default:"30" usage:"timeout in seconds for archive relay queries"`
 273  	ArchiveCacheTTLHrs int      `env:"ORLY_ARCHIVE_CACHE_TTL_HRS" default:"24" usage:"hours to cache query fingerprints to avoid repeated archive requests"`
 274  
 275  	// Storage management configuration (access-based garbage collection)
 276  	// TODO: GC implementation needs batch transaction handling to avoid Badger race conditions
 277  	// TODO: GC should use smaller batches with delays between transactions on large datasets
 278  	// TODO: GC deletion should be serialized or use transaction pools to prevent concurrent txn issues
 279  	MaxStorageBytes int64 `env:"ORLY_MAX_STORAGE_BYTES" default:"0" usage:"maximum storage in bytes (0=auto-detect 80%% of filesystem)"`
 280  	GCEnabled       bool  `env:"ORLY_GC_ENABLED" default:"false" usage:"enable continuous garbage collection based on access patterns (EXPERIMENTAL - may cause crashes under load)"`
 281  	GCIntervalSec   int   `env:"ORLY_GC_INTERVAL_SEC" default:"60" usage:"seconds between GC runs when storage exceeds limit"`
 282  	GCBatchSize     int   `env:"ORLY_GC_BATCH_SIZE" default:"1000" usage:"number of events to consider per GC run"`
 283  
 284  	// Email bridge configuration
 285  	BridgeEnabled bool   `env:"ORLY_BRIDGE_ENABLED" default:"false" usage:"enable Nostr-Email bridge (Marmot DM to SMTP)"`
 286  	BridgeDomain  string `env:"ORLY_BRIDGE_DOMAIN" usage:"email domain for the bridge (e.g., relay.example.com)"`
 287  	BridgeNSEC    string `env:"ORLY_BRIDGE_NSEC" usage:"bridge identity nsec (default: use relay identity from database)"`
 288  	BridgeRelayURL string `env:"ORLY_BRIDGE_RELAY_URL" usage:"WebSocket relay URL for standalone mode (e.g., wss://relay.example.com)"`
 289  	BridgeSMTPPort int    `env:"ORLY_BRIDGE_SMTP_PORT" default:"2525" usage:"SMTP server listen port"`
 290  	BridgeSMTPHost string `env:"ORLY_BRIDGE_SMTP_HOST" default:"0.0.0.0" usage:"SMTP server listen address"`
 291  	BridgeDataDir  string `env:"ORLY_BRIDGE_DATA_DIR" usage:"bridge data directory (default: $ORLY_DATA_DIR/bridge)"`
 292  	BridgeDKIMKeyPath string `env:"ORLY_BRIDGE_DKIM_KEY" usage:"path to DKIM private key PEM file"`
 293  	BridgeDKIMSelector string `env:"ORLY_BRIDGE_DKIM_SELECTOR" default:"marmot" usage:"DKIM selector for DNS TXT record"`
 294  	BridgeNWCURI    string `env:"ORLY_BRIDGE_NWC_URI" usage:"NWC connection string for subscription payments (falls back to ORLY_NWC_URI)"`
 295  	BridgeMonthlyPriceSats int64 `env:"ORLY_BRIDGE_MONTHLY_PRICE_SATS" default:"2100" usage:"price in sats for one month bridge subscription"`
 296  	BridgeComposeURL string `env:"ORLY_BRIDGE_COMPOSE_URL" usage:"public URL of the compose form (e.g., https://relay.example.com/compose)"`
 297  	BridgeSMTPRelayHost string `env:"ORLY_BRIDGE_SMTP_RELAY_HOST" usage:"SMTP smarthost for outbound delivery (e.g., smtp.migadu.com)"`
 298  	BridgeSMTPRelayPort int    `env:"ORLY_BRIDGE_SMTP_RELAY_PORT" default:"587" usage:"SMTP smarthost port (587 for STARTTLS)"`
 299  	BridgeSMTPRelayUsername string `env:"ORLY_BRIDGE_SMTP_RELAY_USERNAME" usage:"SMTP smarthost AUTH username"`
 300  	BridgeSMTPRelayPassword string `env:"ORLY_BRIDGE_SMTP_RELAY_PASSWORD" usage:"SMTP smarthost AUTH password"`
 301  	BridgeACLGRPCServer string `env:"ORLY_BRIDGE_ACL_GRPC_SERVER" usage:"gRPC address of ACL server for paid subscription management"`
 302  	BridgeAliasPriceSats int64 `env:"ORLY_BRIDGE_ALIAS_PRICE_SATS" default:"4200" usage:"monthly price in sats for alias email (default 2x base price)"`
 303  	BridgeProfile string `env:"ORLY_BRIDGE_PROFILE" usage:"path to bridge profile template file (default: $BRIDGE_DATA_DIR/profile.txt)"`
 304  
 305  	// Smesh embedded web client
 306  	SmeshEnabled bool `env:"ORLY_SMESH_ENABLED" default:"false" usage:"enable embedded Smesh web client on a dedicated port"`
 307  	SmeshPort    int  `env:"ORLY_SMESH_PORT" default:"8088" usage:"port for the embedded Smesh web client"`
 308  
 309  	// ServeMode is set programmatically by the 'serve' subcommand to grant full owner
 310  	// access to all users (no env tag - internal use only)
 311  	ServeMode bool
 312  }
 313  
 314  // New creates and initializes a new configuration object for the relay
 315  // application
 316  //
 317  // # Return Values
 318  //
 319  //   - cfg: A pointer to the initialized configuration struct containing default
 320  //     or environment-provided values
 321  //
 322  //   - err: An error object that is non-nil if any operation during
 323  //     initialization fails
 324  //
 325  // # Expected Behaviour:
 326  //
 327  // Initializes a new configuration instance by loading environment variables and
 328  // checking for a .env file in the default configuration directory. Sets logging
 329  // levels based on configuration values and returns the populated configuration
 330  // or an error if any step fails
 331  func New() (cfg *C, err error) {
 332  	cfg = &C{}
 333  	// Load .env file from config directory before parsing env vars.
 334  	// The app name determines the config dir: ~/.config/<AppName>/.env
 335  	// Real environment variables take precedence over .env values.
 336  	appName := os.Getenv("ORLY_APP_NAME")
 337  	if appName == "" {
 338  		appName = "ORLY"
 339  	}
 340  	loadDotEnv(filepath.Join(xdg.ConfigHome, appName, ".env"))
 341  	if err = env.Load(cfg, &env.Options{SliceSep: ","}); chk.T(err) {
 342  		if err != nil {
 343  			fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err)
 344  		}
 345  		PrintHelp(cfg, os.Stderr)
 346  		os.Exit(1)
 347  	}
 348  	if cfg.DataDir == "" || strings.Contains(cfg.DataDir, "~") {
 349  		cfg.DataDir = filepath.Join(xdg.DataHome, cfg.AppName)
 350  	}
 351  	if GetEnv() {
 352  		PrintEnv(cfg, os.Stdout)
 353  		os.Exit(0)
 354  	}
 355  	if HelpRequested() {
 356  		PrintHelp(cfg, os.Stderr)
 357  		os.Exit(0)
 358  	}
 359  	if cfg.LogToStdout {
 360  		lol.Writer = os.Stdout
 361  	}
 362  	// Initialize log buffer for web UI viewing
 363  	if cfg.LogBufferSize > 0 {
 364  		logbuffer.Init(cfg.LogBufferSize)
 365  		logbuffer.SetCurrentLevel(cfg.LogLevel)
 366  		lol.Writer = logbuffer.NewBufferedWriter(lol.Writer, logbuffer.GlobalBuffer)
 367  		// Reinitialize the loggers to use the new wrapped Writer
 368  		// The lol.Main logger is initialized in init() with os.Stderr directly,
 369  		// so we need to recreate it with the new Writer
 370  		l, c, e := lol.New(lol.Writer, 2)
 371  		lol.Main.Log = l
 372  		lol.Main.Check = c
 373  		lol.Main.Errorf = e
 374  		// Also update the log package convenience variables
 375  		log.F, log.E, log.W, log.I, log.D, log.T = l.F, l.E, l.W, l.I, l.D, l.T
 376  	}
 377  	lol.SetLogLevel(cfg.LogLevel)
 378  	return
 379  }
 380  
 381  // loadDotEnv reads a .env file and sets environment variables for any keys
 382  // not already present in the real environment. This allows .env files to
 383  // provide defaults while real environment variables take precedence.
 384  func loadDotEnv(path string) {
 385  	f, err := os.Open(path)
 386  	if err != nil {
 387  		return // file doesn't exist or isn't readable, not an error
 388  	}
 389  	defer f.Close()
 390  
 391  	scanner := bufio.NewScanner(f)
 392  	for scanner.Scan() {
 393  		line := strings.TrimSpace(scanner.Text())
 394  		if line == "" || line[0] == '#' {
 395  			continue
 396  		}
 397  		k, v, ok := strings.Cut(line, "=")
 398  		if !ok {
 399  			continue
 400  		}
 401  		k = strings.TrimSpace(k)
 402  		v = strings.TrimSpace(v)
 403  		// Don't override real environment variables
 404  		if _, exists := os.LookupEnv(k); exists {
 405  			continue
 406  		}
 407  		os.Setenv(k, v)
 408  	}
 409  }
 410  
 411  // HelpRequested determines if the command line arguments indicate a request for help
 412  //
 413  // # Return Values
 414  //
 415  //   - help: A boolean value indicating true if a help flag was detected in the
 416  //     command line arguments, false otherwise
 417  //
 418  // # Expected Behaviour
 419  //
 420  // The function checks the first command line argument for common help flags and
 421  // returns true if any of them are present. Returns false if no help flag is found
 422  func HelpRequested() (help bool) {
 423  	if len(os.Args) > 1 {
 424  		switch strings.ToLower(os.Args[1]) {
 425  		case "help", "-h", "--h", "-help", "--help", "?":
 426  			help = true
 427  		}
 428  	}
 429  	return
 430  }
 431  
 432  // GetEnv checks if the first command line argument is "env" and returns
 433  // whether the environment configuration should be printed.
 434  //
 435  // # Return Values
 436  //
 437  //   - requested: A boolean indicating true if the 'env' argument was
 438  //     provided, false otherwise.
 439  //
 440  // # Expected Behaviour
 441  //
 442  // The function returns true when the first command line argument is "env"
 443  // (case-insensitive), signalling that the environment configuration should be
 444  // printed. Otherwise, it returns false.
 445  func GetEnv() (requested bool) {
 446  	if len(os.Args) > 1 {
 447  		switch strings.ToLower(os.Args[1]) {
 448  		case "env":
 449  			requested = true
 450  		}
 451  	}
 452  	return
 453  }
 454  
 455  // IdentityRequested checks if the first command line argument is "identity" and returns
 456  // whether the relay identity should be printed and the program should exit.
 457  //
 458  // Return Values
 459  //   - requested: true if the 'identity' subcommand was provided, false otherwise.
 460  func IdentityRequested() (requested bool) {
 461  	if len(os.Args) > 1 {
 462  		switch strings.ToLower(os.Args[1]) {
 463  		case "identity":
 464  			requested = true
 465  		}
 466  	}
 467  	return
 468  }
 469  
 470  // ServeRequested checks if the first command line argument is "serve" and returns
 471  // whether the relay should start in ephemeral serve mode with RAM-based storage.
 472  //
 473  // Return Values
 474  //   - requested: true if the 'serve' subcommand was provided, false otherwise.
 475  func ServeRequested() (requested bool) {
 476  	if len(os.Args) > 1 {
 477  		switch strings.ToLower(os.Args[1]) {
 478  		case "serve":
 479  			requested = true
 480  		}
 481  	}
 482  	return
 483  }
 484  
 485  // VersionRequested checks if the first command line argument is "version" and returns
 486  // whether the version should be printed and the program should exit.
 487  //
 488  // Return Values
 489  //   - requested: true if the 'version' subcommand was provided, false otherwise.
 490  func VersionRequested() (requested bool) {
 491  	if len(os.Args) > 1 {
 492  		switch strings.ToLower(os.Args[1]) {
 493  		case "version", "-v", "--v", "-version", "--version":
 494  			requested = true
 495  		}
 496  	}
 497  	return
 498  }
 499  
 500  // CuratingModeRequested checks if the first command line argument is "curatingmode"
 501  // and returns the owner npub/hex pubkey if provided.
 502  //
 503  // Return Values
 504  //   - requested: true if the 'curatingmode' subcommand was provided
 505  //   - ownerKey: the npub or hex pubkey provided as the second argument (empty if not provided)
 506  func CuratingModeRequested() (requested bool, ownerKey string) {
 507  	if len(os.Args) > 1 {
 508  		switch strings.ToLower(os.Args[1]) {
 509  		case "curatingmode":
 510  			requested = true
 511  			if len(os.Args) > 2 {
 512  				ownerKey = os.Args[2]
 513  			}
 514  		}
 515  	}
 516  	return
 517  }
 518  
 519  // MigrateRequested checks if the first command line argument is "migrate"
 520  // and returns the migration parameters.
 521  //
 522  // Return Values
 523  //   - requested: true if the 'migrate' subcommand was provided
 524  //   - fromType: source database type (badger, bbolt, neo4j)
 525  //   - toType: destination database type
 526  //   - targetPath: optional target path for destination database
 527  func MigrateRequested() (requested bool, fromType, toType, targetPath string) {
 528  	if len(os.Args) > 1 {
 529  		switch strings.ToLower(os.Args[1]) {
 530  		case "migrate":
 531  			requested = true
 532  			// Parse --from, --to, --target-path flags
 533  			for i := 2; i < len(os.Args); i++ {
 534  				arg := os.Args[i]
 535  				switch {
 536  				case strings.HasPrefix(arg, "--from="):
 537  					fromType = strings.TrimPrefix(arg, "--from=")
 538  				case strings.HasPrefix(arg, "--to="):
 539  					toType = strings.TrimPrefix(arg, "--to=")
 540  				case strings.HasPrefix(arg, "--target-path="):
 541  					targetPath = strings.TrimPrefix(arg, "--target-path=")
 542  				case arg == "--from" && i+1 < len(os.Args):
 543  					i++
 544  					fromType = os.Args[i]
 545  				case arg == "--to" && i+1 < len(os.Args):
 546  					i++
 547  					toType = os.Args[i]
 548  				case arg == "--target-path" && i+1 < len(os.Args):
 549  					i++
 550  					targetPath = os.Args[i]
 551  				}
 552  			}
 553  		}
 554  	}
 555  	return
 556  }
 557  
 558  // NRCRequested checks if the first command line argument is "nrc" and returns
 559  // the NRC subcommand parameters.
 560  //
 561  // Return Values
 562  //   - requested: true if the 'nrc' subcommand was provided
 563  //   - subcommand: the NRC subcommand (generate, list, revoke)
 564  //   - args: additional arguments for the subcommand
 565  func NRCRequested() (requested bool, subcommand string, args []string) {
 566  	if len(os.Args) > 1 {
 567  		switch strings.ToLower(os.Args[1]) {
 568  		case "nrc":
 569  			requested = true
 570  			if len(os.Args) > 2 {
 571  				subcommand = strings.ToLower(os.Args[2])
 572  				if len(os.Args) > 3 {
 573  					args = os.Args[3:]
 574  				}
 575  			}
 576  		}
 577  	}
 578  	return
 579  }
 580  
 581  // InitBrandingRequested checks if the first command line argument is "init-branding"
 582  // and returns the target directory and style if provided.
 583  //
 584  // Return Values
 585  //   - requested: true if the 'init-branding' subcommand was provided
 586  //   - targetDir: optional target directory for branding files (default: ~/.config/ORLY/branding)
 587  //   - style: branding style ("orly" or "generic", default: "generic")
 588  //
 589  // Usage: orly init-branding [--style orly|generic] [path]
 590  func InitBrandingRequested() (requested bool, targetDir, style string) {
 591  	style = "generic" // default to generic/white-label
 592  	if len(os.Args) > 1 {
 593  		switch strings.ToLower(os.Args[1]) {
 594  		case "init-branding":
 595  			requested = true
 596  			// Parse remaining arguments
 597  			for i := 2; i < len(os.Args); i++ {
 598  				arg := os.Args[i]
 599  				if arg == "--style" && i+1 < len(os.Args) {
 600  					style = strings.ToLower(os.Args[i+1])
 601  					i++ // skip next arg
 602  				} else if !strings.HasPrefix(arg, "-") {
 603  					targetDir = arg
 604  				}
 605  			}
 606  		}
 607  	}
 608  	return
 609  }
 610  
 611  // KV is a key/value pair.
 612  type KV struct{ Key, Value string }
 613  
 614  // KVSlice is a sortable slice of key/value pairs, designed for managing
 615  // configuration data and enabling operations like merging and sorting based on
 616  // keys.
 617  type KVSlice []KV
 618  
 619  func (kv KVSlice) Len() int           { return len(kv) }
 620  func (kv KVSlice) Less(i, j int) bool { return kv[i].Key < kv[j].Key }
 621  func (kv KVSlice) Swap(i, j int)      { kv[i], kv[j] = kv[j], kv[i] }
 622  
 623  // Compose merges two KVSlice instances into a new slice where key-value pairs
 624  // from the second slice override any duplicate keys from the first slice.
 625  //
 626  // # Parameters
 627  //
 628  //   - kv2: The second KVSlice whose entries will be merged with the receiver.
 629  //
 630  // # Return Values
 631  //
 632  //   - out: A new KVSlice containing all entries from both slices, with keys
 633  //     from kv2 taking precedence over keys from the receiver.
 634  //
 635  // # Expected Behaviour
 636  //
 637  // The method returns a new KVSlice that combines the contents of the receiver
 638  // and kv2. If any key exists in both slices, the value from kv2 is used. The
 639  // resulting slice remains sorted by keys as per the KVSlice implementation.
 640  func (kv KVSlice) Compose(kv2 KVSlice) (out KVSlice) {
 641  	// duplicate the initial KVSlice
 642  	out = append(out, kv...)
 643  out:
 644  	for i, p := range kv2 {
 645  		for j, q := range out {
 646  			// if the key is repeated, replace the value
 647  			if p.Key == q.Key {
 648  				out[j].Value = kv2[i].Value
 649  				continue out
 650  			}
 651  		}
 652  		out = append(out, p)
 653  	}
 654  	return
 655  }
 656  
 657  // EnvKV generates key/value pairs from a configuration object's struct tags
 658  //
 659  // # Parameters
 660  //
 661  //   - cfg: A configuration object whose struct fields are processed for env tags
 662  //
 663  // # Return Values
 664  //
 665  //   - m: A KVSlice containing key/value pairs derived from the config's env tags
 666  //
 667  // # Expected Behaviour
 668  //
 669  // Processes each field of the config object, extracting values tagged with
 670  // "env" and converting them to strings. Skips fields without an "env" tag.
 671  // Handles various value types including strings, integers, booleans, durations,
 672  // and string slices by joining elements with commas.
 673  func EnvKV(cfg any) (m KVSlice) {
 674  	t := reflect.TypeOf(cfg)
 675  	for i := 0; i < t.NumField(); i++ {
 676  		k := t.Field(i).Tag.Get("env")
 677  		v := reflect.ValueOf(cfg).Field(i).Interface()
 678  		var val string
 679  		switch v := v.(type) {
 680  		case string:
 681  			val = v
 682  		case int, bool, time.Duration:
 683  			val = fmt.Sprint(v)
 684  		case []string:
 685  			if len(v) > 0 {
 686  				val = strings.Join(v, ",")
 687  			}
 688  		}
 689  		// this can happen with embedded structs
 690  		if k == "" {
 691  			continue
 692  		}
 693  		m = append(m, KV{k, val})
 694  	}
 695  	return
 696  }
 697  
 698  // PrintEnv outputs sorted environment key/value pairs from a configuration object
 699  // to the provided writer
 700  //
 701  // # Parameters
 702  //
 703  //   - cfg: Pointer to the configuration object containing env tags
 704  //
 705  //   - printer: Destination for the output, typically an io.Writer implementation
 706  //
 707  // # Expected Behaviour
 708  //
 709  // Outputs each environment variable derived from the config's struct tags in
 710  // sorted order, formatted as "key=value\n" to the specified writer
 711  func PrintEnv(cfg *C, printer io.Writer) {
 712  	kvs := EnvKV(*cfg)
 713  	sort.Sort(kvs)
 714  	for _, v := range kvs {
 715  		_, _ = fmt.Fprintf(printer, "%s=%s\n", v.Key, v.Value)
 716  	}
 717  }
 718  
 719  // PrintHelp prints help information including application version, environment
 720  // variable configuration, and details about .env file handling to the provided
 721  // writer
 722  //
 723  // # Parameters
 724  //
 725  //   - cfg: Configuration object containing app name and config directory path
 726  //
 727  //   - printer: Output destination for the help text
 728  //
 729  // # Expected Behaviour
 730  //
 731  // Prints application name and version followed by environment variable
 732  // configuration details, explains .env file behaviour including automatic
 733  // loading and custom path options, and displays current configuration values
 734  // using PrintEnv. Outputs all information to the specified writer
 735  func PrintHelp(cfg *C, printer io.Writer) {
 736  	_, _ = fmt.Fprintf(
 737  		printer,
 738  		"%s %s\n\n", cfg.AppName, version.V,
 739  	)
 740  	_, _ = fmt.Fprintf(
 741  		printer,
 742  		`Usage: %s [env|help|identity|init-branding|migrate|serve|version]
 743  
 744  - env: print environment variables configuring %s
 745  - help: print this help text
 746  - identity: print the relay identity secret and public key
 747  - init-branding: create branding directory with default assets and CSS templates
 748             Example: %s init-branding [--style generic|orly] [/path/to/branding]
 749             Styles: generic (default) - neutral white-label branding
 750                     orly - ORLY-branded assets
 751             Default location: ~/.config/%s/branding
 752  - migrate: migrate data between database backends
 753             Example: %s migrate --from badger --to neo4j
 754  - serve: start ephemeral relay with RAM-based storage at /dev/shm/orlyserve
 755           listening on 0.0.0.0:10547 with 'none' ACL mode (open relay)
 756           useful for testing and benchmarking
 757  - version: print version and exit (also: -v, --v, -version, --version)
 758  
 759  `,
 760  		cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName,
 761  	)
 762  	_, _ = fmt.Fprintf(
 763  		printer,
 764  		"Environment variables that configure %s:\n\n", cfg.AppName,
 765  	)
 766  	env.Usage(cfg, printer, &env.Options{SliceSep: ","})
 767  	fmt.Fprintf(printer, "\ncurrent configuration:\n\n")
 768  	PrintEnv(cfg, printer)
 769  	fmt.Fprintln(printer)
 770  }
 771  
 772  // GetDatabaseConfigValues returns the database configuration values as individual fields.
 773  // This avoids circular imports with pkg/database while allowing main.go to construct
 774  // a database.DatabaseConfig with the correct type.
 775  func (cfg *C) GetDatabaseConfigValues() (
 776  	dataDir, logLevel string,
 777  	blockCacheMB, indexCacheMB, queryCacheSizeMB int,
 778  	queryCacheMaxAge time.Duration,
 779  	queryCacheDisabled bool,
 780  	serialCachePubkeys, serialCacheEventIds int,
 781  	zstdLevel int,
 782  	neo4jURI, neo4jUser, neo4jPassword string,
 783  	neo4jMaxConnPoolSize, neo4jFetchSize, neo4jMaxTxRetrySeconds, neo4jQueryResultLimit int,
 784  ) {
 785  	// Parse query cache max age from string to duration
 786  	queryCacheMaxAge = 5 * time.Minute // Default
 787  	if cfg.QueryCacheMaxAge != "" {
 788  		if duration, err := time.ParseDuration(cfg.QueryCacheMaxAge); err == nil {
 789  			queryCacheMaxAge = duration
 790  		}
 791  	}
 792  
 793  	return cfg.DataDir, cfg.DBLogLevel,
 794  		cfg.DBBlockCacheMB, cfg.DBIndexCacheMB, cfg.QueryCacheSizeMB,
 795  		queryCacheMaxAge,
 796  		cfg.QueryCacheDisabled,
 797  		cfg.SerialCachePubkeys, cfg.SerialCacheEventIds,
 798  		cfg.DBZSTDLevel,
 799  		cfg.Neo4jURI, cfg.Neo4jUser, cfg.Neo4jPassword,
 800  		cfg.Neo4jMaxConnPoolSize, cfg.Neo4jFetchSize, cfg.Neo4jMaxTxRetrySeconds, cfg.Neo4jQueryResultLimit
 801  }
 802  
 803  // GetRateLimitConfigValues returns the rate limiting configuration values.
 804  // This avoids circular imports with pkg/ratelimit while allowing main.go to construct
 805  // a ratelimit.Config with the correct type.
 806  func (cfg *C) GetRateLimitConfigValues() (
 807  	enabled bool,
 808  	targetMB int,
 809  	writeKp, writeKi, writeKd float64,
 810  	readKp, readKi, readKd float64,
 811  	maxWriteMs, maxReadMs int,
 812  	writeTarget, readTarget float64,
 813  	emergencyThreshold, recoveryThreshold float64,
 814  	emergencyMaxMs int,
 815  ) {
 816  	return cfg.RateLimitEnabled,
 817  		cfg.RateLimitTargetMB,
 818  		cfg.RateLimitWriteKp, cfg.RateLimitWriteKi, cfg.RateLimitWriteKd,
 819  		cfg.RateLimitReadKp, cfg.RateLimitReadKi, cfg.RateLimitReadKd,
 820  		cfg.RateLimitMaxWriteMs, cfg.RateLimitMaxReadMs,
 821  		cfg.RateLimitWriteTarget, cfg.RateLimitReadTarget,
 822  		cfg.RateLimitEmergencyThreshold, cfg.RateLimitRecoveryThreshold,
 823  		cfg.RateLimitEmergencyMaxMs
 824  }
 825  
 826  // GetWireGuardConfigValues returns the WireGuard VPN configuration values.
 827  // This avoids circular imports with pkg/wireguard while allowing main.go to construct
 828  // the WireGuard server configuration.
 829  func (cfg *C) GetWireGuardConfigValues() (
 830  	enabled bool,
 831  	port int,
 832  	endpoint string,
 833  	network string,
 834  	bunkerEnabled bool,
 835  	bunkerPort int,
 836  ) {
 837  	return cfg.WGEnabled,
 838  		cfg.WGPort,
 839  		cfg.WGEndpoint,
 840  		cfg.WGNetwork,
 841  		cfg.BunkerEnabled,
 842  		cfg.BunkerPort
 843  }
 844  
 845  // GetArchiveConfigValues returns the archive relay configuration values.
 846  // This avoids circular imports with pkg/archive while allowing main.go to construct
 847  // the archive manager configuration.
 848  func (cfg *C) GetArchiveConfigValues() (
 849  	enabled bool,
 850  	relays []string,
 851  	timeoutSec int,
 852  	cacheTTLHrs int,
 853  ) {
 854  	return cfg.ArchiveEnabled,
 855  		cfg.ArchiveRelays,
 856  		cfg.ArchiveTimeoutSec,
 857  		cfg.ArchiveCacheTTLHrs
 858  }
 859  
 860  // GetStorageConfigValues returns the storage management configuration values.
 861  // This avoids circular imports with pkg/storage while allowing main.go to construct
 862  // the garbage collector and access tracker configuration.
 863  func (cfg *C) GetStorageConfigValues() (
 864  	maxStorageBytes int64,
 865  	gcEnabled bool,
 866  	gcIntervalSec int,
 867  	gcBatchSize int,
 868  ) {
 869  	return cfg.MaxStorageBytes,
 870  		cfg.GCEnabled,
 871  		cfg.GCIntervalSec,
 872  		cfg.GCBatchSize
 873  }
 874  
 875  // GetTorConfigValues returns the Tor hidden service configuration values.
 876  // This avoids circular imports with pkg/tor while allowing main.go to construct
 877  // the Tor service configuration.
 878  func (cfg *C) GetTorConfigValues() (
 879  	enabled bool,
 880  	port int,
 881  	dataDir string,
 882  	binary string,
 883  	socksPort int,
 884  ) {
 885  	dataDir = cfg.TorDataDir
 886  	if dataDir == "" {
 887  		dataDir = filepath.Join(cfg.DataDir, "tor")
 888  	}
 889  	return cfg.TorEnabled,
 890  		cfg.TorPort,
 891  		dataDir,
 892  		cfg.TorBinary,
 893  		cfg.TorSOCKS
 894  }
 895  
 896  // GetGraphConfigValues returns the graph query configuration values.
 897  // This avoids circular imports with pkg/protocol/graph while allowing main.go
 898  // to construct the graph executor configuration.
 899  func (cfg *C) GetGraphConfigValues() (
 900  	enabled bool,
 901  	maxDepth int,
 902  	maxResults int,
 903  	rateLimitRPM int,
 904  ) {
 905  	maxDepth = cfg.GraphMaxDepth
 906  	if maxDepth < 1 {
 907  		maxDepth = 1
 908  	}
 909  	if maxDepth > 16 {
 910  		maxDepth = 16
 911  	}
 912  	return cfg.GraphQueriesEnabled,
 913  		maxDepth,
 914  		cfg.GraphMaxResults,
 915  		cfg.GraphRateLimitRPM
 916  }
 917  
 918  // GetNRCConfigValues returns the NRC (Nostr Relay Connect) configuration values.
 919  // This avoids circular imports with pkg/protocol/nrc while allowing main.go to construct
 920  // the NRC bridge configuration.
 921  func (cfg *C) GetNRCConfigValues() (
 922  	enabled bool,
 923  	rendezvousURL string,
 924  	authorizedKeys []string,
 925  	sessionTimeout time.Duration,
 926  ) {
 927  	// Parse session timeout
 928  	sessionTimeout = 30 * time.Minute // Default
 929  	if cfg.NRCSessionTimeout != "" {
 930  		if d, err := time.ParseDuration(cfg.NRCSessionTimeout); err == nil {
 931  			sessionTimeout = d
 932  		}
 933  	}
 934  
 935  	// Parse authorized keys
 936  	if cfg.NRCAuthorizedKeys != "" {
 937  		keys := strings.Split(cfg.NRCAuthorizedKeys, ",")
 938  		for _, k := range keys {
 939  			k = strings.TrimSpace(k)
 940  			if k != "" {
 941  				authorizedKeys = append(authorizedKeys, k)
 942  			}
 943  		}
 944  	}
 945  
 946  	// Use explicit rendezvous URL if set, otherwise use relay's own address for self-rendezvous
 947  	rendezvousURL = cfg.NRCRendezvousURL
 948  	if rendezvousURL == "" && len(cfg.RelayAddresses) > 0 {
 949  		rendezvousURL = cfg.RelayAddresses[0]
 950  	}
 951  
 952  	return cfg.NRCEnabled,
 953  		rendezvousURL,
 954  		authorizedKeys,
 955  		sessionTimeout
 956  }
 957  
 958  // GetFollowsThrottleConfigValues returns the progressive throttle configuration values
 959  // for the follows ACL mode. This allows non-followed users to write with increasing delay.
 960  func (cfg *C) GetFollowsThrottleConfigValues() (
 961  	enabled bool,
 962  	perEvent time.Duration,
 963  	maxDelay time.Duration,
 964  ) {
 965  	return cfg.FollowsThrottleEnabled,
 966  		cfg.FollowsThrottlePerEvent,
 967  		cfg.FollowsThrottleMaxDelay
 968  }
 969  
 970  // GetSocialConfigValues returns the social ACL mode configuration values.
 971  func (cfg *C) GetSocialConfigValues() (
 972  	d2Increment, d2Max, d3Increment, d3Max, outsiderIncrement, outsiderMax time.Duration,
 973  	wotMaxDepth int,
 974  	wotRefresh time.Duration,
 975  ) {
 976  	return cfg.SocialThrottleD2Increment,
 977  		cfg.SocialThrottleD2Max,
 978  		cfg.SocialThrottleD3Increment,
 979  		cfg.SocialThrottleD3Max,
 980  		cfg.SocialThrottleOutsiderIncrement,
 981  		cfg.SocialThrottleOutsiderMax,
 982  		cfg.SocialWoTMaxDepth,
 983  		cfg.SocialWoTRefreshInterval
 984  }
 985  
 986  // GetGRPCConfigValues returns the gRPC database client configuration values.
 987  // This avoids circular imports with pkg/database/grpc while allowing main.go to construct
 988  // the gRPC client configuration.
 989  func (cfg *C) GetGRPCConfigValues() (
 990  	serverAddress string,
 991  	connectTimeout time.Duration,
 992  ) {
 993  	return cfg.GRPCServerAddress,
 994  		cfg.GRPCConnectTimeout
 995  }
 996  
 997  // GetGRPCACLConfigValues returns the gRPC ACL client configuration values.
 998  // This avoids circular imports with pkg/acl/grpc while allowing main.go to construct
 999  // the gRPC ACL client configuration.
1000  func (cfg *C) GetGRPCACLConfigValues() (
1001  	aclType string,
1002  	serverAddress string,
1003  	connectTimeout time.Duration,
1004  ) {
1005  	return cfg.ACLType,
1006  		cfg.GRPCACLServerAddress,
1007  		cfg.GRPCACLConnectTimeout
1008  }
1009  
1010  // GetGRPCSyncConfigValues returns the gRPC sync client configuration values.
1011  // This avoids circular imports with pkg/sync/*/grpc packages while allowing main.go
1012  // to construct the gRPC sync client configurations.
1013  func (cfg *C) GetGRPCSyncConfigValues() (
1014  	syncType string,
1015  	distributedAddress string,
1016  	clusterAddress string,
1017  	relayGroupAddress string,
1018  	negentropyAddress string,
1019  	connectTimeout time.Duration,
1020  	negentropyEnabled bool,
1021  ) {
1022  	return cfg.SyncType,
1023  		cfg.GRPCSyncDistributedAddress,
1024  		cfg.GRPCSyncClusterAddress,
1025  		cfg.GRPCSyncRelayGroupAddress,
1026  		cfg.GRPCSyncNegentropyAddress,
1027  		cfg.GRPCSyncConnectTimeout,
1028  		cfg.NegentropyEnabled
1029  }
1030  
1031  // GetBridgeConfigValues returns the email bridge configuration values.
1032  // This avoids circular imports with pkg/bridge while allowing main.go to construct
1033  // the bridge configuration.
1034  func (cfg *C) GetBridgeConfigValues() (
1035  	enabled bool,
1036  	domain string,
1037  	nsec string,
1038  	relayURL string,
1039  	smtpPort int,
1040  	smtpHost string,
1041  	dataDir string,
1042  	dkimKeyPath string,
1043  	dkimSelector string,
1044  	nwcURI string,
1045  	monthlyPriceSats int64,
1046  	composeURL string,
1047  	smtpRelayHost string,
1048  	smtpRelayPort int,
1049  	smtpRelayUsername string,
1050  	smtpRelayPassword string,
1051  	aclGRPCServer string,
1052  	aliasPriceSats int64,
1053  	profilePath string,
1054  ) {
1055  	dataDir = cfg.BridgeDataDir
1056  	if dataDir == "" {
1057  		dataDir = filepath.Join(cfg.DataDir, "bridge")
1058  	}
1059  	// Fall back to relay NWC URI if bridge-specific not set
1060  	nwcURI = cfg.BridgeNWCURI
1061  	if nwcURI == "" {
1062  		nwcURI = cfg.NWCUri
1063  	}
1064  	return cfg.BridgeEnabled,
1065  		cfg.BridgeDomain,
1066  		cfg.BridgeNSEC,
1067  		cfg.BridgeRelayURL,
1068  		cfg.BridgeSMTPPort,
1069  		cfg.BridgeSMTPHost,
1070  		dataDir,
1071  		cfg.BridgeDKIMKeyPath,
1072  		cfg.BridgeDKIMSelector,
1073  		nwcURI,
1074  		cfg.BridgeMonthlyPriceSats,
1075  		cfg.BridgeComposeURL,
1076  		cfg.BridgeSMTPRelayHost,
1077  		cfg.BridgeSMTPRelayPort,
1078  		cfg.BridgeSMTPRelayUsername,
1079  		cfg.BridgeSMTPRelayPassword,
1080  		cfg.BridgeACLGRPCServer,
1081  		cfg.BridgeAliasPriceSats,
1082  		func() string {
1083  			if cfg.BridgeProfile != "" {
1084  				return cfg.BridgeProfile
1085  			}
1086  			return filepath.Join(dataDir, "profile.txt")
1087  		}()
1088  }
1089