summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-14 10:17:54 -0800
committerbndw <ben@bdw.to>2026-02-14 10:17:54 -0800
commit702fa6c37b9f74e75404a0ea8e6f9023841143de (patch)
treea3d686b0252c0c889696dc745b37810af73e46b7 /internal
parent606e0a3329a3534a00889eee19c25e7d432f7d2d (diff)
refactor: remove frivolous comments from auth and config
Removed ~100 lines of obvious comments that just repeated what the code does. Kept only comments that add clarity or valuable detail.
Diffstat (limited to 'internal')
-rw-r--r--internal/auth/interceptor.go78
-rw-r--r--internal/config/config.go53
2 files changed, 16 insertions, 115 deletions
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go
index d394102..42c2688 100644
--- a/internal/auth/interceptor.go
+++ b/internal/auth/interceptor.go
@@ -11,52 +11,28 @@ import (
11 "google.golang.org/grpc/status" 11 "google.golang.org/grpc/status"
12) 12)
13 13
14// contextKey is a custom type for context keys to avoid collisions.
15type contextKey string 14type contextKey string
16 15
17const ( 16const (
18 // pubkeyContextKey is the key for storing the authenticated pubkey in context.
19 pubkeyContextKey contextKey = "nostr-pubkey" 17 pubkeyContextKey contextKey = "nostr-pubkey"
20) 18)
21 19
22// InterceptorOptions configures the authentication interceptor behavior.
23type InterceptorOptions struct { 20type InterceptorOptions struct {
24 // Read configures authentication for read operations (Subscribe, QueryEvents, etc.) 21 Read OperationAuthConfig
25 Read OperationAuthConfig 22 Write OperationAuthConfig
26
27 // Write configures authentication for write operations (PublishEvent, PublishBatch)
28 Write OperationAuthConfig
29
30 // TimestampWindow is the maximum age of auth events in seconds.
31 // Events older than this are rejected to prevent replay attacks.
32 // Default: 60 seconds
33 TimestampWindow int64 23 TimestampWindow int64
34
35 // ValidatePayload checks the payload hash tag if present.
36 // Default: false
37 ValidatePayload bool 24 ValidatePayload bool
38 25 SkipMethods []string
39 // SkipMethods is a list of gRPC methods that bypass authentication.
40 // Useful for public endpoints like health checks or relay info.
41 // Example: []string{"/grpc.health.v1.Health/Check"}
42 // Default: nil (authenticate all methods)
43 SkipMethods []string
44} 26}
45 27
46// OperationAuthConfig configures auth for a specific operation type. 28// OperationAuthConfig configures auth for read or write operations.
29// Three states: disabled (allow all), enabled with empty list (require auth),
30// enabled with npubs (whitelist only). Npubs normalized to hex at load time.
47type OperationAuthConfig struct { 31type OperationAuthConfig struct {
48 // Enabled determines if auth is required. 32 Enabled bool
49 // false = no auth, allow all
50 // true = auth required
51 Enabled bool
52
53 // AllowedNpubs is an optional whitelist (hex format, normalized from npub at config load).
54 // If Enabled=true && AllowedNpubs=[]: any valid signature accepted
55 // If Enabled=true && AllowedNpubs=[...]: only whitelisted npubs accepted
56 AllowedNpubs []string 33 AllowedNpubs []string
57} 34}
58 35
59// DefaultInterceptorOptions returns the default configuration.
60func DefaultInterceptorOptions() *InterceptorOptions { 36func DefaultInterceptorOptions() *InterceptorOptions {
61 return &InterceptorOptions{ 37 return &InterceptorOptions{
62 Read: OperationAuthConfig{ 38 Read: OperationAuthConfig{
@@ -73,19 +49,16 @@ func DefaultInterceptorOptions() *InterceptorOptions {
73 } 49 }
74} 50}
75 51
76// NostrUnaryInterceptor creates a gRPC unary interceptor for NIP-98 authentication.
77func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor { 52func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor {
78 if opts == nil { 53 if opts == nil {
79 opts = DefaultInterceptorOptions() 54 opts = DefaultInterceptorOptions()
80 } 55 }
81 56
82 return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { 57 return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
83 // Check if this method should skip auth
84 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { 58 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) {
85 return handler(ctx, req) 59 return handler(ctx, req)
86 } 60 }
87 61
88 // Check if auth is required for this operation type
89 var authRequired bool 62 var authRequired bool
90 if isWriteMethod(info.FullMethod) { 63 if isWriteMethod(info.FullMethod) {
91 authRequired = opts.Write.Enabled 64 authRequired = opts.Write.Enabled
@@ -93,37 +66,31 @@ func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor
93 authRequired = opts.Read.Enabled 66 authRequired = opts.Read.Enabled
94 } 67 }
95 68
96 // If auth not required, skip validation
97 if !authRequired { 69 if !authRequired {
98 return handler(ctx, req) 70 return handler(ctx, req)
99 } 71 }
100 72
101 // Extract and validate auth
102 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) 73 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
103 if err != nil { 74 if err != nil {
104 return nil, status.Error(codes.Unauthenticated, err.Error()) 75 return nil, status.Error(codes.Unauthenticated, err.Error())
105 } 76 }
106 77
107 // Add pubkey to context for handlers
108 ctx = context.WithValue(ctx, pubkeyContextKey, pubkey) 78 ctx = context.WithValue(ctx, pubkeyContextKey, pubkey)
109 79
110 return handler(ctx, req) 80 return handler(ctx, req)
111 } 81 }
112} 82}
113 83
114// NostrStreamInterceptor creates a gRPC stream interceptor for NIP-98 authentication.
115func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor { 84func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor {
116 if opts == nil { 85 if opts == nil {
117 opts = DefaultInterceptorOptions() 86 opts = DefaultInterceptorOptions()
118 } 87 }
119 88
120 return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { 89 return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
121 // Check if this method should skip auth
122 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { 90 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) {
123 return handler(srv, ss) 91 return handler(srv, ss)
124 } 92 }
125 93
126 // Check if auth is required for this operation type
127 var authRequired bool 94 var authRequired bool
128 if isWriteMethod(info.FullMethod) { 95 if isWriteMethod(info.FullMethod) {
129 authRequired = opts.Write.Enabled 96 authRequired = opts.Write.Enabled
@@ -131,19 +98,16 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept
131 authRequired = opts.Read.Enabled 98 authRequired = opts.Read.Enabled
132 } 99 }
133 100
134 // If auth not required, skip validation
135 if !authRequired { 101 if !authRequired {
136 return handler(srv, ss) 102 return handler(srv, ss)
137 } 103 }
138 104
139 // Extract and validate auth
140 ctx := ss.Context() 105 ctx := ss.Context()
141 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) 106 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
142 if err != nil { 107 if err != nil {
143 return status.Error(codes.Unauthenticated, err.Error()) 108 return status.Error(codes.Unauthenticated, err.Error())
144 } 109 }
145 110
146 // Wrap stream with authenticated context
147 wrappedStream := &authenticatedStream{ 111 wrappedStream := &authenticatedStream{
148 ServerStream: ss, 112 ServerStream: ss,
149 ctx: context.WithValue(ctx, pubkeyContextKey, pubkey), 113 ctx: context.WithValue(ctx, pubkeyContextKey, pubkey),
@@ -153,8 +117,7 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept
153 } 117 }
154} 118}
155 119
156// authenticatedStream wraps a ServerStream with an authenticated context. 120type authenticatedStream struct{
157type authenticatedStream struct {
158 grpc.ServerStream 121 grpc.ServerStream
159 ctx context.Context 122 ctx context.Context
160} 123}
@@ -163,45 +126,34 @@ func (s *authenticatedStream) Context() context.Context {
163 return s.ctx 126 return s.ctx
164} 127}
165 128
166// validateAuthFromContext extracts and validates the NIP-98 auth event from the context.
167func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) { 129func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) {
168 // Extract metadata from context
169 md, ok := metadata.FromIncomingContext(ctx) 130 md, ok := metadata.FromIncomingContext(ctx)
170 if !ok { 131 if !ok {
171 return "", fmt.Errorf("missing metadata") 132 return "", fmt.Errorf("missing metadata")
172 } 133 }
173 134
174 // Get authorization header
175 authHeaders := md.Get("authorization") 135 authHeaders := md.Get("authorization")
176 if len(authHeaders) == 0 { 136 if len(authHeaders) == 0 {
177 return "", fmt.Errorf("missing authorization header") 137 return "", fmt.Errorf("missing authorization header")
178 } 138 }
179 139
180 authHeader := authHeaders[0] 140 event, err := ParseAuthHeader(authHeaders[0])
181
182 // Parse the auth event
183 event, err := ParseAuthHeader(authHeader)
184 if err != nil { 141 if err != nil {
185 return "", fmt.Errorf("invalid auth header: %w", err) 142 return "", fmt.Errorf("invalid auth header: %w", err)
186 } 143 }
187 144
188 // Validate the event
189 validationOpts := ValidationOptions{ 145 validationOpts := ValidationOptions{
190 TimestampWindow: opts.TimestampWindow, 146 TimestampWindow: opts.TimestampWindow,
191 ValidatePayload: opts.ValidatePayload, 147 ValidatePayload: opts.ValidatePayload,
192 ExpectedMethod: "POST", // gRPC always uses POST 148 ExpectedMethod: "POST",
193 // Note: We don't validate URI here because the full URI isn't easily
194 // available in the interceptor context. The method name is validated instead.
195 } 149 }
196 150
197 if err := ValidateAuthEvent(event, validationOpts); err != nil { 151 if err := ValidateAuthEvent(event, validationOpts); err != nil {
198 return "", fmt.Errorf("invalid auth event: %w", err) 152 return "", fmt.Errorf("invalid auth event: %w", err)
199 } 153 }
200 154
201 // Extract pubkey
202 pubkey := ExtractPubkey(event) 155 pubkey := ExtractPubkey(event)
203 156
204 // Get the operation config based on method type
205 var opConfig OperationAuthConfig 157 var opConfig OperationAuthConfig
206 if isWriteMethod(method) { 158 if isWriteMethod(method) {
207 opConfig = opts.Write 159 opConfig = opts.Write
@@ -209,7 +161,6 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept
209 opConfig = opts.Read 161 opConfig = opts.Read
210 } 162 }
211 163
212 // Check whitelist if configured
213 if len(opConfig.AllowedNpubs) > 0 { 164 if len(opConfig.AllowedNpubs) > 0 {
214 if !contains(opConfig.AllowedNpubs, pubkey) { 165 if !contains(opConfig.AllowedNpubs, pubkey) {
215 if isWriteMethod(method) { 166 if isWriteMethod(method) {
@@ -219,18 +170,13 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept
219 } 170 }
220 } 171 }
221 172
222 // No whitelist or pubkey in whitelist - allow
223 return pubkey, nil 173 return pubkey, nil
224} 174}
225 175
226// isWriteMethod determines if a gRPC method is a write operation.
227// Write: PublishEvent, PublishBatch
228// Read: Subscribe, Unsubscribe, QueryEvents, CountEvents
229func isWriteMethod(method string) bool { 176func isWriteMethod(method string) bool {
230 return strings.Contains(method, "/PublishEvent") || strings.Contains(method, "/PublishBatch") 177 return strings.Contains(method, "/PublishEvent") || strings.Contains(method, "/PublishBatch")
231} 178}
232 179
233// shouldSkipAuth checks if a method should bypass authentication.
234func shouldSkipAuth(method string, skipMethods []string) bool { 180func shouldSkipAuth(method string, skipMethods []string) bool {
235 for _, skip := range skipMethods { 181 for _, skip := range skipMethods {
236 if skip == method { 182 if skip == method {
@@ -240,7 +186,6 @@ func shouldSkipAuth(method string, skipMethods []string) bool {
240 return false 186 return false
241} 187}
242 188
243// contains checks if a slice contains a string.
244func contains(slice []string, item string) bool { 189func contains(slice []string, item string) bool {
245 for _, s := range slice { 190 for _, s := range slice {
246 if s == item { 191 if s == item {
@@ -250,14 +195,11 @@ func contains(slice []string, item string) bool {
250 return false 195 return false
251} 196}
252 197
253// PubkeyFromContext retrieves the authenticated pubkey from the context.
254// Returns the pubkey and true if authentication was successful, or empty string and false otherwise.
255func PubkeyFromContext(ctx context.Context) (string, bool) { 198func PubkeyFromContext(ctx context.Context) (string, bool) {
256 pubkey, ok := ctx.Value(pubkeyContextKey).(string) 199 pubkey, ok := ctx.Value(pubkeyContextKey).(string)
257 return pubkey, ok 200 return pubkey, ok
258} 201}
259 202
260// RequireAuth is a helper that extracts the pubkey and returns an error if not authenticated.
261func RequireAuth(ctx context.Context) (string, error) { 203func RequireAuth(ctx context.Context) (string, error) {
262 pubkey, ok := PubkeyFromContext(ctx) 204 pubkey, ok := PubkeyFromContext(ctx)
263 if !ok || pubkey == "" { 205 if !ok || pubkey == "" {
diff --git a/internal/config/config.go b/internal/config/config.go
index 294510d..fc392b4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -10,7 +10,6 @@ import (
10 "gopkg.in/yaml.v3" 10 "gopkg.in/yaml.v3"
11) 11)
12 12
13// Config holds all configuration for the relay.
14type Config struct { 13type Config struct {
15 Server ServerConfig `yaml:"server"` 14 Server ServerConfig `yaml:"server"`
16 Database DatabaseConfig `yaml:"database"` 15 Database DatabaseConfig `yaml:"database"`
@@ -21,7 +20,6 @@ type Config struct {
21 Storage StorageConfig `yaml:"storage"` 20 Storage StorageConfig `yaml:"storage"`
22} 21}
23 22
24// ServerConfig holds server configuration.
25type ServerConfig struct { 23type ServerConfig struct {
26 GrpcAddr string `yaml:"grpc_addr"` 24 GrpcAddr string `yaml:"grpc_addr"`
27 HttpAddr string `yaml:"http_addr"` 25 HttpAddr string `yaml:"http_addr"`
@@ -30,14 +28,10 @@ type ServerConfig struct {
30 WriteTimeout time.Duration `yaml:"write_timeout"` 28 WriteTimeout time.Duration `yaml:"write_timeout"`
31} 29}
32 30
33// DatabaseConfig holds database configuration.
34type DatabaseConfig struct { 31type DatabaseConfig struct {
35 Path string `yaml:"path"` 32 Path string `yaml:"path"`
36 // Note: SQLite connection pooling is handled internally in the storage layer.
37 // SQLite works best with a single connection due to its single-writer architecture.
38} 33}
39 34
40// AuthConfig holds authentication configuration.
41type AuthConfig struct { 35type AuthConfig struct {
42 Read AuthOperationConfig `yaml:"read"` 36 Read AuthOperationConfig `yaml:"read"`
43 Write AuthOperationConfig `yaml:"write"` 37 Write AuthOperationConfig `yaml:"write"`
@@ -45,16 +39,14 @@ type AuthConfig struct {
45 SkipMethods []string `yaml:"skip_methods"` 39 SkipMethods []string `yaml:"skip_methods"`
46} 40}
47 41
48// AuthOperationConfig configures auth for a specific operation type (read or write). 42// AuthOperationConfig configures auth for read or write operations.
43// Three states: disabled (allow all), enabled with empty list (require auth),
44// enabled with npubs (whitelist only). Npubs normalized to hex at load time.
49type AuthOperationConfig struct { 45type AuthOperationConfig struct {
50 Enabled bool `yaml:"enabled"` // false = no auth required, true = auth required 46 Enabled bool `yaml:"enabled"`
51 AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only - normalized to hex internally 47 AllowedNpubs []string `yaml:"allowed_npubs"`
52 // If enabled=false: no auth, allow all
53 // If enabled=true && allowed_npubs=[]: auth required, any valid signature accepted
54 // If enabled=true && allowed_npubs=[...]: auth required, only whitelisted npubs
55} 48}
56 49
57// RateLimitConfig holds rate limiting configuration.
58type RateLimitConfig struct { 50type RateLimitConfig struct {
59 Enabled bool `yaml:"enabled"` 51 Enabled bool `yaml:"enabled"`
60 DefaultRPS float64 `yaml:"default_rps"` 52 DefaultRPS float64 `yaml:"default_rps"`
@@ -69,20 +61,17 @@ type RateLimitConfig struct {
69 MaxIdleTime time.Duration `yaml:"max_idle_time"` 61 MaxIdleTime time.Duration `yaml:"max_idle_time"`
70} 62}
71 63
72// MethodLimit defines rate limits for a specific method.
73type MethodLimit struct { 64type MethodLimit struct {
74 RPS float64 `yaml:"rps"` 65 RPS float64 `yaml:"rps"`
75 Burst int `yaml:"burst"` 66 Burst int `yaml:"burst"`
76} 67}
77 68
78// UserLimit defines rate limits for a specific user.
79type UserLimit struct { 69type UserLimit struct {
80 RPS float64 `yaml:"rps"` 70 RPS float64 `yaml:"rps"`
81 Burst int `yaml:"burst"` 71 Burst int `yaml:"burst"`
82 Methods map[string]MethodLimit `yaml:"methods"` 72 Methods map[string]MethodLimit `yaml:"methods"`
83} 73}
84 74
85// MetricsConfig holds metrics configuration.
86type MetricsConfig struct { 75type MetricsConfig struct {
87 Enabled bool `yaml:"enabled"` 76 Enabled bool `yaml:"enabled"`
88 Addr string `yaml:"addr"` 77 Addr string `yaml:"addr"`
@@ -91,14 +80,12 @@ type MetricsConfig struct {
91 Subsystem string `yaml:"subsystem"` 80 Subsystem string `yaml:"subsystem"`
92} 81}
93 82
94// LoggingConfig holds logging configuration.
95type LoggingConfig struct { 83type LoggingConfig struct {
96 Level string `yaml:"level"` 84 Level string `yaml:"level"`
97 Format string `yaml:"format"` 85 Format string `yaml:"format"`
98 Output string `yaml:"output"` 86 Output string `yaml:"output"`
99} 87}
100 88
101// StorageConfig holds storage configuration.
102type StorageConfig struct { 89type StorageConfig struct {
103 AutoCompact bool `yaml:"auto_compact"` 90 AutoCompact bool `yaml:"auto_compact"`
104 CompactInterval time.Duration `yaml:"compact_interval"` 91 CompactInterval time.Duration `yaml:"compact_interval"`
@@ -157,12 +144,9 @@ func Default() *Config {
157 } 144 }
158} 145}
159 146
160// Load loads configuration from a YAML file and applies environment variable overrides.
161func Load(filename string) (*Config, error) { 147func Load(filename string) (*Config, error) {
162 // Start with defaults
163 cfg := Default() 148 cfg := Default()
164 149
165 // Read file if provided
166 if filename != "" { 150 if filename != "" {
167 data, err := os.ReadFile(filename) 151 data, err := os.ReadFile(filename)
168 if err != nil { 152 if err != nil {
@@ -174,15 +158,12 @@ func Load(filename string) (*Config, error) {
174 } 158 }
175 } 159 }
176 160
177 // Apply environment variable overrides
178 applyEnvOverrides(cfg) 161 applyEnvOverrides(cfg)
179 162
180 // Normalize npubs to hex pubkeys
181 if err := normalizeNpubs(cfg); err != nil { 163 if err := normalizeNpubs(cfg); err != nil {
182 return nil, fmt.Errorf("failed to normalize npubs: %w", err) 164 return nil, fmt.Errorf("failed to normalize npubs: %w", err)
183 } 165 }
184 166
185 // Validate
186 if err := cfg.Validate(); err != nil { 167 if err := cfg.Validate(); err != nil {
187 return nil, fmt.Errorf("invalid configuration: %w", err) 168 return nil, fmt.Errorf("invalid configuration: %w", err)
188 } 169 }
@@ -190,19 +171,15 @@ func Load(filename string) (*Config, error) {
190 return cfg, nil 171 return cfg, nil
191} 172}
192 173
193// normalizeNpubs converts all npub (bech32) pubkeys to hex format. 174// normalizeNpubs converts npub (bech32) to hex format for internal use.
194// Config only accepts npub format (human-readable), which is converted
195// to hex format (computer-readable) for internal use.
196func normalizeNpubs(cfg *Config) error { 175func normalizeNpubs(cfg *Config) error {
197 var err error 176 var err error
198 177
199 // Normalize read allowlist
200 cfg.Auth.Read.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Read.AllowedNpubs) 178 cfg.Auth.Read.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Read.AllowedNpubs)
201 if err != nil { 179 if err != nil {
202 return fmt.Errorf("auth.read.allowed_npubs: %w", err) 180 return fmt.Errorf("auth.read.allowed_npubs: %w", err)
203 } 181 }
204 182
205 // Normalize write allowlist
206 cfg.Auth.Write.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Write.AllowedNpubs) 183 cfg.Auth.Write.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Write.AllowedNpubs)
207 if err != nil { 184 if err != nil {
208 return fmt.Errorf("auth.write.allowed_npubs: %w", err) 185 return fmt.Errorf("auth.write.allowed_npubs: %w", err)
@@ -211,7 +188,6 @@ func normalizeNpubs(cfg *Config) error {
211 return nil 188 return nil
212} 189}
213 190
214// normalizeNpubList converts a list of npubs to hex pubkeys.
215func normalizeNpubList(npubs []string) ([]string, error) { 191func normalizeNpubList(npubs []string) ([]string, error) {
216 if len(npubs) == 0 { 192 if len(npubs) == 0 {
217 return nil, nil 193 return nil, nil
@@ -219,33 +195,27 @@ func normalizeNpubList(npubs []string) ([]string, error) {
219 195
220 normalized := make([]string, 0, len(npubs)) 196 normalized := make([]string, 0, len(npubs))
221 for _, npub := range npubs { 197 for _, npub := range npubs {
222 // Skip empty strings
223 npub = strings.TrimSpace(npub) 198 npub = strings.TrimSpace(npub)
224 if npub == "" { 199 if npub == "" {
225 continue 200 continue
226 } 201 }
227 202
228 // Validate npub format
229 if !strings.HasPrefix(npub, "npub1") { 203 if !strings.HasPrefix(npub, "npub1") {
230 return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) 204 return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub)
231 } 205 }
232 206
233 // Parse npub to get hex pubkey
234 key, err := nostr.ParsePublicKey(npub) 207 key, err := nostr.ParsePublicKey(npub)
235 if err != nil { 208 if err != nil {
236 return nil, fmt.Errorf("invalid npub %q: %w", npub, err) 209 return nil, fmt.Errorf("invalid npub %q: %w", npub, err)
237 } 210 }
238 211
239 // Get the hex representation for internal use
240 normalized = append(normalized, key.Public()) 212 normalized = append(normalized, key.Public())
241 } 213 }
242 214
243 return normalized, nil 215 return normalized, nil
244} 216}
245 217
246// Validate validates the configuration.
247func (c *Config) Validate() error { 218func (c *Config) Validate() error {
248 // Validate server addresses
249 if c.Server.GrpcAddr == "" { 219 if c.Server.GrpcAddr == "" {
250 return fmt.Errorf("server.grpc_addr is required") 220 return fmt.Errorf("server.grpc_addr is required")
251 } 221 }
@@ -253,12 +223,10 @@ func (c *Config) Validate() error {
253 return fmt.Errorf("server.http_addr is required") 223 return fmt.Errorf("server.http_addr is required")
254 } 224 }
255 225
256 // Validate database path
257 if c.Database.Path == "" { 226 if c.Database.Path == "" {
258 return fmt.Errorf("database.path is required") 227 return fmt.Errorf("database.path is required")
259 } 228 }
260 229
261 // Validate metrics config if enabled
262 if c.Metrics.Enabled { 230 if c.Metrics.Enabled {
263 if c.Metrics.Addr == "" { 231 if c.Metrics.Addr == "" {
264 return fmt.Errorf("metrics.addr is required when metrics enabled") 232 return fmt.Errorf("metrics.addr is required when metrics enabled")
@@ -268,7 +236,6 @@ func (c *Config) Validate() error {
268 } 236 }
269 } 237 }
270 238
271 // Validate logging
272 validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} 239 validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
273 if !validLevels[c.Logging.Level] { 240 if !validLevels[c.Logging.Level] {
274 return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level) 241 return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level)
@@ -282,10 +249,7 @@ func (c *Config) Validate() error {
282 return nil 249 return nil
283} 250}
284 251
285// applyEnvOverrides applies environment variable overrides to the configuration.
286// Environment variables follow the pattern: MUXSTR_<SECTION>_<KEY>
287func applyEnvOverrides(cfg *Config) { 252func applyEnvOverrides(cfg *Config) {
288 // Server
289 if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" { 253 if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" {
290 cfg.Server.GrpcAddr = val 254 cfg.Server.GrpcAddr = val
291 } 255 }
@@ -306,12 +270,10 @@ func applyEnvOverrides(cfg *Config) {
306 } 270 }
307 } 271 }
308 272
309 // Database
310 if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" { 273 if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" {
311 cfg.Database.Path = val 274 cfg.Database.Path = val
312 } 275 }
313 276
314 // Auth
315 if val := os.Getenv("MUXSTR_AUTH_READ_ENABLED"); val != "" { 277 if val := os.Getenv("MUXSTR_AUTH_READ_ENABLED"); val != "" {
316 cfg.Auth.Read.Enabled = parseBool(val) 278 cfg.Auth.Read.Enabled = parseBool(val)
317 } 279 }
@@ -331,7 +293,6 @@ func applyEnvOverrides(cfg *Config) {
331 } 293 }
332 } 294 }
333 295
334 // Rate limit
335 if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { 296 if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" {
336 cfg.RateLimit.Enabled = parseBool(val) 297 cfg.RateLimit.Enabled = parseBool(val)
337 } 298 }
@@ -348,7 +309,6 @@ func applyEnvOverrides(cfg *Config) {
348 } 309 }
349 } 310 }
350 311
351 // Metrics
352 if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" { 312 if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" {
353 cfg.Metrics.Enabled = parseBool(val) 313 cfg.Metrics.Enabled = parseBool(val)
354 } 314 }
@@ -359,7 +319,6 @@ func applyEnvOverrides(cfg *Config) {
359 cfg.Metrics.Path = val 319 cfg.Metrics.Path = val
360 } 320 }
361 321
362 // Logging
363 if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" { 322 if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" {
364 cfg.Logging.Level = val 323 cfg.Logging.Level = val
365 } 324 }