summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-14 10:02:52 -0800
committerbndw <ben@bdw.to>2026-02-14 10:02:52 -0800
commit5d21632ea70e1c7de7becb7ab6227b06b1535a83 (patch)
treee63bcbe8cdf7dc888ca0e3476ad529690a0a44a8
parentd30459513ec44ab298fafd1bfe0edc08d6ab62e4 (diff)
feat: add separate read/write allowlists for granular access control
- Split allowed_npubs into allowed_npubs_read and allowed_npubs_write - Write operations: Publish, Delete, Create, Update, Insert, Remove, Set, Put - Read operations: everything else (Query, Subscribe, Get, List, etc.) - Auth interceptor checks appropriate list based on method type - Enables common patterns: - Public relay: only some can write, everyone can read - Private relay: restricted read and write - Open relay: everyone can read and write - Updated config, docs, and comprehensive tests Use cases: "only some can write, everyone can read"
-rw-r--r--internal/auth/README.md13
-rw-r--r--internal/auth/auth_test.go38
-rw-r--r--internal/auth/interceptor.go67
-rw-r--r--internal/config/README.md24
-rw-r--r--internal/config/config.go52
-rw-r--r--internal/config/config_test.go136
6 files changed, 263 insertions, 67 deletions
diff --git a/internal/auth/README.md b/internal/auth/README.md
index df0de6a..366e110 100644
--- a/internal/auth/README.md
+++ b/internal/auth/README.md
@@ -209,9 +209,20 @@ authOpts := &auth.InterceptorOptions{
209- **`TimestampWindow`**: Maximum age of events in seconds (default: 60) 209- **`TimestampWindow`**: Maximum age of events in seconds (default: 60)
210- **`Required`**: Whether to reject unauthenticated requests (default: false) 210- **`Required`**: Whether to reject unauthenticated requests (default: false)
211- **`ValidatePayload`**: Whether to verify payload hash when present (default: false) 211- **`ValidatePayload`**: Whether to verify payload hash when present (default: false)
212- **`AllowedNpubs`**: Optional whitelist of allowed pubkeys (nil = allow all) 212- **`AllowedNpubsRead`**: Optional whitelist of allowed pubkeys for read operations (nil = allow all)
213 - Config accepts npub format only (human-readable bech32) 213 - Config accepts npub format only (human-readable bech32)
214 - Automatically normalized to hex format (computer-readable) at config load time 214 - Automatically normalized to hex format (computer-readable) at config load time
215 - Controls access to Query, Get, List, Subscribe, and other read methods
216- **`AllowedNpubsWrite`**: Optional whitelist of allowed pubkeys for write operations (nil = allow all)
217 - Config accepts npub format only (human-readable bech32)
218 - Automatically normalized to hex format (computer-readable) at config load time
219 - Controls access to Publish, Delete, Create, Update, and other write methods
220
221**Access Control Patterns:**
222- **Public relay**: Set `AllowedNpubsWrite` (only some can publish), leave `AllowedNpubsRead` empty (everyone can read)
223- **Private relay**: Set both lists (restricted read and write access)
224- **Open relay**: Leave both empty (everyone can read and write)
225- **Read-only relay**: Set `AllowedNpubsRead`, block all writes
215 226
216### NostrCredentials Options 227### NostrCredentials Options
217 228
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index 1f0efee..7a0da19 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -304,3 +304,41 @@ func TestHashPayload(t *testing.T) {
304 t.Error("different payloads produced same hash") 304 t.Error("different payloads produced same hash")
305 } 305 }
306} 306}
307
308func TestIsWriteMethod(t *testing.T) {
309 tests := []struct {
310 method string
311 want bool
312 }{
313 // Write methods
314 {"/nostr.v1.NostrRelay/PublishEvent", true},
315 {"/nostr.v1.NostrRelay/DeleteEvent", true},
316 {"/admin.v1.Admin/CreateUser", true},
317 {"/admin.v1.Admin/UpdateSettings", true},
318 {"/data.v1.Data/InsertRecord", true},
319 {"/data.v1.Data/RemoveItem", true},
320 {"/storage.v1.Storage/SetValue", true},
321 {"/storage.v1.Storage/PutObject", true},
322
323 // Read methods
324 {"/nostr.v1.NostrRelay/QueryEvents", false},
325 {"/nostr.v1.NostrRelay/Subscribe", false},
326 {"/nostr.v1.NostrRelay/GetEvent", false},
327 {"/admin.v1.Admin/ListUsers", false},
328 {"/health.v1.Health/Check", false},
329 {"/info.v1.Info/GetRelayInfo", false},
330
331 // Edge cases
332 {"", false},
333 {"/", false},
334 }
335
336 for _, tt := range tests {
337 t.Run(tt.method, func(t *testing.T) {
338 got := isWriteMethod(tt.method)
339 if got != tt.want {
340 t.Errorf("isWriteMethod(%q) = %v, want %v", tt.method, got, tt.want)
341 }
342 })
343 }
344}
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go
index 7d785bf..66880a7 100644
--- a/internal/auth/interceptor.go
+++ b/internal/auth/interceptor.go
@@ -3,6 +3,7 @@ package auth
3import ( 3import (
4 "context" 4 "context"
5 "fmt" 5 "fmt"
6 "strings"
6 7
7 "google.golang.org/grpc" 8 "google.golang.org/grpc"
8 "google.golang.org/grpc/codes" 9 "google.golang.org/grpc/codes"
@@ -35,11 +36,17 @@ type InterceptorOptions struct {
35 // Default: false 36 // Default: false
36 ValidatePayload bool 37 ValidatePayload bool
37 38
38 // AllowedNpubs is an optional whitelist of allowed pubkeys (hex format). 39 // AllowedNpubsRead is an optional whitelist of allowed pubkeys for read operations (hex format).
39 // Config accepts npub format only, normalized to hex at load time. 40 // Config accepts npub format only, normalized to hex at load time.
40 // If nil or empty, all valid signatures are accepted. 41 // If nil or empty, all valid signatures are accepted for reads.
41 // Default: nil (allow all) 42 // Default: nil (allow all)
42 AllowedNpubs []string 43 AllowedNpubsRead []string
44
45 // AllowedNpubsWrite is an optional whitelist of allowed pubkeys for write operations (hex format).
46 // Config accepts npub format only, normalized to hex at load time.
47 // If nil or empty, all valid signatures are accepted for writes.
48 // Default: nil (allow all)
49 AllowedNpubsWrite []string
43 50
44 // SkipMethods is a list of gRPC methods that bypass authentication. 51 // SkipMethods is a list of gRPC methods that bypass authentication.
45 // Useful for public endpoints like health checks or relay info. 52 // Useful for public endpoints like health checks or relay info.
@@ -51,11 +58,12 @@ type InterceptorOptions struct {
51// DefaultInterceptorOptions returns the default configuration. 58// DefaultInterceptorOptions returns the default configuration.
52func DefaultInterceptorOptions() *InterceptorOptions { 59func DefaultInterceptorOptions() *InterceptorOptions {
53 return &InterceptorOptions{ 60 return &InterceptorOptions{
54 TimestampWindow: 60, 61 TimestampWindow: 60,
55 Required: false, 62 Required: false,
56 ValidatePayload: false, 63 ValidatePayload: false,
57 AllowedNpubs: nil, 64 AllowedNpubsRead: nil,
58 SkipMethods: nil, 65 AllowedNpubsWrite: nil,
66 SkipMethods: nil,
59 } 67 }
60} 68}
61 69
@@ -169,16 +177,51 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept
169 // Extract pubkey 177 // Extract pubkey
170 pubkey := ExtractPubkey(event) 178 pubkey := ExtractPubkey(event)
171 179
172 // Check whitelist if configured (all values are already normalized to hex) 180 // Check whitelist based on operation type (all values are already normalized to hex)
173 if len(opts.AllowedNpubs) > 0 { 181 if isWriteMethod(method) {
174 if !contains(opts.AllowedNpubs, pubkey) { 182 // Write operation - check write allowlist
175 return "", fmt.Errorf("pubkey not in whitelist") 183 if len(opts.AllowedNpubsWrite) > 0 {
184 if !contains(opts.AllowedNpubsWrite, pubkey) {
185 return "", fmt.Errorf("pubkey not authorized for write operations")
186 }
187 }
188 } else {
189 // Read operation - check read allowlist
190 if len(opts.AllowedNpubsRead) > 0 {
191 if !contains(opts.AllowedNpubsRead, pubkey) {
192 return "", fmt.Errorf("pubkey not authorized for read operations")
193 }
176 } 194 }
177 } 195 }
178 196
179 return pubkey, nil 197 return pubkey, nil
180} 198}
181 199
200// isWriteMethod determines if a gRPC method is a write operation.
201// Write operations modify state (Publish, Delete, Create, Update, etc.)
202// Read operations query state (Query, Get, List, Subscribe, etc.)
203func isWriteMethod(method string) bool {
204 // Common write operation patterns
205 writePatterns := []string{
206 "Publish",
207 "Delete",
208 "Create",
209 "Update",
210 "Insert",
211 "Remove",
212 "Set",
213 "Put",
214 }
215
216 for _, pattern := range writePatterns {
217 if strings.Contains(method, pattern) {
218 return true
219 }
220 }
221
222 return false
223}
224
182// shouldSkipAuth checks if a method should bypass authentication. 225// shouldSkipAuth checks if a method should bypass authentication.
183func shouldSkipAuth(method string, skipMethods []string) bool { 226func shouldSkipAuth(method string, skipMethods []string) bool {
184 for _, skip := range skipMethods { 227 for _, skip := range skipMethods {
diff --git a/internal/config/README.md b/internal/config/README.md
index dbb8760..7deb38f 100644
--- a/internal/config/README.md
+++ b/internal/config/README.md
@@ -90,12 +90,25 @@ auth:
90 # Timestamp window in seconds for replay protection 90 # Timestamp window in seconds for replay protection
91 timestamp_window: 60 91 timestamp_window: 60
92 92
93 # Allowed npubs (optional, whitelist) 93 # Allowed npubs for read operations (optional, whitelist)
94 # If empty, all valid signatures are accepted 94 # If empty, all valid signatures are accepted for reads
95 # Use npub format only (e.g., npub1...) 95 # Use npub format only (e.g., npub1...)
96 allowed_npubs: [] 96 allowed_npubs_read: []
97
98 # Allowed npubs for write operations (optional, whitelist)
99 # If empty, all valid signatures are accepted for writes
100 # Use npub format only (e.g., npub1...)
101 allowed_npubs_write: []
102
103 # Example use cases:
104 # - Public relay: allowed_npubs_write (only some can publish), empty read (everyone can read)
105 # - Private relay: both lists populated (restricted read and write)
106 # - Open relay: both lists empty (everyone can read and write)
107 #
97 # Example: 108 # Example:
98 # allowed_npubs: 109 # allowed_npubs_read:
110 # - npub1a2b3c4d5e6f...
111 # allowed_npubs_write:
99 # - npub1a2b3c4d5e6f... 112 # - npub1a2b3c4d5e6f...
100 113
101 # Skip authentication for these methods 114 # Skip authentication for these methods
@@ -222,7 +235,8 @@ Complex types:
222 235
223```bash 236```bash
224# Lists (comma-separated, npub format) 237# Lists (comma-separated, npub format)
225export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..." 238export MUXSTR_AUTH_ALLOWED_NPUBS_READ="npub1...,npub1..."
239export MUXSTR_AUTH_ALLOWED_NPUBS_WRITE="npub1..."
226 240
227# Durations 241# Durations
228export MUXSTR_SERVER_READ_TIMEOUT="30s" 242export MUXSTR_SERVER_READ_TIMEOUT="30s"
diff --git a/internal/config/config.go b/internal/config/config.go
index 0566537..3e52272 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -39,11 +39,12 @@ type DatabaseConfig struct {
39 39
40// AuthConfig holds authentication configuration. 40// AuthConfig holds authentication configuration.
41type AuthConfig struct { 41type AuthConfig struct {
42 Enabled bool `yaml:"enabled"` 42 Enabled bool `yaml:"enabled"`
43 Required bool `yaml:"required"` 43 Required bool `yaml:"required"`
44 TimestampWindow int64 `yaml:"timestamp_window"` 44 TimestampWindow int64 `yaml:"timestamp_window"`
45 AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only (bech32) - normalized to hex internally 45 AllowedNpubsRead []string `yaml:"allowed_npubs_read"` // npub format only (bech32) - normalized to hex internally
46 SkipMethods []string `yaml:"skip_methods"` 46 AllowedNpubsWrite []string `yaml:"allowed_npubs_write"` // npub format only (bech32) - normalized to hex internally
47 SkipMethods []string `yaml:"skip_methods"`
47} 48}
48 49
49// RateLimitConfig holds rate limiting configuration. 50// RateLimitConfig holds rate limiting configuration.
@@ -180,12 +181,31 @@ func Load(filename string) (*Config, error) {
180// Config only accepts npub format (human-readable), which is converted 181// Config only accepts npub format (human-readable), which is converted
181// to hex format (computer-readable) for internal use. 182// to hex format (computer-readable) for internal use.
182func normalizeNpubs(cfg *Config) error { 183func normalizeNpubs(cfg *Config) error {
183 if len(cfg.Auth.AllowedNpubs) == 0 { 184 var err error
184 return nil 185
186 // Normalize read allowlist
187 cfg.Auth.AllowedNpubsRead, err = normalizeNpubList(cfg.Auth.AllowedNpubsRead)
188 if err != nil {
189 return fmt.Errorf("allowed_npubs_read: %w", err)
190 }
191
192 // Normalize write allowlist
193 cfg.Auth.AllowedNpubsWrite, err = normalizeNpubList(cfg.Auth.AllowedNpubsWrite)
194 if err != nil {
195 return fmt.Errorf("allowed_npubs_write: %w", err)
196 }
197
198 return nil
199}
200
201// normalizeNpubList converts a list of npubs to hex pubkeys.
202func normalizeNpubList(npubs []string) ([]string, error) {
203 if len(npubs) == 0 {
204 return nil, nil
185 } 205 }
186 206
187 normalized := make([]string, 0, len(cfg.Auth.AllowedNpubs)) 207 normalized := make([]string, 0, len(npubs))
188 for _, npub := range cfg.Auth.AllowedNpubs { 208 for _, npub := range npubs {
189 // Skip empty strings 209 // Skip empty strings
190 npub = strings.TrimSpace(npub) 210 npub = strings.TrimSpace(npub)
191 if npub == "" { 211 if npub == "" {
@@ -194,21 +214,20 @@ func normalizeNpubs(cfg *Config) error {
194 214
195 // Validate npub format 215 // Validate npub format
196 if !strings.HasPrefix(npub, "npub1") { 216 if !strings.HasPrefix(npub, "npub1") {
197 return fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) 217 return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub)
198 } 218 }
199 219
200 // Parse npub to get hex pubkey 220 // Parse npub to get hex pubkey
201 key, err := nostr.ParsePublicKey(npub) 221 key, err := nostr.ParsePublicKey(npub)
202 if err != nil { 222 if err != nil {
203 return fmt.Errorf("invalid npub %q: %w", npub, err) 223 return nil, fmt.Errorf("invalid npub %q: %w", npub, err)
204 } 224 }
205 225
206 // Get the hex representation for internal use 226 // Get the hex representation for internal use
207 normalized = append(normalized, key.Public()) 227 normalized = append(normalized, key.Public())
208 } 228 }
209 229
210 cfg.Auth.AllowedNpubs = normalized 230 return normalized, nil
211 return nil
212} 231}
213 232
214// Validate validates the configuration. 233// Validate validates the configuration.
@@ -292,8 +311,11 @@ func applyEnvOverrides(cfg *Config) {
292 cfg.Auth.TimestampWindow = n 311 cfg.Auth.TimestampWindow = n
293 } 312 }
294 } 313 }
295 if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS"); val != "" { 314 if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_READ"); val != "" {
296 cfg.Auth.AllowedNpubs = strings.Split(val, ",") 315 cfg.Auth.AllowedNpubsRead = strings.Split(val, ",")
316 }
317 if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_WRITE"); val != "" {
318 cfg.Auth.AllowedNpubsWrite = strings.Split(val, ",")
297 } 319 }
298 320
299 // Rate limit 321 // Rate limit
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5fa159e..c0d4555 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -260,9 +260,11 @@ database:
260 260
261auth: 261auth:
262 enabled: true 262 enabled: true
263 allowed_npubs: 263 allowed_npubs_read:
264 - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 264 - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
265 - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft 265 - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
266 allowed_npubs_write:
267 - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
266` 268`
267 269
268 if _, err := tmpfile.Write([]byte(configData)); err != nil { 270 if _, err := tmpfile.Write([]byte(configData)); err != nil {
@@ -275,18 +277,32 @@ auth:
275 t.Fatalf("failed to load config: %v", err) 277 t.Fatalf("failed to load config: %v", err)
276 } 278 }
277 279
278 // Verify npubs were normalized to hex 280 // Verify read npubs were normalized to hex
279 if len(cfg.Auth.AllowedNpubs) != 2 { 281 if len(cfg.Auth.AllowedNpubsRead) != 2 {
280 t.Errorf("expected 2 allowed npubs, got %d", len(cfg.Auth.AllowedNpubs)) 282 t.Errorf("expected 2 allowed npubs for read, got %d", len(cfg.Auth.AllowedNpubsRead))
283 }
284
285 // Verify write npubs were normalized to hex
286 if len(cfg.Auth.AllowedNpubsWrite) != 1 {
287 t.Errorf("expected 1 allowed npub for write, got %d", len(cfg.Auth.AllowedNpubsWrite))
281 } 288 }
282 289
283 // Check that they're hex format (64 chars, not npub1...) 290 // Check that they're hex format (64 chars, not npub1...)
284 for i, pubkey := range cfg.Auth.AllowedNpubs { 291 for i, pubkey := range cfg.Auth.AllowedNpubsRead {
292 if len(pubkey) != 64 {
293 t.Errorf("read npub %d: expected 64 hex chars, got %d", i, len(pubkey))
294 }
295 if len(pubkey) >= 5 && pubkey[:5] == "npub1" {
296 t.Errorf("read npub %d: should be normalized to hex, still in npub format", i)
297 }
298 }
299
300 for i, pubkey := range cfg.Auth.AllowedNpubsWrite {
285 if len(pubkey) != 64 { 301 if len(pubkey) != 64 {
286 t.Errorf("npub %d: expected 64 hex chars, got %d", i, len(pubkey)) 302 t.Errorf("write npub %d: expected 64 hex chars, got %d", i, len(pubkey))
287 } 303 }
288 if pubkey[:5] == "npub1" { 304 if len(pubkey) >= 5 && pubkey[:5] == "npub1" {
289 t.Errorf("npub %d: should be normalized to hex, still in npub format", i) 305 t.Errorf("write npub %d: should be normalized to hex, still in npub format", i)
290 } 306 }
291 } 307 }
292 308
@@ -294,46 +310,98 @@ auth:
294 expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" 310 expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
295 expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" 311 expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"
296 312
297 if cfg.Auth.AllowedNpubs[0] != expectedHex1 { 313 if cfg.Auth.AllowedNpubsRead[0] != expectedHex1 {
298 t.Errorf("npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubs[0]) 314 t.Errorf("read npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsRead[0])
315 }
316 if cfg.Auth.AllowedNpubsRead[1] != expectedHex2 {
317 t.Errorf("read npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubsRead[1])
299 } 318 }
300 if cfg.Auth.AllowedNpubs[1] != expectedHex2 { 319 if cfg.Auth.AllowedNpubsWrite[0] != expectedHex1 {
301 t.Errorf("npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubs[1]) 320 t.Errorf("write npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsWrite[0])
302 } 321 }
303} 322}
304 323
305func TestNpubValidation(t *testing.T) { 324func TestNpubValidation(t *testing.T) {
306 tmpfile, err := os.CreateTemp("", "config-*.yaml") 325 tests := []struct {
307 if err != nil { 326 name string
308 t.Fatal(err) 327 config string
309 } 328 expectError bool
310 defer os.Remove(tmpfile.Name()) 329 errorMsg string
311 330 }{
312 // Invalid: hex format instead of npub 331 {
313 configData := ` 332 name: "invalid hex in read list",
333 config: `
314server: 334server:
315 grpc_addr: ":50051" 335 grpc_addr: ":50051"
316 http_addr: ":8080" 336 http_addr: ":8080"
317
318database: 337database:
319 path: "test.db" 338 path: "test.db"
320
321auth: 339auth:
322 allowed_npubs: 340 allowed_npubs_read:
323 - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d 341 - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d
324` 342`,
325 343 expectError: true,
326 if _, err := tmpfile.Write([]byte(configData)); err != nil { 344 errorMsg: "must start with 'npub1'",
327 t.Fatal(err) 345 },
346 {
347 name: "invalid hex in write list",
348 config: `
349server:
350 grpc_addr: ":50051"
351 http_addr: ":8080"
352database:
353 path: "test.db"
354auth:
355 allowed_npubs_write:
356 - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d
357`,
358 expectError: true,
359 errorMsg: "must start with 'npub1'",
360 },
361 {
362 name: "valid npub lists",
363 config: `
364server:
365 grpc_addr: ":50051"
366 http_addr: ":8080"
367database:
368 path: "test.db"
369auth:
370 allowed_npubs_read:
371 - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
372 allowed_npubs_write:
373 - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
374`,
375 expectError: false,
376 },
328 } 377 }
329 tmpfile.Close()
330 378
331 _, err = Load(tmpfile.Name()) 379 for _, tt := range tests {
332 if err == nil { 380 t.Run(tt.name, func(t *testing.T) {
333 t.Error("expected error for hex format in allowed_npubs, got nil") 381 tmpfile, err := os.CreateTemp("", "config-*.yaml")
334 } 382 if err != nil {
335 if err != nil && !strings.Contains(err.Error(), "must start with 'npub1'") { 383 t.Fatal(err)
336 t.Errorf("expected 'must start with npub1' error, got: %v", err) 384 }
385 defer os.Remove(tmpfile.Name())
386
387 if _, err := tmpfile.Write([]byte(tt.config)); err != nil {
388 t.Fatal(err)
389 }
390 tmpfile.Close()
391
392 _, err = Load(tmpfile.Name())
393 if tt.expectError {
394 if err == nil {
395 t.Error("expected error, got nil")
396 } else if !strings.Contains(err.Error(), tt.errorMsg) {
397 t.Errorf("expected error containing %q, got: %v", tt.errorMsg, err)
398 }
399 } else {
400 if err != nil {
401 t.Errorf("unexpected error: %v", err)
402 }
403 }
404 })
337 } 405 }
338} 406}
339 407