• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orneryd / NornicDB / 26609980512

29 May 2026 12:13AM UTC coverage: 86.666% (+0.02%) from 86.645%
26609980512

push

github

orneryd
test(server,search): adding unit test coverage

133694 of 154264 relevant lines covered (86.67%)

1.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.42
/pkg/server/server.go
1
// Package server provides a Neo4j-compatible HTTP REST API server for NornicDB.
2
//
3
// This package implements the Neo4j HTTP API specification, making NornicDB compatible
4
// with existing Neo4j tools, drivers, and browsers while adding NornicDB-specific
5
// extensions for memory decay, vector search, and compliance features.
6
//
7
// Neo4j Compatibility:
8
//   - Discovery endpoint (/) returns Neo4j-compatible service information
9
//   - Transaction API (/db/{name}/tx) supports implicit and explicit transactions
10
//   - Cypher query execution with Neo4j response format
11
//   - Basic Auth and Bearer token authentication
12
//   - Error codes follow Neo4j conventions (Neo.ClientError.*)
13
//
14
// NornicDB Extensions:
15
//   - JWT authentication with RBAC
16
//   - Vector search endpoints (/nornicdb/search, /nornicdb/similar)
17
//   - Memory decay information (/nornicdb/decay)
18
//   - GDPR compliance endpoints (/gdpr/export, /gdpr/delete)
19
//   - Admin endpoints (/admin/stats, /admin/config)
20
//   - GPU acceleration control (/admin/gpu/*)
21
//   - HTTP/2 support (always enabled, backwards compatible with HTTP/1.1)
22
//
23
// Example Usage:
24
//
25
//        // Create server
26
//        db, _ := nornicdb.Open("./data", nil)
27
//        auth, _ := auth.NewAuthenticator(auth.DefaultAuthConfig())
28
//        config := server.DefaultConfig()
29
//
30
//        server, err := server.New(db, auth, config)
31
//        if err != nil {
32
//                log.Fatal(err)
33
//        }
34
//
35
//        // Start server
36
//        if err := server.Start(); err != nil {
37
//                log.Fatal(err)
38
//        }
39
//
40
//        // Server listening on server.Addr()
41
//
42
//        // Use with Neo4j Browser
43
//        // Open: http://localhost:7474
44
//        // Connect URI: bolt://localhost:7687 (if Bolt server is running)
45
//        // Or use HTTP: http://localhost:7474/db/nornic/tx/commit
46
//
47
//        // Use with Neo4j drivers
48
//        driver := neo4j.NewDriver("http://localhost:7474", neo4j.BasicAuth("admin", "password"))
49
//        session := driver.NewSession(neo4j.SessionConfig{})
50
//        result, _ := session.Run("MATCH (n) RETURN count(n)", nil)
51
//
52
//        // Graceful shutdown
53
//        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
54
//        defer cancel()
55
//        server.Stop(ctx)
56
//
57
// Authentication:
58
//
59
// The server supports multiple authentication methods:
60
//
61
//  1. **Basic Auth** (Neo4j compatible):
62
//     Authorization: Basic base64(username:password)
63
//
64
//  2. **Bearer Token** (JWT):
65
//     Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
66
//
67
//  3. **Cookie** (browser sessions):
68
//     Cookie: token=eyJhbGciOiJIUzI1NiIs...
69
//
70
//  4. **Query Parameter** (for SSE/WebSocket):
71
//     ?token=eyJhbGciOiJIUzI1NiIs...
72
//
73
// Neo4j HTTP API Endpoints:
74
//
75
//        GET  /                           - Discovery (service information)
76
//        GET  /db/{name}                  - Database information
77
//        POST /db/{name}/tx/commit       - Execute Cypher (implicit transaction)
78
//        POST /db/{name}/tx              - Begin explicit transaction
79
//        POST /db/{name}/tx/{id}         - Execute in transaction
80
//        POST /db/{name}/tx/{id}/commit  - Commit transaction
81
//        DELETE /db/{name}/tx/{id}       - Rollback transaction
82
//
83
// NornicDB Extension Endpoints:
84
//
85
//        Authentication:
86
//          POST /auth/token                - Get JWT token
87
//          POST /auth/logout               - Logout
88
//          GET  /auth/me                   - Current user info
89
//          POST /auth/api-token            - Generate API token (admin)
90
//          GET  /auth/oauth/redirect       - OAuth redirect
91
//          GET  /auth/oauth/callback        - OAuth callback
92
//          GET  /auth/users                 - List users (admin)
93
//          POST /auth/users                 - Create user (admin)
94
//          GET  /auth/users/{username}      - Get user (admin)
95
//          PUT  /auth/users/{username}      - Update user (admin)
96
//          DELETE /auth/users/{username}    - Delete user (admin)
97
//
98
//        Search & Embeddings:
99
//          POST /nornicdb/search           - Hybrid search (vector + BM25)
100
//          POST /nornicdb/similar           - Vector similarity search
101
//          GET  /nornicdb/decay             - Memory decay statistics
102
//          POST /nornicdb/embed/trigger     - Trigger embedding generation
103
//          GET  /nornicdb/embed/stats       - Embedding statistics
104
//          POST /nornicdb/embed/clear       - Clear all embeddings (admin)
105
//          POST /nornicdb/search/rebuild    - Rebuild search indexes
106
//
107
//        Admin & System:
108
//          GET  /admin/stats               - System statistics (admin)
109
//          GET  /admin/config               - Server configuration (admin)
110
//          POST /admin/backup               - Create backup (admin)
111
//          GET  /admin/gpu/status           - GPU status (admin)
112
//          POST /admin/gpu/enable           - Enable GPU (admin)
113
//          POST /admin/gpu/disable          - Disable GPU (admin)
114
//          POST /admin/gpu/test              - Test GPU (admin)
115
//
116
//        GDPR Compliance:
117
//          POST /gdpr/export                - GDPR data export (requires user_id and format in body)
118
//          POST /gdpr/delete                - GDPR erasure request
119
//
120
//        GraphQL & AI:
121
//          POST /graphql                    - GraphQL endpoint
122
//          GET  /graphql/playground         - GraphQL Playground
123
//          POST /mcp                        - MCP server endpoint
124
//          POST /api/bifrost/chat/completions - Heimdall AI chat
125
//
126
// For complete API documentation, see: docs/api-reference/openapi.yaml
127
//
128
// Security Features:
129
//
130
//   - CORS support with configurable origins
131
//   - Request size limits (default 10MB)
132
//   - IP-based rate limiting (configurable per-minute/per-hour limits)
133
//   - Audit logging integration
134
//   - Panic recovery middleware
135
//   - TLS/HTTPS support
136
//
137
// Compliance:
138
//   - GDPR Art.15 (right of access) via /gdpr/export
139
//   - GDPR Art.17 (right to erasure) via /gdpr/delete
140
//   - HIPAA audit logging for all data access
141
//   - SOC2 access controls via RBAC
142
//
143
// ELI12 (Explain Like I'm 12):
144
//
145
// Think of this server like a restaurant:
146
//
147
//  1. **Neo4j compatibility**: We speak the same "language" as Neo4j, so existing
148
//     customers (tools/drivers) can order from our menu without learning new words.
149
//
150
//  2. **Authentication**: Like checking IDs at the door - we make sure you're allowed
151
//     to be here and what you're allowed to do.
152
//
153
//  3. **Endpoints**: Different "counters" for different services - one for regular
154
//     food (Cypher queries), one for special orders (vector search), one for the
155
//     manager's office (admin functions).
156
//
157
//  4. **Middleware**: Like security guards, cashiers, and cleaners who help every
158
//     customer but do different jobs (logging, auth, error handling).
159
//
160
// The server makes sure everyone gets served safely and efficiently!
161
package server
162

163
import (
164
        "context"
165
        "errors"
166
        "fmt"
167
        "io"
168
        "log"
169
        "log/slog"
170
        "net"
171
        "net/http"
172
        "os"
173
        "path/filepath"
174
        "strconv"
175
        "strings"
176
        "sync"
177
        "sync/atomic"
178
        "time"
179

180
        "github.com/prometheus/client_golang/prometheus"
181
        "golang.org/x/net/http2"
182
        "golang.org/x/net/http2/h2c"
183

184
        "github.com/orneryd/nornicdb/pkg/audit"
185
        "github.com/orneryd/nornicdb/pkg/auth"
186
        "github.com/orneryd/nornicdb/pkg/buildinfo"
187
        nornicConfig "github.com/orneryd/nornicdb/pkg/config"
188
        "github.com/orneryd/nornicdb/pkg/config/dbconfig"
189
        "github.com/orneryd/nornicdb/pkg/cypher"
190
        "github.com/orneryd/nornicdb/pkg/embed"
191
        "github.com/orneryd/nornicdb/pkg/envutil"
192
        "github.com/orneryd/nornicdb/pkg/graphql"
193
        "github.com/orneryd/nornicdb/pkg/heimdall"
194
        "github.com/orneryd/nornicdb/pkg/localllm"
195
        "github.com/orneryd/nornicdb/pkg/mcp"
196
        "github.com/orneryd/nornicdb/pkg/multidb"
197
        "github.com/orneryd/nornicdb/pkg/nornicdb"
198
        "github.com/orneryd/nornicdb/pkg/observability"
199
        "github.com/orneryd/nornicdb/pkg/qdrantgrpc"
200
        "github.com/orneryd/nornicdb/pkg/search"
201
        "github.com/orneryd/nornicdb/pkg/storage"
202
        "github.com/orneryd/nornicdb/pkg/txsession"
203
)
204

205
// Errors for HTTP operations.
206
var (
207
        ErrServerClosed       = fmt.Errorf("server closed")
208
        ErrUnauthorized       = fmt.Errorf("unauthorized")
209
        ErrForbidden          = fmt.Errorf("forbidden")
210
        ErrBadRequest         = fmt.Errorf("bad request")
211
        ErrNotFound           = fmt.Errorf("not found")
212
        ErrMethodNotAllowed   = fmt.Errorf("method not allowed")
213
        ErrInternalError      = fmt.Errorf("internal server error")
214
        ErrServiceUnavailable = fmt.Errorf("service unavailable")
215
)
216

217
// embeddingCacheMemoryMB calculates approximate memory usage for embedding cache.
218
// Each cached embedding uses: cacheSize * dimensions * 4 bytes (float32).
219
func embeddingCacheMemoryMB(cacheSize, dimensions int) int {
1✔
220
        return cacheSize * dimensions * 4 / 1024 / 1024
1✔
221
}
1✔
222

223
// waitForDurationOrServerClose sleeps for d and returns true only when the full
224
// duration elapsed. It returns false when the server is closing so background
225
// retry loops can exit promptly during shutdown.
226
func waitForDurationOrServerClose(s *Server, d time.Duration) bool {
1✔
227
        if d <= 0 {
2✔
228
                return true
1✔
229
        }
1✔
230
        if s == nil {
2✔
231
                time.Sleep(d)
1✔
232
                return true
1✔
233
        }
1✔
234

235
        timer := time.NewTimer(d)
1✔
236
        defer timer.Stop()
1✔
237
        poll := time.NewTicker(250 * time.Millisecond)
1✔
238
        defer poll.Stop()
1✔
239

1✔
240
        for {
2✔
241
                if s.closed.Load() {
2✔
242
                        return false
1✔
243
                }
1✔
244
                select {
1✔
245
                case <-timer.C:
1✔
246
                        return true
1✔
247
                case <-poll.C:
1✔
248
                        if s.closed.Load() {
2✔
249
                                return false
1✔
250
                        }
1✔
251
                }
252
        }
253
}
254

255
// buildEmbedConfigFromResolved builds an embed.Config from per-DB effective map and server config fallbacks.
256
// Used by the per-DB embedder registry when EmbedConfigForDB is set.
257
func buildEmbedConfigFromResolved(effective map[string]string, fallback *Config) *embed.Config {
1✔
258
        if fallback == nil {
2✔
259
                return nil
1✔
260
        }
1✔
261
        get := func(key, def string) string {
2✔
262
                if v := effective[key]; v != "" {
2✔
263
                        return strings.TrimSpace(v)
1✔
264
                }
1✔
265
                return def
1✔
266
        }
267
        getInt := func(key string, def int) int {
2✔
268
                if v := effective[key]; v != "" {
2✔
269
                        if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
2✔
270
                                return i
1✔
271
                        }
1✔
272
                }
273
                return def
1✔
274
        }
275
        provider := get("NORNICDB_EMBEDDING_PROVIDER", fallback.EmbeddingProvider)
1✔
276
        if provider == "" {
2✔
277
                provider = "openai"
1✔
278
        }
1✔
279
        model := get("NORNICDB_EMBEDDING_MODEL", fallback.EmbeddingModel)
1✔
280
        apiURL := get("NORNICDB_EMBEDDING_API_URL", fallback.EmbeddingAPIURL)
1✔
281
        apiKey := get("NORNICDB_EMBEDDING_API_KEY", fallback.EmbeddingAPIKey)
1✔
282
        dimensions := getInt("NORNICDB_EMBEDDING_DIMENSIONS", fallback.EmbeddingDimensions)
1✔
283
        if dimensions <= 0 {
2✔
284
                dimensions = 1024
1✔
285
        }
1✔
286
        gpuLayers := getInt("NORNICDB_EMBEDDING_GPU_LAYERS", 0)
1✔
287
        cfg := &embed.Config{
1✔
288
                Provider:   provider,
1✔
289
                APIURL:     apiURL,
1✔
290
                APIKey:     apiKey,
1✔
291
                Model:      model,
1✔
292
                Dimensions: dimensions,
1✔
293
                ModelsDir:  fallback.ModelsDir,
1✔
294
                Timeout:    30 * time.Second,
1✔
295
                GPULayers:  gpuLayers,
1✔
296
        }
1✔
297
        switch provider {
1✔
298
        case "ollama":
1✔
299
                cfg.APIPath = "/api/embeddings"
1✔
300
        case "openai":
1✔
301
                cfg.APIPath = "/v1/embeddings"
1✔
302
        case "local":
1✔
303
                // no APIPath
304
        default:
1✔
305
                cfg.APIPath = "/api/embeddings"
1✔
306
        }
307
        return cfg
1✔
308
}
309

310
// Config holds HTTP server configuration options.
311
//
312
// All settings have sensible defaults via DefaultConfig(). The server follows
313
// Neo4j conventions where applicable (default port 7474, timeouts, etc.).
314
//
315
// Example:
316
//
317
//        // Production configuration
318
//        config := &server.Config{
319
//                Address:           "0.0.0.0",
320
//                Port:              7474,
321
//                ReadTimeout:       30 * time.Second,
322
//                WriteTimeout:      60 * time.Second,
323
//                MaxRequestSize:    50 * 1024 * 1024, // 50MB for large imports
324
//                EnableCORS:        true,
325
//                CORSOrigins:       []string{"https://myapp.com"},
326
//                EnableCompression: true,
327
//                TLSCertFile:       "/etc/ssl/server.crt",
328
//                TLSKeyFile:        "/etc/ssl/server.key",
329
//        }
330
//
331
//        // Development configuration with CORS for local UI
332
//        config = server.DefaultConfig()
333
//        config.Port = 8080
334
//        config.EnableCORS = true
335
//        config.CORSOrigins = []string{"http://localhost:3000"} // Local dev UI only
336
type Config struct {
337
        // Address to bind to (default: "127.0.0.1" - localhost only for security)
338
        // Set to "0.0.0.0" to listen on all interfaces (required for Docker/external access)
339
        Address string
340
        // PerDBYAMLOverrides carries the `databases:` map parsed from
341
        // nornicdb.yaml. Server.New consumes it during system-DB load to seed
342
        // dbconfig.Store on first boot via LoadWithYAMLDefaults — admin-API
343
        // edits remain authoritative across restarts. Must be set BEFORE
344
        // server.New runs; the post-construction setter is gone because the
345
        // store load happens inside New and won't see late assignments.
346
        PerDBYAMLOverrides map[string]map[string]string
347
        // Port to listen on (default: 7474)
348
        Port int
349
        // BoltPort is the port the Bolt protocol server listens on. Surfaced
350
        // in the discovery response so browser clients constructing
351
        // neo4j-driver Bolt-over-WS sessions know where to connect.
352
        // Default: 7687 when zero.
353
        BoltPort int
354
        // ReadTimeout for requests
355
        ReadTimeout time.Duration
356
        // WriteTimeout for responses
357
        WriteTimeout time.Duration
358
        // IdleTimeout for keep-alive connections
359
        IdleTimeout time.Duration
360
        // MaxRequestSize in bytes (default: 10MB)
361
        MaxRequestSize int64
362
        // EnableCORS for cross-origin requests (default: false for security)
363
        EnableCORS bool
364
        // CORSOrigins allowed origins (default: empty - must be explicitly configured)
365
        // WARNING: Never use "*" with credentials - this is a CSRF vulnerability
366
        CORSOrigins []string
367
        // EnableCompression for responses
368
        EnableCompression bool
369

370
        // Rate Limiting Configuration (DoS protection)
371
        // RateLimitEnabled enables IP-based rate limiting (default: true)
372
        RateLimitEnabled bool
373
        // RateLimitPerMinute max requests per IP per minute (default: 100)
374
        RateLimitPerMinute int
375
        // RateLimitPerHour max requests per IP per hour (default: 3000)
376
        RateLimitPerHour int
377
        // RateLimitBurst max burst size for short request spikes (default: 20)
378
        RateLimitBurst int
379
        // TLSCertFile for HTTPS
380
        TLSCertFile string
381
        // TLSKeyFile for HTTPS
382
        TLSKeyFile string
383

384
        // HTTP/2 Configuration
385
        // HTTP/2 is always enabled (backwards compatible with HTTP/1.1)
386
        // HTTP/2 provides multiplexing, header compression, and improved performance
387
        // HTTP/1.1 clients continue to work normally
388
        // HTTP2MaxConcurrentStreams limits the number of concurrent streams per connection (default: 250)
389
        // - 250: Go's internal default, matches standard library behavior (default)
390
        // - 100: Lower memory usage, good for resource-constrained environments
391
        // - 500-1000: High concurrency scenarios, uses more memory per connection
392
        // - Very high values (>1000) are not recommended due to DoS attack risk
393
        HTTP2MaxConcurrentStreams uint32
394

395
        // MCP Configuration (Model Context Protocol)
396
        // MCPEnabled controls whether the MCP server is started (default: true)
397
        // Set to false to disable MCP tools entirely
398
        // Env: NORNICDB_MCP_ENABLED=true|false
399
        MCPEnabled bool
400

401
        // Embedding Configuration (for vector search)
402
        // EmbeddingEnabled turns on automatic embedding generation
403
        EmbeddingEnabled bool
404
        // EmbeddingProvider: "ollama" or "openai" or "local"
405
        EmbeddingProvider string
406
        // EmbeddingAPIURL is the base URL (e.g., http://localhost:11434)
407
        EmbeddingAPIURL string
408
        // EmbeddingModel is the model name (e.g., bge-m3)
409
        EmbeddingModel string
410
        // EmbeddingDimensions is expected vector size (e.g., 1024)
411
        EmbeddingDimensions int
412
        // EmbeddingCacheSize is max embeddings to cache (0 = disabled, default: 10000)
413
        // Each cached embedding uses ~4KB (1024 dims × 4 bytes)
414
        EmbeddingCacheSize int
415
        // EmbeddingAPIKey is the API key for authenticated embedding providers (OpenAI, Cloudflare Workers AI, etc.)
416
        // Env: NORNICDB_EMBEDDING_API_KEY
417
        EmbeddingAPIKey string
418
        // ModelsDir is the directory containing local GGUF models
419
        // Env: NORNICDB_MODELS_DIR (default: ./models)
420
        ModelsDir string
421

422
        // Slow Query Logging Configuration
423
        // SlowQueryEnabled turns on slow query logging (default: true)
424
        SlowQueryEnabled bool
425
        // D-04d: SlowQueryThreshold and SlowQueryLogFile collapsed into
426
        // pkg/config.LoggingConfig (the single source of truth). Threaded into
427
        // the server via the Logging field below; readers go through
428
        // s.config.Logging.SlowQueryThreshold / .SlowQueryLogFile.
429
        //
430
        // Logging carries the runtime LoggingConfig snapshot. Populated by
431
        // cmd/nornicdb/main.go from cfg.Logging at server construction.
432
        Logging nornicConfig.LoggingConfig
433

434
        // Headless Mode Configuration
435
        // Headless disables the web UI and browser-related endpoints
436
        // Set to true for API-only deployments (e.g., embedded use, microservices)
437
        // Env: NORNICDB_HEADLESS=true|false
438
        Headless bool
439

440
        // BasePath for deployment behind a reverse proxy with URL prefix
441
        // Example: "/nornicdb" when deployed at https://example.com/nornicdb/
442
        // Leave empty for root deployment (default)
443
        // Env: NORNICDB_BASE_PATH
444
        BasePath string
445

446
        // Plugins Configuration
447
        // PluginsDir is the directory for APOC/function plugins
448
        // Env: NORNICDB_PLUGINS_DIR
449
        PluginsDir string
450
        // HeimdallPluginsDir is the directory for Heimdall plugins
451
        // Env: NORNICDB_HEIMDALL_PLUGINS_DIR
452
        HeimdallPluginsDir string
453

454
        // Features configuration (passed from main config loading)
455
        // This contains feature flags like HeimdallEnabled loaded from YAML/env
456
        Features *nornicConfig.FeatureFlagsConfig
457

458
        // Debug/Profiling Configuration
459
        // EnablePprof enables /debug/pprof endpoints for performance profiling
460
        // WARNING: Only enable in development/testing environments
461
        // Env: NORNICDB_ENABLE_PPROF=true|false
462
        EnablePprof bool
463

464
        // Logger is the structured-logging entrypoint per Phase 2 D-01.
465
        // If nil, a discard-handler fallback is installed at New() — graceful
466
        // degrade for the transitional period; ctors will be tightened post-M1
467
        // once all consumers are updated to pass an explicit logger via
468
        // observability.Provider.Logger().
469
        Logger *slog.Logger
470
}
471

472
// DefaultConfig returns Neo4j-compatible default server configuration.
473
//
474
// Defaults match Neo4j HTTP server settings:
475
//   - Port 7474 (Neo4j HTTP default)
476
//   - 30s read timeout
477
//   - 60s write timeout
478
//   - 120s idle timeout
479
//   - 10MB max request size
480
//   - CORS enabled for browser compatibility
481
//   - Compression enabled
482
//
483
// Embedding defaults (for MCP vector search):
484
//   - Enabled by default, connects to localhost:11434 (llama.cpp/Ollama)
485
//   - Model: bge-m3 (1024 dimensions)
486
//   - Falls back to text search if embeddings unavailable
487
//
488
// Environment Variables to override embedding config:
489
//
490
//        NORNICDB_EMBEDDING_ENABLED=true|false  - Enable/disable embeddings
491
//        NORNICDB_EMBEDDING_PROVIDER=openai     - API format: "openai" or "ollama"
492
//        NORNICDB_EMBEDDING_URL=http://...      - Embeddings API URL
493
//        NORNICDB_EMBEDDING_MODEL=bge-m3
494
//        NORNICDB_EMBEDDING_DIM=1024            - Vector dimensions
495
//
496
// Example:
497
//
498
//        config := server.DefaultConfig()
499
//        server, err := server.New(db, auth, config)
500
//
501
//        // Or customize
502
//        config = server.DefaultConfig()
503
//        config.Port = 8080
504
//        config.EnableCORS = false
505
//        server, err = server.New(db, auth, config)
506
func DefaultConfig() *Config {
1✔
507
        return &Config{
1✔
508
                // SECURITY: Bind to localhost only by default - prevents external access
1✔
509
                // Set Address to "0.0.0.0" for Docker/container deployments or external access
1✔
510
                Address:        "127.0.0.1",
1✔
511
                Port:           7474,
1✔
512
                ReadTimeout:    30 * time.Second,
1✔
513
                WriteTimeout:   300 * time.Second, // Bifrost agentic loops (many tool calls) can run 1–2 min; avoid closing stream early
1✔
514
                IdleTimeout:    120 * time.Second,
1✔
515
                MaxRequestSize: 10 * 1024 * 1024, // 10MB
1✔
516
                // CORS enabled by default for ease of use (allows all origins)
1✔
517
                // Override via: NORNICDB_CORS_ENABLED=false or NORNICDB_CORS_ORIGINS=https://myapp.com
1✔
518
                // WARNING: "*" allows any origin - configure specific origins for production
1✔
519
                EnableCORS:        true,
1✔
520
                CORSOrigins:       []string{"*"}, // Allow all origins by default
1✔
521
                EnableCompression: true,
1✔
522

1✔
523
                // Rate limiting enabled by default to prevent DoS attacks
1✔
524
                // High limits for high-performance local/development use
1✔
525
                RateLimitEnabled:   false,
1✔
526
                RateLimitPerMinute: 10000,  // 10,000 requests/minute per IP (166/sec)
1✔
527
                RateLimitPerHour:   100000, // 100,000 requests/hour per IP
1✔
528
                RateLimitBurst:     1000,   // Allow large bursts for batch operations
1✔
529

1✔
530
                // MCP server enabled by default
1✔
531
                // Override: NORNICDB_MCP_ENABLED=false
1✔
532
                MCPEnabled: true,
1✔
533

1✔
534
                // Embedding defaults - connects to local llama.cpp/Ollama server
1✔
535
                // Override via environment variables:
1✔
536
                //   NORNICDB_EMBEDDING_ENABLED=false     - Disable embeddings entirely
1✔
537
                //   NORNICDB_EMBEDDING_PROVIDER=ollama   - Use "ollama" or "openai" format
1✔
538
                //   NORNICDB_EMBEDDING_URL=http://...    - Embeddings API URL
1✔
539
                //   NORNICDB_EMBEDDING_MODEL=...         - Model name
1✔
540
                //   NORNICDB_EMBEDDING_DIM=1024          - Vector dimensions
1✔
541
                EmbeddingEnabled:    true,
1✔
542
                EmbeddingProvider:   "ollama", // default URL targets Ollama (port 11434)
1✔
543
                EmbeddingAPIURL:     "http://localhost:11434",
1✔
544
                EmbeddingModel:      "bge-m3",
1✔
545
                EmbeddingDimensions: 1024,
1✔
546
                EmbeddingCacheSize:  10000, // ~40MB cache for 1024-dim vectors
1✔
547

1✔
548
                // Slow query logging enabled by default
1✔
549
                // Override via:
1✔
550
                //   NORNICDB_SLOW_QUERY_ENABLED=false
1✔
551
                //   NORNICDB_SLOW_QUERY_THRESHOLD=200ms
1✔
552
                //   NORNICDB_SLOW_QUERY_LOG=/var/log/nornicdb/slow.log
1✔
553
                // D-04d: Threshold + LogFile defaults now live in
1✔
554
                // pkg/config.DefaultConfig().Logging (the single source of truth);
1✔
555
                // callers populate Logging from cfg.Logging.
1✔
556
                SlowQueryEnabled: false,
1✔
557
                Logging:          nornicConfig.LoggingConfig{SlowQueryThreshold: 100 * time.Millisecond},
1✔
558

1✔
559
                // Headless mode disabled by default (UI enabled)
1✔
560
                // Override via:
1✔
561
                //   NORNICDB_HEADLESS=true
1✔
562
                //   --headless flag
1✔
563
                Headless: false,
1✔
564

1✔
565
                // Pprof disabled by default (security: profiling endpoints expose internals)
1✔
566
                // Override via:
1✔
567
                //   NORNICDB_ENABLE_PPROF=true
1✔
568
                EnablePprof: envutil.GetBoolStrict("NORNICDB_ENABLE_PPROF", false),
1✔
569

1✔
570
                // HTTP/2 always enabled (backwards compatible with HTTP/1.1)
1✔
571
                // MaxConcurrentStreams: 250 matches Go's internal default
1✔
572
                // - Matches standard library http2.Server default (250)
1✔
573
                // - Good balance between performance and memory usage
1✔
574
                // - Can be reduced to 100 for lower-memory environments
1✔
575
                // - Can be increased to 500+ for high-concurrency scenarios
1✔
576
                HTTP2MaxConcurrentStreams: 250,
1✔
577
        }
1✔
578
}
1✔
579

580
// Server is the HTTP API server providing Neo4j-compatible endpoints.
581
//
582
// The server is thread-safe and handles concurrent requests. It maintains
583
// metrics, supports graceful shutdown, and integrates with audit logging.
584
//
585
// Lifecycle:
586
//  1. Create with New()
587
//  2. Optionally set audit logger with SetAuditLogger()
588
//  3. Start with Start()
589
//  4. Handle requests automatically
590
//  5. Stop with Stop() for graceful shutdown
591
//
592
// Example:
593
//
594
//        server := server.New(db, auth, config)
595
//
596
//        // Set up audit logging
597
//        auditLogger, _ := audit.NewLogger(audit.DefaultConfig())
598
//        server.SetAuditLogger(auditLogger)
599
//
600
//        // Start server
601
//        if err := server.Start(); err != nil {
602
//                log.Fatal(err)
603
//        }
604
//
605
//        // Server is now handling requests
606
//        // (Listening on server.Addr())
607
//
608
//        // Get metrics
609
//        stats := server.Stats()
610
//        // stats.RequestCount, stats.ErrorCount expose request/error counts
611
//
612
//        // Graceful shutdown
613
//        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
614
//        defer cancel()
615
//        server.Stop(ctx)
616
type Server struct {
617
        config    *Config
618
        db        *nornicdb.DB
619
        dbManager *multidb.DatabaseManager // Manages multiple databases
620
        auth      *auth.Authenticator
621
        audit     *audit.Logger
622

623
        // log is the structured logger for operational events (Phase 2 D-01).
624
        // Tagged .With("component", "server") at construction so every record
625
        // carries component attribution. NEVER nil after New() returns
626
        // (discard-fallback handler installed when cfg.Logger == nil).
627
        log *slog.Logger
628

629
        // MCP server for LLM tool interface
630
        mcpServer *mcp.Server
631

632
        // Heimdall - AI assistant for database management
633
        heimdallHandler *heimdall.Handler
634

635
        // GraphQL handler for GraphQL API
636
        graphqlHandler *graphql.Handler
637

638
        // Qdrant-compatible gRPC server (optional; feature-flagged).
639
        qdrantGRPCServer      *qdrantgrpc.Server
640
        qdrantCollectionStore qdrantgrpc.CollectionStore
641

642
        httpServer *http.Server
643
        listener   net.Listener
644
        // requestsCtx is the parent context handed to every inbound HTTP request
645
        // via http.Server.BaseContext. Cancelled at the start of Stop() so any
646
        // long-running handler (Cypher traversals, etc.) can unwind promptly
647
        // instead of holding shutdown open until ReadTimeout/WriteTimeout fires.
648
        requestsCtx       context.Context
649
        cancelRequestsCtx context.CancelFunc
650

651
        // httpMetrics is the Plan-04-02 HTTP catalog bag (D-02 typed handle DI).
652
        // Populated by SetHTTPMetrics(...) AFTER observability.New runs in
653
        // cmd/nornicdb/main.go (Phase 2 D-08 two-phase bootstrap: server is
654
        // constructed BEFORE obs to keep the existing logger plumbing; metrics
655
        // bag is injected post-hoc and applied at Start() time when the http
656
        // handler is wrapped). Nil-safe: instrumentedMux is a pass-through
657
        // when nil, so test fixtures and pre-Phase-4 callers compile unchanged.
658
        httpMetrics *observability.HTTPMetrics
659

660
        // obsRegistry is the unified pkg/observability *prometheus.Registry,
661
        // injected post-construction via SetObsRegistry from cmd/nornicdb/main.go.
662
        // Used by handleMetrics (Phase 5 / Plan 05-04) to call
663
        // observability.RenderLegacy and produce the legacy :7474/metrics body
664
        // from the same registry that backs :9090/metrics — eliminating the
665
        // pre-Phase-5 hand-built second source of truth (ROADMAP SC #1).
666
        // Nil-safe: handleMetrics tolerates nil (RenderLegacy returns empty bytes).
667
        obsRegistry *prometheus.Registry
668

669
        // Rate limiter for DoS protection
670
        rateLimiter *IPRateLimiter
671

672
        // OAuth manager for OAuth 2.0 authentication
673
        oauthManager *auth.OAuthManager
674

675
        // Cache for Basic auth results to avoid bcrypt+JWT work on every request.
676
        // This materially improves throughput for Neo4j-compatible clients that
677
        // send Basic auth on each request.
678
        basicAuthCache *auth.BasicAuthCache
679

680
        mu      sync.RWMutex
681
        closed  atomic.Bool
682
        started time.Time
683

684
        // Metrics
685
        requestCount   atomic.Int64
686
        errorCount     atomic.Int64
687
        activeRequests atomic.Int64
688
        activeTxReqs   atomic.Int64
689

690
        // Slow query logging
691
        slowQueryLogger *log.Logger
692
        slowQueryCount  atomic.Int64
693

694
        // Cached search services per database (namespace-aware indexes)
695
        searchServicesMu sync.RWMutex
696
        searchServices   map[string]*search.Service
697

698
        // Cached Cypher executors per database (thread-safe, reusable)
699
        executorsMu sync.RWMutex
700
        executors   map[string]*cypher.StorageExecutor
701

702
        // Explicit transaction sessions shared across transports.
703
        txSessions *txsession.Manager
704

705
        // Per-database access control (Neo4j-aligned). When auth disabled, Full is used.
706
        // When auth enabled, allowlistStore (if set) provides allowlist-based mode per principal.
707
        databaseAccessMode    auth.DatabaseAccessMode
708
        allowlistStore        *auth.AllowlistStore        // loaded from system DB when auth enabled
709
        roleStore             *auth.RoleStore             // user-defined roles when auth enabled
710
        privilegesStore       *auth.PrivilegesStore       // per-DB read/write (Phase 4) when auth enabled
711
        roleEntitlementsStore *auth.RoleEntitlementsStore // per-role global entitlements when auth enabled
712
        dbConfigStore         *dbconfig.Store             // per-DB config overrides (embedding, search, etc.)
713
        // perDBYAMLOverrides carries the `databases:` map from nornicdb.yaml,
714
        // passed through by the binary at construction time. Seeded into
715
        // dbConfigStore on first boot via LoadWithYAMLDefaults; subsequent
716
        // admin API edits are authoritative across restarts.
717
        perDBYAMLOverrides map[string]map[string]string
718
}
719

720
// ensureSearchBuildStartedForKnownDatabases reconciles search-service startup for
721
// databases known to DatabaseManager, including metadata-only empty databases.
722
// It is safe to call repeatedly; per-db build start is idempotent.
723
func (s *Server) ensureSearchBuildStartedForKnownDatabases() {
1✔
724
        if s == nil || s.db == nil || s.dbManager == nil {
2✔
725
                return
1✔
726
        }
1✔
727
        for _, info := range s.dbManager.ListDatabases() {
2✔
728
                if info == nil || info.Name == "" || info.Name == "system" {
2✔
729
                        continue
1✔
730
                }
731
                if s.dbManager.IsCompositeDatabase(info.Name) {
2✔
732
                        continue
1✔
733
                }
734
                status := s.db.GetDatabaseSearchStatus(info.Name)
1✔
735
                if status.Initialized {
2✔
736
                        continue
1✔
737
                }
738
                storageEngine, err := s.dbManager.GetStorage(info.Name)
1✔
739
                if err != nil {
1✔
740
                        s.log.Warn("startup search reconcile: storage unavailable", "subsystem", "search", "db", info.Name, "error", err)
×
741
                        continue
×
742
                }
743
                if _, err := s.db.EnsureSearchIndexesBuildStarted(info.Name, storageEngine); err != nil {
1✔
744
                        s.log.Warn("startup search reconcile failed", "subsystem", "search", "db", info.Name, "error", err)
×
745
                }
×
746
        }
747
}
748

749
// mcpToolRunnerAdapter adapts pkg/mcp.Server to heimdall.InMemoryToolRunner so the agentic loop
750
// can expose store, recall, discover, link, task, tasks to the LLM and execute them in process.
751
// allowlist: nil = all tools, empty = no tools, non-empty = only those names.
752
type mcpToolRunnerAdapter struct {
753
        s         *mcp.Server
754
        allowlist []string
755
}
756

757
func (a *mcpToolRunnerAdapter) allowedNames() []string {
1✔
758
        if a.allowlist == nil {
2✔
759
                return mcp.AllTools()
1✔
760
        }
1✔
761
        return a.allowlist
1✔
762
}
763

764
func (a *mcpToolRunnerAdapter) allow(name string) bool {
1✔
765
        allowed := a.allowedNames()
1✔
766
        for _, n := range allowed {
2✔
767
                if n == name {
2✔
768
                        return true
1✔
769
                }
1✔
770
        }
771
        return false
1✔
772
}
773

774
func (a *mcpToolRunnerAdapter) ToolDefinitions() []heimdall.MCPTool {
1✔
775
        defs := a.s.ToolDefinitions()
1✔
776
        allowed := a.allowedNames()
1✔
777
        if len(allowed) == 0 {
2✔
778
                return nil
1✔
779
        }
1✔
780
        allowSet := make(map[string]struct{}, len(allowed))
1✔
781
        for _, n := range allowed {
2✔
782
                allowSet[n] = struct{}{}
1✔
783
        }
1✔
784
        var out []heimdall.MCPTool
1✔
785
        for _, t := range defs {
2✔
786
                if _, ok := allowSet[t.Name]; ok {
2✔
787
                        out = append(out, heimdall.MCPTool{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
1✔
788
                }
1✔
789
        }
790
        return out
1✔
791
}
792

793
func (a *mcpToolRunnerAdapter) ToolNames() []string {
1✔
794
        return a.allowedNames()
1✔
795
}
1✔
796

797
func (a *mcpToolRunnerAdapter) CallTool(ctx context.Context, name string, args map[string]interface{}, dbName string) (interface{}, error) {
1✔
798
        if !a.allow(name) {
2✔
799
                return nil, fmt.Errorf("tool %q is not in the MCP allowlist", name)
1✔
800
        }
1✔
801
        // Ensure we always pass a concrete database so MCP uses DatabaseScopedExecutor when set.
802
        // Empty dbName would cause MCP to fall back to s.db, which can diverge from the default DB in multi-db setups.
803
        if dbName == "" {
2✔
804
                dbName = a.s.DefaultDatabaseName()
1✔
805
        }
1✔
806
        ctx = mcp.ContextWithDatabase(ctx, dbName)
1✔
807
        return a.s.CallTool(ctx, name, args)
1✔
808
}
809

810
// mcpDatabaseScopedExecutor returns a callback that provides a Cypher executor and node getter for the given database.
811
// Used so MCP tools (store, recall, link, etc.) run against the request's database when invoked from the agentic loop.
812
func (s *Server) mcpDatabaseScopedExecutor() func(dbName string) (*cypher.StorageExecutor, func(context.Context, string) (*nornicdb.Node, error), error) {
1✔
813
        return func(dbName string) (*cypher.StorageExecutor, func(context.Context, string) (*nornicdb.Node, error), error) {
2✔
814
                exec, err := s.getExecutorForDatabase(dbName)
1✔
815
                if err != nil {
2✔
816
                        return nil, nil, err
1✔
817
                }
1✔
818
                getNode := func(ctx context.Context, id string) (*nornicdb.Node, error) {
2✔
819
                        result, err := exec.Execute(ctx, "MATCH (n) WHERE elementId(n) = $id RETURN n", map[string]interface{}{"id": id})
1✔
820
                        if err != nil {
1✔
821
                                return nil, err
×
822
                        }
×
823
                        if len(result.Rows) == 0 || len(result.Rows[0]) == 0 {
2✔
824
                                return nil, nornicdb.ErrNotFound
1✔
825
                        }
1✔
826
                        v := result.Rows[0][0]
1✔
827
                        if snode, ok := v.(*storage.Node); ok {
2✔
828
                                props := make(map[string]interface{}, len(snode.Properties))
1✔
829
                                for k, val := range snode.Properties {
2✔
830
                                        props[k] = val
1✔
831
                                }
1✔
832
                                return &nornicdb.Node{
1✔
833
                                        ID:         string(snode.ID),
1✔
834
                                        Labels:     snode.Labels,
1✔
835
                                        Properties: props,
1✔
836
                                        CreatedAt:  snode.CreatedAt,
1✔
837
                                }, nil
1✔
838
                        }
839
                        return nil, nornicdb.ErrNotFound
×
840
                }
841
                return exec, getNode, nil
1✔
842
        }
843
}
844

845
// IPRateLimiter provides IP-based rate limiting to prevent DoS attacks.
846
type IPRateLimiter struct {
847
        mu              sync.RWMutex
848
        counters        map[string]*ipRateLimitCounter
849
        perMinute       int
850
        perHour         int
851
        burst           int
852
        cleanupInterval time.Duration
853
        stopCleanup     chan struct{}
854
}
855

856
type ipRateLimitCounter struct {
857
        mu          sync.Mutex
858
        minuteCount int
859
        hourCount   int
860
        minuteReset time.Time
861
        hourReset   time.Time
862
}
863

864
// NewIPRateLimiter creates a new IP-based rate limiter.
865
func NewIPRateLimiter(perMinute, perHour, burst int) *IPRateLimiter {
1✔
866
        rl := &IPRateLimiter{
1✔
867
                counters:        make(map[string]*ipRateLimitCounter),
1✔
868
                perMinute:       perMinute,
1✔
869
                perHour:         perHour,
1✔
870
                burst:           burst,
1✔
871
                cleanupInterval: 10 * time.Minute,
1✔
872
                stopCleanup:     make(chan struct{}),
1✔
873
        }
1✔
874
        // Start background cleanup of stale entries
1✔
875
        go rl.cleanupLoop()
1✔
876
        return rl
1✔
877
}
1✔
878

879
// Allow checks if a request from the given IP is allowed.
880
func (rl *IPRateLimiter) Allow(ip string) bool {
1✔
881
        rl.mu.Lock()
1✔
882
        counter, exists := rl.counters[ip]
1✔
883
        if !exists {
2✔
884
                counter = &ipRateLimitCounter{
1✔
885
                        minuteReset: time.Now().Add(time.Minute),
1✔
886
                        hourReset:   time.Now().Add(time.Hour),
1✔
887
                }
1✔
888
                rl.counters[ip] = counter
1✔
889
        }
1✔
890
        rl.mu.Unlock()
1✔
891

1✔
892
        counter.mu.Lock()
1✔
893
        defer counter.mu.Unlock()
1✔
894

1✔
895
        now := time.Now()
1✔
896

1✔
897
        // Reset minute counter if needed
1✔
898
        if now.After(counter.minuteReset) {
2✔
899
                counter.minuteCount = 0
1✔
900
                counter.minuteReset = now.Add(time.Minute)
1✔
901
        }
1✔
902

903
        // Reset hour counter if needed
904
        if now.After(counter.hourReset) {
2✔
905
                counter.hourCount = 0
1✔
906
                counter.hourReset = now.Add(time.Hour)
1✔
907
        }
1✔
908

909
        // Check limits
910
        if counter.minuteCount >= rl.perMinute {
2✔
911
                return false
1✔
912
        }
1✔
913
        if counter.hourCount >= rl.perHour {
1✔
914
                return false
×
915
        }
×
916

917
        // Increment counters
918
        counter.minuteCount++
1✔
919
        counter.hourCount++
1✔
920

1✔
921
        return true
1✔
922
}
923

924
// cleanupLoop periodically removes stale IP entries to prevent memory leaks.
925
func (rl *IPRateLimiter) cleanupLoop() {
1✔
926
        ticker := time.NewTicker(rl.cleanupInterval)
1✔
927
        defer ticker.Stop()
1✔
928

1✔
929
        for {
2✔
930
                select {
1✔
931
                case <-ticker.C:
1✔
932
                        rl.cleanup()
1✔
933
                case <-rl.stopCleanup:
1✔
934
                        return
1✔
935
                }
936
        }
937
}
938

939
func (rl *IPRateLimiter) cleanup() {
1✔
940
        rl.mu.Lock()
1✔
941
        defer rl.mu.Unlock()
1✔
942

1✔
943
        now := time.Now()
1✔
944
        for ip, counter := range rl.counters {
2✔
945
                counter.mu.Lock()
1✔
946
                // Remove if both counters have been reset (inactive for >1 hour)
1✔
947
                if now.After(counter.hourReset) {
2✔
948
                        delete(rl.counters, ip)
1✔
949
                }
1✔
950
                counter.mu.Unlock()
1✔
951
        }
952
}
953

954
// Stop stops the cleanup goroutine.
955
func (rl *IPRateLimiter) Stop() {
1✔
956
        close(rl.stopCleanup)
1✔
957
}
1✔
958

959
// New creates a new HTTP server with the given database, authenticator, and configuration.
960
//
961
// The server is created but not started. Call Start() to begin accepting connections.
962
//
963
// Parameters:
964
//   - db: NornicDB database instance (required)
965
//   - authenticator: Authentication handler (can be nil to disable auth)
966
//   - config: Server configuration (uses DefaultConfig() if nil)
967
//
968
// Returns:
969
//   - Server instance ready to start
970
//   - Error if database is nil or configuration is invalid
971
//
972
// Example:
973
//
974
//        // With authentication
975
//        db, _ := nornicdb.Open("./data", nil)
976
//        auth, _ := auth.NewAuthenticator(auth.DefaultAuthConfig())
977
//        server, err := server.New(db, auth, nil) // Uses default config
978
//
979
//        // Without authentication (development)
980
//        server, err = server.New(db, nil, nil)
981
//
982
//        // Custom configuration
983
//        config := &server.Config{
984
//                Port: 8080,
985
//                EnableCORS: false,
986
//        }
987
//        server, err = server.New(db, auth, config)
988
func New(db *nornicdb.DB, authenticator *auth.Authenticator, config *Config) (*Server, error) {
1✔
989
        if config == nil {
2✔
990
                config = DefaultConfig()
1✔
991
        }
1✔
992
        if db == nil {
2✔
993
                return nil, fmt.Errorf("database required")
1✔
994
        }
1✔
995
        // Phase 2 D-01a: graceful-degrade discard fallback when caller did not
996
        // thread observability.Provider.Logger() through Config.Logger. Keeps
997
        // existing tests/callers compileable; tightens post-M1 once all
998
        // consumers wire the logger explicitly.
999
        if config.Logger == nil {
2✔
1000
                config.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
1✔
1001
        }
1✔
1002

1003
        // Note: GPU status is logged in main.go during GPU manager initialization
1004
        // This avoids duplicate logs and provides more detailed information
1005

1006
        // Load environment-backed global config once (used for multi-db + feature defaults).
1007
        globalConfig := nornicConfig.LoadFromEnv()
1✔
1008

1✔
1009
        // Create MCP server for LLM tool interface (if enabled)
1✔
1010
        var mcpServer *mcp.Server
1✔
1011
        if config.MCPEnabled {
2✔
1012
                mcpConfig := mcp.DefaultServerConfig()
1✔
1013
                mcpConfig.EmbeddingEnabled = config.EmbeddingEnabled
1✔
1014
                mcpConfig.EmbeddingModel = config.EmbeddingModel
1✔
1015
                mcpConfig.EmbeddingDimensions = config.EmbeddingDimensions
1✔
1016
                mcpConfig.DefaultNodeLabel = globalConfig.Memory.DefaultNodeLabel
1✔
1017
                mcpServer = mcp.NewServer(db, mcpConfig)
1✔
1018
        } else {
2✔
1019
                config.Logger.With("component", "server").Info("mcp server disabled via configuration")
1✔
1020
        }
1✔
1021

1022
        // Initialize DatabaseManager for multi-database support.
1023
        // IMPORTANT: This must happen before Heimdall/GraphQL so they can route per database.
1024
        //
1025
        // Get the base storage engine from the DB (unwraps the namespaced storage).
1026
        // DatabaseManager will create NamespacedEngines for each logical database.
1027
        storageEngine := db.GetBaseStorageForManager()
1✔
1028
        remoteCredentialEncryptionKey := ""
1✔
1029
        switch {
1✔
1030
        case strings.TrimSpace(os.Getenv("NORNICDB_REMOTE_CREDENTIALS_KEY")) != "":
1✔
1031
                remoteCredentialEncryptionKey = strings.TrimSpace(os.Getenv("NORNICDB_REMOTE_CREDENTIALS_KEY"))
1✔
1032
        case strings.TrimSpace(globalConfig.Database.EncryptionPassword) != "":
×
1033
                remoteCredentialEncryptionKey = strings.TrimSpace(globalConfig.Database.EncryptionPassword)
×
1034
                config.Logger.With("component", "server").Warn("remote credential encryption key fallback in use",
×
1035
                        "fallback", "database_encryption_password",
×
1036
                        "remediation", "set NORNICDB_REMOTE_CREDENTIALS_KEY for key separation")
×
1037
        case strings.TrimSpace(globalConfig.Auth.JWTSecret) != "":
1✔
1038
                remoteCredentialEncryptionKey = strings.TrimSpace(globalConfig.Auth.JWTSecret)
1✔
1039
                config.Logger.With("component", "server").Warn("remote credential encryption key fallback in use",
1✔
1040
                        "fallback", "jwt_signing_secret",
1✔
1041
                        "remediation", "set NORNICDB_REMOTE_CREDENTIALS_KEY for stronger key separation")
1✔
1042
        }
1043
        multiDBConfig := &multidb.Config{
1✔
1044
                DefaultDatabase:               globalConfig.Database.DefaultDatabase,
1✔
1045
                SystemDatabase:                "system",
1✔
1046
                MaxDatabases:                  0, // Unlimited
1✔
1047
                AllowDropDefault:              false,
1✔
1048
                RemoteCredentialEncryptionKey: remoteCredentialEncryptionKey,
1✔
1049
                RemoteEngineFactory: func(ref multidb.ConstituentRef, authToken string) (storage.Engine, error) {
2✔
1050
                        useUserPassword := strings.EqualFold(strings.TrimSpace(ref.AuthMode), "user_password")
1✔
1051
                        cfg := storage.RemoteEngineConfig{
1✔
1052
                                URI:       ref.URI,
1✔
1053
                                Database:  ref.DatabaseName,
1✔
1054
                                AuthToken: authToken,
1✔
1055
                        }
1✔
1056
                        if useUserPassword {
2✔
1057
                                cfg.User = ref.User
1✔
1058
                                cfg.Password = ref.Password
1✔
1059
                        }
1✔
1060
                        return storage.NewRemoteEngine(cfg)
1✔
1061
                },
1062
        }
1063
        dbManager, err := multidb.NewDatabaseManager(storageEngine, multiDBConfig)
1✔
1064
        if err != nil {
1✔
1065
                return nil, fmt.Errorf("failed to initialize database manager: %w", err)
×
1066
        }
×
1067

1068
        s := &Server{
1✔
1069
                config:             config,
1✔
1070
                db:                 db,
1✔
1071
                dbManager:          dbManager,
1✔
1072
                auth:               authenticator,
1✔
1073
                log:                config.Logger.With("component", "server"),
1✔
1074
                mcpServer:          mcpServer,
1✔
1075
                graphqlHandler:     graphql.NewHandler(db, dbManager),
1✔
1076
                basicAuthCache:     auth.NewBasicAuthCache(auth.DefaultAuthCacheEntries, auth.DefaultAuthCacheTTL),
1✔
1077
                searchServices:     make(map[string]*search.Service),
1✔
1078
                executors:          make(map[string]*cypher.StorageExecutor),
1✔
1079
                perDBYAMLOverrides: config.PerDBYAMLOverrides,
1✔
1080
        }
1✔
1081
        // Foreground-first policy: while tx requests are active, background embed work yields.
1✔
1082
        s.db.SetEmbedQueueShouldYield(func() bool {
1✔
1083
                return s.activeTxReqs.Load() > 0
×
1084
        })
×
1085
        s.txSessions = txsession.NewManager(30*time.Second, s.newExecutorForDatabase)
1✔
1086
        s.txSessions.SetTerminalErrorObserver(func(session *txsession.Session, err error) {
2✔
1087
                s.logMVCCSnapshotExpiration(session, err)
1✔
1088
        })
1✔
1089

1090
        // ==========================================================================
1091
        // Heimdall - AI Assistant for Database Management
1092
        // ==========================================================================
1093
        // Use features config passed from main.go (which loads from YAML + env)
1094
        // Fall back to LoadFromEnv() if not provided (for backwards compatibility)
1095
        var featuresConfig *nornicConfig.FeatureFlagsConfig
1✔
1096
        if config.Features != nil {
2✔
1097
                featuresConfig = config.Features
1✔
1098
        } else {
2✔
1099
                featuresConfig = &globalConfig.Features
1✔
1100
                config.Features = featuresConfig
1✔
1101
        }
1✔
1102
        var rerankerResolverMu sync.RWMutex
1✔
1103
        var globalRerankerResolver func(string) search.Reranker
1✔
1104
        perDBRerankerCache := make(map[string]search.Reranker)
1✔
1105
        setGlobalRerankerResolver := func(fn func(string) search.Reranker) {
2✔
1106
                rerankerResolverMu.Lock()
1✔
1107
                globalRerankerResolver = fn
1✔
1108
                rerankerResolverMu.Unlock()
1✔
1109
        }
1✔
1110
        getGlobalRerankerResolver := func() func(string) search.Reranker {
1✔
1111
                rerankerResolverMu.RLock()
×
1112
                defer rerankerResolverMu.RUnlock()
×
1113
                return globalRerankerResolver
×
1114
        }
×
1115
        resolveDBRerankConfig := func(dbName string) (enabled bool, provider, model, apiURL, apiKey string) {
2✔
1116
                enabled = featuresConfig.SearchRerankEnabled
1✔
1117
                provider = strings.TrimSpace(strings.ToLower(featuresConfig.SearchRerankProvider))
1✔
1118
                model = featuresConfig.SearchRerankModel
1✔
1119
                apiURL = strings.TrimSpace(featuresConfig.SearchRerankAPIURL)
1✔
1120
                apiKey = featuresConfig.SearchRerankAPIKey
1✔
1121
                if provider == "" {
2✔
1122
                        provider = "local"
1✔
1123
                }
1✔
1124
                if provider == "ollama" && apiURL == "" {
1✔
1125
                        apiURL = "http://localhost:11434/rerank"
×
1126
                }
×
1127
                if s.dbConfigStore == nil {
2✔
1128
                        return enabled, provider, model, apiURL, apiKey
1✔
1129
                }
1✔
1130
                overrides := s.dbConfigStore.GetOverrides(dbName)
1✔
1131
                resolved := dbconfig.Resolve(globalConfig, overrides)
1✔
1132
                if resolved == nil || resolved.Effective == nil {
1✔
1133
                        return enabled, provider, model, apiURL, apiKey
×
1134
                }
×
1135
                eff := resolved.Effective
1✔
1136
                if raw := strings.TrimSpace(strings.ToLower(eff["NORNICDB_SEARCH_RERANK_ENABLED"])); raw != "" {
2✔
1137
                        switch raw {
1✔
1138
                        case "1", "true", "yes", "on":
×
1139
                                enabled = true
×
1140
                        case "0", "false", "no", "off":
1✔
1141
                                enabled = false
1✔
1142
                        }
1143
                }
1144
                if v := strings.TrimSpace(strings.ToLower(eff["NORNICDB_SEARCH_RERANK_PROVIDER"])); v != "" {
2✔
1145
                        provider = v
1✔
1146
                }
1✔
1147
                if v := eff["NORNICDB_SEARCH_RERANK_MODEL"]; strings.TrimSpace(v) != "" {
2✔
1148
                        model = strings.TrimSpace(v)
1✔
1149
                }
1✔
1150
                if v := eff["NORNICDB_SEARCH_RERANK_API_URL"]; strings.TrimSpace(v) != "" {
1✔
1151
                        apiURL = strings.TrimSpace(v)
×
1152
                }
×
1153
                if v := eff["NORNICDB_SEARCH_RERANK_API_KEY"]; strings.TrimSpace(v) != "" {
1✔
1154
                        apiKey = v
×
1155
                }
×
1156
                if provider == "ollama" && apiURL == "" {
1✔
1157
                        apiURL = "http://localhost:11434/rerank"
×
1158
                }
×
1159
                return enabled, provider, model, apiURL, apiKey
1✔
1160
        }
1161
        getOrCreateExternalReranker := func(provider, model, apiURL, apiKey string) search.Reranker {
1✔
1162
                key := strings.Join([]string{provider, model, apiURL, apiKey}, "|")
×
1163
                rerankerResolverMu.RLock()
×
1164
                if cached, ok := perDBRerankerCache[key]; ok {
×
1165
                        rerankerResolverMu.RUnlock()
×
1166
                        return cached
×
1167
                }
×
1168
                rerankerResolverMu.RUnlock()
×
1169
                if apiURL == "" {
×
1170
                        return nil
×
1171
                }
×
1172
                ceConfig := &search.CrossEncoderConfig{
×
1173
                        Enabled:  true,
×
1174
                        APIURL:   apiURL,
×
1175
                        APIKey:   apiKey,
×
1176
                        Model:    model,
×
1177
                        TopK:     100,
×
1178
                        Timeout:  30 * time.Second,
×
1179
                        MinScore: 0.0,
×
1180
                }
×
1181
                if ceConfig.Model == "" && provider == "ollama" {
×
1182
                        ceConfig.Model = "reranker"
×
1183
                }
×
1184
                ce := search.NewCrossEncoder(ceConfig)
×
1185
                rerankerResolverMu.Lock()
×
1186
                perDBRerankerCache[key] = ce
×
1187
                rerankerResolverMu.Unlock()
×
1188
                return ce
×
1189
        }
1190
        // Install per-DB reranker resolver. It respects DB overrides and falls back to global resolver.
1191
        db.SetRerankerResolver(func(dbName string) search.Reranker {
2✔
1192
                enabled, provider, model, apiURL, apiKey := resolveDBRerankConfig(dbName)
1✔
1193
                if !enabled {
2✔
1194
                        return nil
1✔
1195
                }
1✔
1196
                if provider == "local" {
×
1197
                        if resolver := getGlobalRerankerResolver(); resolver != nil {
×
1198
                                return resolver(dbName)
×
1199
                        }
×
1200
                        return nil
×
1201
                }
1202
                if r := getOrCreateExternalReranker(provider, model, apiURL, apiKey); r != nil {
×
1203
                        return r
×
1204
                }
×
1205
                if resolver := getGlobalRerankerResolver(); resolver != nil {
×
1206
                        return resolver(dbName)
×
1207
                }
×
1208
                return nil
×
1209
        })
1210
        if featuresConfig.HeimdallEnabled {
2✔
1211
                // Derive a child logger once at goroutine entry so every record
1✔
1212
                // carries subsystem=heimdall (Phase 2 D-10a).
1✔
1213
                heimdallLog := s.log.With("subsystem", "heimdall")
1✔
1214
                heimdallLog.Info("heimdall AI assistant initializing asynchronously")
1✔
1215
                go func() {
2✔
1216
                        // Configure token budget from environment variables
1✔
1217
                        heimdall.SetTokenBudget(featuresConfig)
1✔
1218
                        heimdallCfg := heimdall.ConfigFromFeatureFlags(featuresConfig)
1✔
1219
                        // Log resolved provider so users can verify env overrides (openai/ollama/local)
1✔
1220
                        provider := strings.TrimSpace(strings.ToLower(heimdallCfg.Provider))
1✔
1221
                        if provider == "" {
1✔
1222
                                provider = "local"
×
1223
                        }
×
1224
                        heimdallLog.Info("heimdall provider resolved",
1✔
1225
                                "provider", provider,
1✔
1226
                                "override_env", "NORNICDB_HEIMDALL_PROVIDER")
1✔
1227
                        manager, err := heimdall.NewManager(heimdallCfg)
1✔
1228
                        if err != nil {
1✔
1229
                                heimdallLog.Warn("heimdall initialization failed",
×
1230
                                        "error", err,
×
1231
                                        "remediation", "check NORNICDB_HEIMDALL_MODEL and NORNICDB_MODELS_DIR")
×
1232
                                return
×
1233
                        }
×
1234
                        if baseExec := db.GetCypherExecutor(); baseExec != nil {
2✔
1235
                                baseExec.SetInferenceManager(manager)
1✔
1236
                        }
1✔
1237

1238
                        // Create database router wrapper for Heimdall (multi-db aware)
1239
                        dbRouter := newHeimdallDBRouter(db, dbManager, featuresConfig)
1✔
1240
                        metricsReader := &heimdallMetricsReader{}
1✔
1241
                        handler := heimdall.NewHandler(manager, heimdallCfg, dbRouter, metricsReader)
1✔
1242
                        // Expose MCP tools to the agentic loop only when enabled (default off to avoid context bloat)
1✔
1243
                        if mcpServer != nil && featuresConfig.HeimdallMCPEnable {
2✔
1244
                                handler.SetInMemoryToolRunner(&mcpToolRunnerAdapter{
1✔
1245
                                        s:         mcpServer,
1✔
1246
                                        allowlist: featuresConfig.HeimdallMCPTools,
1✔
1247
                                })
1✔
1248
                        }
1✔
1249

1250
                        // Initialize Heimdall plugin subsystem
1251
                        subsystemMgr := heimdall.GetSubsystemManager()
1✔
1252

1✔
1253
                        // Create the Heimdall invoker so plugins can call the LLM
1✔
1254
                        heimdallInvoker := heimdall.NewLiveHeimdallInvoker(
1✔
1255
                                subsystemMgr,
1✔
1256
                                manager, // Manager implements Generator interface
1✔
1257
                                handler.Bifrost(),
1✔
1258
                                dbRouter,
1✔
1259
                                metricsReader,
1✔
1260
                        )
1✔
1261

1✔
1262
                        subsystemCtx := heimdall.SubsystemContext{
1✔
1263
                                Config:   heimdallCfg,
1✔
1264
                                Bifrost:  handler.Bifrost(),
1✔
1265
                                Database: dbRouter,
1✔
1266
                                Metrics:  metricsReader,
1✔
1267
                                Heimdall: heimdallInvoker, // Now plugins can call p.ctx.Heimdall.SendPrompt()
1✔
1268
                        }
1✔
1269
                        subsystemMgr.SetContext(subsystemCtx)
1✔
1270

1✔
1271
                        // Load plugins from configured directories.
1✔
1272
                        if config.PluginsDir != "" {
2✔
1273
                                heimdallLog.Debug("loading APOC plugins", "dir", config.PluginsDir)
1✔
1274
                                if err := nornicdb.LoadPluginsFromDir(config.PluginsDir, &subsystemCtx); err != nil {
1✔
1275
                                        heimdallLog.Warn("failed to load APOC plugins", "dir", config.PluginsDir, "error", err)
×
1276
                                }
×
1277
                        }
1278
                        if config.HeimdallPluginsDir != "" && config.HeimdallPluginsDir != config.PluginsDir {
2✔
1279
                                heimdallLog.Debug("loading Heimdall plugins", "dir", config.HeimdallPluginsDir)
1✔
1280
                                if err := nornicdb.LoadPluginsFromDir(config.HeimdallPluginsDir, &subsystemCtx); err != nil {
1✔
1281
                                        heimdallLog.Warn("failed to load Heimdall plugins", "dir", config.HeimdallPluginsDir, "error", err)
×
1282
                                }
×
1283
                        } else if config.HeimdallPluginsDir == "" {
2✔
1284
                                heimdallLog.Debug("heimdall plugins dir is empty")
1✔
1285
                        } else {
1✔
1286
                                heimdallLog.Debug("heimdall plugins dir same as plugins dir; skipping",
×
1287
                                        "heimdall_dir", config.HeimdallPluginsDir,
×
1288
                                        "plugins_dir", config.PluginsDir)
×
1289
                        }
×
1290

1291
                        s.setHeimdallHandler(handler)
1✔
1292

1✔
1293
                        plugins := heimdall.ListHeimdallPlugins()
1✔
1294
                        actions := heimdall.ListHeimdallActions()
1✔
1295
                        heimdallLog.Info("heimdall AI assistant ready",
1✔
1296
                                "model", heimdallCfg.Model,
1✔
1297
                                "plugins_loaded", len(plugins),
1✔
1298
                                "actions_available", len(actions),
1✔
1299
                                "bifrost_chat_route", "/api/bifrost/chat/completions",
1✔
1300
                                "status_route", "/api/bifrost/status",
1✔
1301
                        )
1✔
1302
                        if len(plugins) == 0 {
2✔
1303
                                heimdallLog.Warn("no heimdall plugins loaded — watcher logs will be absent",
1✔
1304
                                        "remediation", "ensure a .so exists in HeimdallPluginsDir")
1✔
1305
                        }
1✔
1306
                        for _, actionName := range actions {
1✔
1307
                                heimdallLog.Debug("heimdall action registered", "action", actionName)
×
1308
                        }
×
1309
                }()
1310
        } else {
1✔
1311
                s.log.Info("heimdall AI assistant disabled",
1✔
1312
                        "subsystem", "heimdall",
1✔
1313
                        "override_env", "NORNICDB_HEIMDALL_ENABLED")
1✔
1314
        }
1✔
1315

1316
        // Independent search rerank (Stage-2 reranking, not tied to Heimdall).
1317
        // Supports local (GGUF, like embeddings) or external provider (ollama/openai/http),
1318
        // similar to Heimdall and embeddings.
1319
        if featuresConfig.SearchRerankEnabled {
2✔
1320
                provider := strings.TrimSpace(strings.ToLower(featuresConfig.SearchRerankProvider))
1✔
1321
                if provider == "" {
1✔
1322
                        provider = "local"
×
1323
                }
×
1324

1325
                if provider == "local" {
2✔
1326
                        // Load GGUF into memory (same pattern as embedding model).
1✔
1327
                        modelsDir := config.ModelsDir
1✔
1328
                        if modelsDir == "" {
1✔
1329
                                modelsDir = "./models"
×
1330
                        }
×
1331
                        modelName := featuresConfig.SearchRerankModel
1✔
1332
                        if modelName == "" {
1✔
1333
                                modelName = "bge-reranker-v2-m3-Q4_K_M.gguf"
×
1334
                        }
×
1335
                        if !strings.HasSuffix(modelName, ".gguf") {
2✔
1336
                                modelName = modelName + ".gguf"
1✔
1337
                        }
1✔
1338
                        modelPath := filepath.Join(modelsDir, modelName)
1✔
1339

1✔
1340
                        rerankLog := s.log.With("subsystem", "search_rerank")
1✔
1341
                        rerankLog.Info("loading search reranker model",
1✔
1342
                                "provider", "local",
1✔
1343
                                "model_path", modelPath,
1✔
1344
                                "note", "server starts immediately; reranking available after model loads")
1✔
1345

1✔
1346
                        go func() {
2✔
1347
                                opts := localllm.DefaultOptions(modelPath)
1✔
1348
                                opts.GPULayers = -1
1✔
1349
                                rerankerModel, err := localllm.LoadRerankerModel(opts)
1✔
1350
                                if err != nil {
2✔
1351
                                        rerankLog.Warn("search reranker model unavailable; stage-2 reranking disabled, RRF order only",
1✔
1352
                                                "error", err)
1✔
1353
                                        return
1✔
1354
                                }
1✔
1355

1356
                                ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
×
1357
                                _, healthErr := rerankerModel.Score(ctx, "health", "check")
×
1358
                                cancel()
×
1359
                                if healthErr != nil {
×
1360
                                        rerankerModel.Close()
×
1361
                                        rerankLog.Warn("search reranker failed health check", "error", healthErr)
×
1362
                                        return
×
1363
                                }
×
1364

1365
                                cfg := search.DefaultLocalRerankerConfig()
×
1366
                                cfg.Enabled = true
×
1367
                                r := search.NewLocalReranker(rerankerModel, cfg)
×
1368
                                db.SetSearchReranker(r)
×
1369
                                setGlobalRerankerResolver(func(string) search.Reranker { return r })
×
1370
                                rerankLog.Info("search reranker ready (stage-2 reranking enabled)",
×
1371
                                        "model", modelName)
×
1372
                        }()
1373
                } else {
1✔
1374
                        // External provider: use HTTP rerank API (Cohere, HuggingFace TEI, Ollama adapter, etc.).
1✔
1375
                        apiURL := strings.TrimSpace(featuresConfig.SearchRerankAPIURL)
1✔
1376
                        if apiURL == "" {
2✔
1377
                                if provider == "ollama" {
2✔
1378
                                        apiURL = "http://localhost:11434/rerank"
1✔
1379
                                }
1✔
1380
                        }
1381
                        if apiURL == "" {
2✔
1382
                                s.log.Warn("search rerank enabled but API URL not set; stage-2 reranking disabled",
1✔
1383
                                        "subsystem", "search_rerank",
1✔
1384
                                        "provider", provider,
1✔
1385
                                        "required_env", "NORNICDB_SEARCH_RERANK_API_URL")
1✔
1386
                        } else {
2✔
1387
                                ceConfig := &search.CrossEncoderConfig{
1✔
1388
                                        Enabled:  true,
1✔
1389
                                        APIURL:   apiURL,
1✔
1390
                                        APIKey:   featuresConfig.SearchRerankAPIKey,
1✔
1391
                                        Model:    featuresConfig.SearchRerankModel,
1✔
1392
                                        TopK:     100,
1✔
1393
                                        Timeout:  30 * time.Second,
1✔
1394
                                        MinScore: 0.0,
1✔
1395
                                }
1✔
1396
                                if ceConfig.Model == "" && provider == "ollama" {
2✔
1397
                                        ceConfig.Model = "reranker"
1✔
1398
                                }
1✔
1399
                                ce := search.NewCrossEncoder(ceConfig)
1✔
1400
                                db.SetSearchReranker(ce)
1✔
1401
                                setGlobalRerankerResolver(func(string) search.Reranker { return ce })
1✔
1402
                                s.log.Info("search reranker ready (stage-2 reranking enabled)",
1✔
1403
                                        "subsystem", "search_rerank",
1✔
1404
                                        "provider", provider,
1✔
1405
                                        "url", apiURL)
1✔
1406
                        }
1407
                }
1408
        } else {
1✔
1409
                s.log.Info("search rerank disabled",
1✔
1410
                        "subsystem", "search_rerank",
1✔
1411
                        "override_env", "NORNICDB_SEARCH_RERANK_ENABLED")
1✔
1412
        }
1✔
1413

1414
        // Configure embeddings if enabled
1415
        // Local provider doesn't need API URL, others do
1416
        embeddingsReady := config.EmbeddingEnabled && (config.EmbeddingProvider == "local" || config.EmbeddingAPIURL != "")
1✔
1417
        if embeddingsReady {
2✔
1418
                embedConfig := &embed.Config{
1✔
1419
                        Provider:   config.EmbeddingProvider,
1✔
1420
                        APIURL:     config.EmbeddingAPIURL,
1✔
1421
                        APIKey:     config.EmbeddingAPIKey,
1✔
1422
                        Model:      config.EmbeddingModel,
1✔
1423
                        Dimensions: config.EmbeddingDimensions,
1✔
1424
                        ModelsDir:  config.ModelsDir,
1✔
1425
                        Timeout:    30 * time.Second,
1✔
1426
                }
1✔
1427

1✔
1428
                // Set API path based on provider (only for remote providers)
1✔
1429
                switch config.EmbeddingProvider {
1✔
1430
                case "ollama":
1✔
1431
                        embedConfig.APIPath = "/api/embeddings"
1✔
1432
                case "openai":
1✔
1433
                        embedConfig.APIPath = "/v1/embeddings"
1✔
1434
                case "local":
1✔
1435
                        // Local provider doesn't need API path
1436
                default:
×
1437
                        // Default to Ollama format
×
1438
                        embedConfig.APIPath = "/api/embeddings"
×
1439
                }
1440

1441
                // Initialize embeddings asynchronously to prevent startup blocking
1442
                // Local GGUF models can take 5-30 seconds to load (graph compilation)
1443
                embedInitLog := s.log.With("subsystem", "embed_init")
1✔
1444
                embedInitLog.Info("loading embedding model",
1✔
1445
                        "model", embedConfig.Model,
1✔
1446
                        "provider", embedConfig.Provider,
1✔
1447
                        "note", "server starts immediately; embeddings available after model loads")
1✔
1448

1✔
1449
                go func() {
2✔
1450
                        // Retry forever: exponential backoff to 5m, then fixed 5m interval.
1✔
1451
                        const (
1✔
1452
                                initialBackoff = 2 * time.Second
1✔
1453
                                maxBackoff     = 5 * time.Minute
1✔
1454
                        )
1✔
1455

1✔
1456
                        backoff := initialBackoff
1✔
1457
                        attempt := 0
1✔
1458

1✔
1459
                        for {
2✔
1460
                                if s.closed.Load() {
1✔
1461
                                        embedInitLog.Info("embedding init retry loop stopped: server shutting down")
×
1462
                                        return
×
1463
                                }
×
1464

1465
                                attempt++
1✔
1466

1✔
1467
                                // Use factory function for all providers.
1✔
1468
                                embedder, err := embed.NewEmbedder(embedConfig)
1✔
1469
                                if err == nil {
2✔
1470
                                        // Health check: test embedding before enabling.
1✔
1471
                                        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1✔
1472
                                        _, healthErr := embedder.Embed(ctx, "health check")
1✔
1473
                                        cancel()
1✔
1474
                                        if healthErr != nil {
2✔
1475
                                                err = fmt.Errorf("health check failed: %w", healthErr)
1✔
1476
                                        }
1✔
1477
                                }
1478

1479
                                if err == nil {
2✔
1480
                                        // Wrap with caching if enabled (default: 10K cache).
1✔
1481
                                        if config.EmbeddingCacheSize > 0 {
2✔
1482
                                                embedder = embed.NewCachedEmbedder(embedder, config.EmbeddingCacheSize)
1✔
1483
                                                embedInitLog.Info("embedding cache enabled",
1✔
1484
                                                        "entries", config.EmbeddingCacheSize,
1✔
1485
                                                        "memory_mb", embeddingCacheMemoryMB(config.EmbeddingCacheSize, config.EmbeddingDimensions))
1✔
1486
                                        }
1✔
1487

1488
                                        if config.EmbeddingProvider == "local" {
1✔
1489
                                                embedInitLog.Info("embeddings ready",
×
1490
                                                        "provider", "local_gguf",
×
1491
                                                        "model", config.EmbeddingModel,
×
1492
                                                        "dims", config.EmbeddingDimensions)
×
1493
                                        } else {
1✔
1494
                                                embedInitLog.Info("embeddings ready",
1✔
1495
                                                        "provider", config.EmbeddingProvider,
1✔
1496
                                                        "url", config.EmbeddingAPIURL,
1✔
1497
                                                        "model", config.EmbeddingModel,
1✔
1498
                                                        "dims", config.EmbeddingDimensions)
1✔
1499
                                        }
1✔
1500

1501
                                        if mcpServer != nil {
2✔
1502
                                                mcpServer.SetEmbedder(embedder)
1✔
1503
                                        }
1✔
1504
                                        // Share embedder with DB for auto-embed queue.
1505
                                        // The embed worker will wait for this to be set before processing.
1506
                                        db.SetEmbedder(embedder)
1✔
1507
                                        // Register as default for per-DB embedder registry (no-op if SetEmbedConfigForDB was not set).
1✔
1508
                                        db.SetDefaultEmbedConfig(embedConfig)
1✔
1509
                                        return
1✔
1510
                                }
1511

1512
                                if config.EmbeddingProvider == "local" {
2✔
1513
                                        embedInitLog.Warn("embedding init attempt failed",
1✔
1514
                                                "attempt", attempt,
1✔
1515
                                                "provider", "local",
1✔
1516
                                                "model", config.EmbeddingModel,
1✔
1517
                                                "error", err)
1✔
1518
                                } else {
2✔
1519
                                        embedInitLog.Warn("embedding init attempt failed",
1✔
1520
                                                "attempt", attempt,
1✔
1521
                                                "provider", config.EmbeddingProvider,
1✔
1522
                                                "model", config.EmbeddingModel,
1✔
1523
                                                "url", config.EmbeddingAPIURL,
1✔
1524
                                                "error", err)
1✔
1525
                                }
1✔
1526

1527
                                if backoff < maxBackoff {
2✔
1528
                                        embedInitLog.Info("retrying embedding init (exponential backoff)", "wait", backoff)
1✔
1529
                                        if !waitForDurationOrServerClose(s, backoff) {
2✔
1530
                                                embedInitLog.Info("embedding init retry interrupted by server shutdown")
1✔
1531
                                                return
1✔
1532
                                        }
1✔
1533
                                        backoff *= 2
1✔
1534
                                        if backoff > maxBackoff {
1✔
1535
                                                backoff = maxBackoff
×
1536
                                        }
×
1537
                                } else {
×
1538
                                        embedInitLog.Info("embedding init retry interval capped; continuing periodic retries",
×
1539
                                                "interval", maxBackoff)
×
1540
                                        if !waitForDurationOrServerClose(s, maxBackoff) {
×
1541
                                                embedInitLog.Info("embedding init retry interrupted by server shutdown")
×
1542
                                                return
×
1543
                                        }
×
1544
                                }
1545
                        }
1546
                }()
1547
        }
1548

1549
        // Log authentication status
1550
        if authenticator == nil || !authenticator.IsSecurityEnabled() {
2✔
1551
                s.log.Warn("authentication disabled")
1✔
1552
        }
1✔
1553

1554
        // Initialize rate limiter if enabled
1555
        var rateLimiter *IPRateLimiter
1✔
1556
        if config.RateLimitEnabled {
2✔
1557
                rateLimiter = NewIPRateLimiter(config.RateLimitPerMinute, config.RateLimitPerHour, config.RateLimitBurst)
1✔
1558
                s.log.Info("rate limiting enabled",
1✔
1559
                        "per_minute", config.RateLimitPerMinute,
1✔
1560
                        "per_hour", config.RateLimitPerHour,
1✔
1561
                        "scope", "per_ip")
1✔
1562
        }
1✔
1563
        s.rateLimiter = rateLimiter
1✔
1564

1✔
1565
        // So EmbeddingCount() aggregates across all databases (not just default)
1✔
1566
        s.db.SetAllDatabasesProvider(func() []nornicdb.DatabaseAndStorage {
2✔
1567
                var out []nornicdb.DatabaseAndStorage
1✔
1568
                for _, info := range s.dbManager.ListDatabases() {
2✔
1569
                        if info.Name == "system" {
2✔
1570
                                continue
1✔
1571
                        }
1572
                        isComposite := s.dbManager.IsCompositeDatabase(info.Name)
1✔
1573
                        if isComposite {
1✔
1574
                                continue
×
1575
                        }
1576
                        storageEngine, err := s.dbManager.GetStorage(info.Name)
1✔
1577
                        if err != nil {
1✔
1578
                                continue
×
1579
                        }
1580
                        out = append(out, nornicdb.DatabaseAndStorage{
1✔
1581
                                Name:        info.Name,
1✔
1582
                                Storage:     storageEngine,
1✔
1583
                                IsComposite: isComposite,
1✔
1584
                        })
1✔
1585
                }
1586
                return out
1✔
1587
        })
1588

1589
        // Reconcile search-service startup for metadata-only or late-created databases.
1590
        // DB.Open() warms namespaces present in storage; this loop ensures known DB metadata
1591
        // also gets initialized, and keeps doing so without requiring first-search triggers.
1592
        s.ensureSearchBuildStartedForKnownDatabases()
1✔
1593
        go func() {
2✔
1594
                ticker := time.NewTicker(2 * time.Second)
1✔
1595
                defer ticker.Stop()
1✔
1596
                for {
2✔
1597
                        if s.closed.Load() {
2✔
1598
                                return
1✔
1599
                        }
1✔
1600
                        s.ensureSearchBuildStartedForKnownDatabases()
1✔
1601
                        <-ticker.C
1✔
1602
                }
1603
        }()
1604

1605
        // Wire MCP to use per-database executors when invoked from the agentic loop (so link/store/recall use the request's database)
1606
        if mcpServer != nil && dbManager != nil {
2✔
1607
                mcpServer.SetDatabaseScopedExecutor(s.mcpDatabaseScopedExecutor())
1✔
1608
                mcpServer.SetDatabaseScopedStorage(func(dbName string) (storage.Engine, error) {
1✔
1609
                        return s.dbManager.GetStorage(dbName)
×
1610
                })
×
1611
        }
1612

1613
        // Initialize OAuth manager if authenticator is available
1614
        if authenticator != nil {
2✔
1615
                s.oauthManager = auth.NewOAuthManager(authenticator)
1✔
1616
        }
1✔
1617

1618
        // Per-database access: Full when auth disabled; when auth enabled, DenyAll until allowlist resolves.
1619
        if authenticator == nil || !authenticator.IsSecurityEnabled() {
2✔
1620
                s.databaseAccessMode = auth.FullDatabaseAccessMode
1✔
1621
        } else {
2✔
1622
                s.databaseAccessMode = auth.DenyAllDatabaseAccessMode
1✔
1623
        }
1✔
1624

1625
        // Load RBAC stores from system DB when available so roles/allowlist/privileges/entitlements APIs
1626
        // work even with --no-auth (e.g. fetch roles, configure RBAC before enabling auth).
1627
        if systemStorage, err := dbManager.GetStorage("system"); err == nil {
2✔
1628
                ctx := context.Background()
1✔
1629
                roleStore := auth.NewRoleStore(systemStorage)
1✔
1630
                if loadErr := roleStore.Load(ctx); loadErr != nil {
1✔
1631
                        s.log.Warn("failed to load RBAC roles", "subsystem", "rbac", "error", loadErr)
×
1632
                } else {
1✔
1633
                        s.roleStore = roleStore
1✔
1634
                }
1✔
1635
                allowlistStore := auth.NewAllowlistStore(systemStorage)
1✔
1636
                if loadErr := allowlistStore.Load(ctx); loadErr != nil {
1✔
1637
                        s.log.Warn("failed to load RBAC allowlist", "subsystem", "rbac", "error", loadErr)
×
1638
                } else {
1✔
1639
                        dbList := make([]string, 0, len(dbManager.ListDatabases()))
1✔
1640
                        for _, info := range dbManager.ListDatabases() {
2✔
1641
                                dbList = append(dbList, info.Name)
1✔
1642
                        }
1✔
1643
                        if seedErr := allowlistStore.SeedIfEmpty(ctx, dbList); seedErr != nil {
1✔
1644
                                s.log.Warn("failed to seed RBAC allowlist", "subsystem", "rbac", "error", seedErr)
×
1645
                        }
×
1646
                        s.allowlistStore = allowlistStore
1✔
1647
                }
1648
                privilegesStore := auth.NewPrivilegesStore(systemStorage)
1✔
1649
                if loadErr := privilegesStore.Load(ctx); loadErr != nil {
1✔
1650
                        s.log.Warn("failed to load RBAC privileges", "subsystem", "rbac", "error", loadErr)
×
1651
                } else {
1✔
1652
                        s.privilegesStore = privilegesStore
1✔
1653
                }
1✔
1654
                roleEntitlementsStore := auth.NewRoleEntitlementsStore(systemStorage)
1✔
1655
                if loadErr := roleEntitlementsStore.Load(ctx); loadErr != nil {
1✔
1656
                        s.log.Warn("failed to load RBAC role entitlements", "subsystem", "rbac", "error", loadErr)
×
1657
                } else {
1✔
1658
                        s.roleEntitlementsStore = roleEntitlementsStore
1✔
1659
                }
1✔
1660
                dbConfigStore := dbconfig.NewStore(systemStorage)
1✔
1661
                // Seed yaml-declared per-DB overrides on first boot. After the
1✔
1662
                // first successful seed, admin-API edits are authoritative across
1✔
1663
                // restarts (LoadWithYAMLDefaults skips keys already in the store).
1✔
1664
                // Falls back to plain Load when no yaml overrides are present.
1✔
1665
                yamlOverrides := s.perDBYAMLOverrides
1✔
1666
                if loadErr := dbConfigStore.LoadWithYAMLDefaults(ctx, yamlOverrides); loadErr != nil {
1✔
1667
                        s.log.Warn("failed to load per-DB config store", "subsystem", "dbconfig", "error", loadErr)
×
1668
                } else {
1✔
1669
                        s.dbConfigStore = dbConfigStore
1✔
1670
                        globalConfig := nornicConfig.LoadFromEnv()
1✔
1671
                        db.SetDbConfigResolver(func(dbName string) (int, float64, string) {
2✔
1672
                                overrides := dbConfigStore.GetOverrides(dbName)
1✔
1673
                                r := dbconfig.Resolve(globalConfig, overrides)
1✔
1674
                                if r == nil {
1✔
1675
                                        return 0, 0, ""
×
1676
                                }
×
1677
                                return r.EmbeddingDimensions, r.SearchMinSimilarity, r.BM25Engine
1✔
1678
                        })
1679
                        db.SetDbSearchFlagsResolver(func(dbName string) (bool, bool, string, string) {
2✔
1680
                                overrides := dbConfigStore.GetOverrides(dbName)
1✔
1681
                                r := dbconfig.Resolve(globalConfig, overrides)
1✔
1682
                                if r == nil {
1✔
1683
                                        return true, true, "startup", "startup"
×
1684
                                }
×
1685
                                return r.BM25Enabled, r.VectorEnabled, r.BM25Warming, r.VectorWarming
1✔
1686
                        })
1687
                        // Per-DB embedder registry: resolve embed config per database for EmbedQueryForDB.
1688
                        db.SetEmbedConfigForDB(func(dbName string) (*embed.Config, error) {
2✔
1689
                                overrides := dbConfigStore.GetOverrides(dbName)
1✔
1690
                                r := dbconfig.Resolve(globalConfig, overrides)
1✔
1691
                                if r == nil || r.Effective == nil {
1✔
1692
                                        return nil, nil
×
1693
                                }
×
1694
                                return buildEmbedConfigFromResolved(r.Effective, config), nil
1✔
1695
                        })
1696
                }
1697
        }
1698
        // Release the search-index warmup gate. nornicdb.Open holds the
1699
        // warmup goroutine at a sync point until this is called so it can
1700
        // resolve per-DB flags through the resolver wired above instead of
1701
        // racing with it. Default DB and named DBs go through the same
1702
        // resolution path; per-DB overrides (YAML databases: block, admin
1703
        // API store) apply uniformly. If wiring above failed (e.g. system
1704
        // storage unavailable) the gate is still released — warmup falls
1705
        // through to db.config.Memory.Search* + the (true,true,startup)
1706
        // final fallback, which matches today's behaviour for misconfigured
1707
        // systems.
1708
        db.MarkSearchWarmupReady()
1✔
1709

1✔
1710
        // Initialize slow query logger if file specified.
1✔
1711
        // D-04d collapse: threshold + log file path read from the canonical
1✔
1712
        // pkg/config.LoggingConfig snapshot threaded via Config.Logging.
1✔
1713
        if config.SlowQueryEnabled && config.Logging.SlowQueryLogFile != "" {
2✔
1714
                file, err := os.OpenFile(config.Logging.SlowQueryLogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1✔
1715
                if err != nil {
2✔
1716
                        s.log.Warn("failed to open slow query log file",
1✔
1717
                                "subsystem", "slow_query",
1✔
1718
                                "file", config.Logging.SlowQueryLogFile,
1✔
1719
                                "error", err)
1✔
1720
                } else {
2✔
1721
                        s.slowQueryLogger = log.New(file, "", log.LstdFlags)
1✔
1722
                        s.log.Info("slow query logging configured",
1✔
1723
                                "subsystem", "slow_query",
1✔
1724
                                "file", config.Logging.SlowQueryLogFile,
1✔
1725
                                "threshold", config.Logging.SlowQueryThreshold)
1✔
1726
                }
1✔
1727
        } else if config.SlowQueryEnabled {
1✔
1728
                s.log.Info("slow query logging enabled",
×
1729
                        "subsystem", "slow_query",
×
1730
                        "threshold", config.Logging.SlowQueryThreshold)
×
1731
        }
×
1732

1733
        return s, nil
1✔
1734
}
1735

1736
// SetHTTPMetrics injects the Plan-04-02 HTTP catalog bag (D-02 typed
1737
// handle DI). MUST be called BEFORE Start() — once the http.Server's
1738
// Handler is wired in Start(), the wrapper is fixed for the server
1739
// lifetime. Callers (cmd/nornicdb/main.go) inject after observability.New
1740
// returns the registry, then call Start().
1741
//
1742
// Nil-safe: passing nil is equivalent to never calling — instrumentedMux
1743
// is a pass-through. Test fixtures and pre-Phase-4 callers compile and
1744
// run unchanged.
1745
func (s *Server) SetHTTPMetrics(m *observability.HTTPMetrics) {
1✔
1746
        s.mu.Lock()
1✔
1747
        defer s.mu.Unlock()
1✔
1748
        s.httpMetrics = m
1✔
1749
}
1✔
1750

1751
// SetObsRegistry plumbs the unified prometheus registry from
1752
// observability.New into the server so handleMetrics can call
1753
// observability.RenderLegacy. Phase 5 / Plan 05-04. Mirrors the
1754
// SetHTTPMetrics pattern (mu.Lock + assign + unlock).
1755
//
1756
// Nil-safe: passing nil is equivalent to never calling — handleMetrics
1757
// tolerates a nil registry by emitting empty body bytes (RenderLegacy
1758
// contract). Test fixtures and pre-Phase-5 callers compile and run
1759
// unchanged.
1760
func (s *Server) SetObsRegistry(reg *prometheus.Registry) {
1✔
1761
        s.mu.Lock()
1✔
1762
        defer s.mu.Unlock()
1✔
1763
        s.obsRegistry = reg
1✔
1764
}
1✔
1765

1766
// SetAuditLogger sets the audit logger for compliance logging.
1767
func (s *Server) SetAuditLogger(logger *audit.Logger) {
1✔
1768
        s.mu.Lock()
1✔
1769
        defer s.mu.Unlock()
1✔
1770
        s.audit = logger
1✔
1771
        if s.db != nil {
2✔
1772
                s.db.SetRetentionAuditCallback(func(action, recordID, category string) {
1✔
1773
                        if s.audit == nil {
×
1774
                                return
×
1775
                        }
×
1776
                        _ = s.audit.LogDataAccess("system", "retention-manager", "node", recordID, action, true, category)
×
1777
                })
1778
        }
1779
}
1780

1781
func (s *Server) setHeimdallHandler(handler *heimdall.Handler) {
1✔
1782
        s.mu.Lock()
1✔
1783
        s.heimdallHandler = handler
1✔
1784
        s.mu.Unlock()
1✔
1785
}
1✔
1786

1787
func (s *Server) getHeimdallHandler() *heimdall.Handler {
1✔
1788
        s.mu.RLock()
1✔
1789
        defer s.mu.RUnlock()
1✔
1790
        return s.heimdallHandler
1✔
1791
}
1✔
1792

1793
// Start begins listening for HTTP connections on the configured address and port.
1794
//
1795
// The server starts in a separate goroutine, so this method returns immediately
1796
// after successfully binding to the port. Use Addr() to get the actual listening
1797
// address after starting.
1798
//
1799
// Returns:
1800
//   - nil if server started successfully
1801
//   - Error if failed to bind to port or server is already closed
1802
//
1803
// Example:
1804
//
1805
//        server := server.New(db, auth, config)
1806
//
1807
//        if err := server.Start(); err != nil {
1808
//                log.Fatalf("Failed to start server: %v", err)
1809
//        }
1810
//
1811
//        // Server started on server.Addr()
1812
//
1813
//        // Server is now accepting connections
1814
//        // Keep main goroutine alive
1815
//        select {}
1816
//
1817
// TLS Support:
1818
//
1819
//        If TLSCertFile and TLSKeyFile are configured, the server automatically
1820
//        starts with HTTPS. Otherwise, it uses HTTP.
1821
func (s *Server) Start() error {
1✔
1822
        if s.closed.Load() {
2✔
1823
                return ErrServerClosed
1✔
1824
        }
1✔
1825

1826
        addr := fmt.Sprintf("%s:%d", s.config.Address, s.config.Port)
1✔
1827
        listener, err := net.Listen("tcp", addr)
1✔
1828
        if err != nil {
1✔
1829
                return fmt.Errorf("failed to listen on %s: %w", addr, err)
×
1830
        }
×
1831

1832
        s.listener = listener
1✔
1833
        s.started = time.Now()
1✔
1834

1✔
1835
        // Build router
1✔
1836
        mux := s.buildRouter()
1✔
1837

1✔
1838
        // Plan 04-02 D-03: instrumentedMux is the SOLE HTTP observation
1✔
1839
        // chokepoint per AGENTS.md §7 DRY. It wraps mux.ServeHTTP, reading
1✔
1840
        // `r.Pattern` post-dispatch (Go 1.22+ stdlib field) so path_template
1✔
1841
        // values come from the closed route table — never from r.URL.Path
1✔
1842
        // (cardinality bomb). Nil-safe: when s.httpMetrics is nil (test
1✔
1843
        // fixtures, pre-Phase-4 callers), the wrapper is a pass-through.
1✔
1844
        // Panic-safe: handler panics still emit a 5xx observation before
1✔
1845
        // re-propagating (T-04-08).
1✔
1846
        instrumented := instrumentedMux(mux, s.httpMetrics)
1✔
1847

1✔
1848
        s.requestsCtx, s.cancelRequestsCtx = context.WithCancel(context.Background())
1✔
1849
        s.httpServer = &http.Server{
1✔
1850
                Handler:      instrumented,
1✔
1851
                ReadTimeout:  s.config.ReadTimeout,
1✔
1852
                WriteTimeout: s.config.WriteTimeout,
1✔
1853
                IdleTimeout:  s.config.IdleTimeout,
1✔
1854
                // BaseContext links every request's r.Context() to the server's
1✔
1855
                // shutdown signal. When Stop() cancels requestsCtx, all in-flight
1✔
1856
                // handlers see ctx.Err() != nil on their next cancellation probe and
1✔
1857
                // unwind, so http.Server.Shutdown returns instead of waiting on a
1✔
1858
                // long-running BFS.
1✔
1859
                BaseContext: func(net.Listener) context.Context { return s.requestsCtx },
2✔
1860
        }
1861

1862
        // Configure HTTP/2 (always enabled, backwards compatible with HTTP/1.1)
1863
        http2Config := &http2.Server{
1✔
1864
                MaxConcurrentStreams: s.config.HTTP2MaxConcurrentStreams,
1✔
1865
        }
1✔
1866

1✔
1867
        if s.config.TLSCertFile != "" && s.config.TLSKeyFile != "" {
1✔
1868
                // HTTPS mode: HTTP/2 is automatically enabled via ALPN
×
1869
                // Configure HTTP/2 settings for TLS connections
×
1870
                if err := http2.ConfigureServer(s.httpServer, http2Config); err != nil {
×
1871
                        return fmt.Errorf("failed to configure HTTP/2 for TLS: %w", err)
×
1872
                }
×
1873
                s.log.Info("HTTP/2 enabled", "mode", "https")
×
1874
        } else {
1✔
1875
                // HTTP mode: Use h2c (HTTP/2 cleartext) for backwards compatibility
1✔
1876
                // h2c allows HTTP/2 over plain TCP, falling back to HTTP/1.1 for older clients
1✔
1877
                // Wrap the INSTRUMENTED mux (not bare mux) so observation runs
1✔
1878
                // inside the h2c transport adapter (Plan 04-02 D-03).
1✔
1879
                s.httpServer.Handler = h2c.NewHandler(instrumented, http2Config)
1✔
1880
                s.log.Info("HTTP/2 enabled", "mode", "h2c_cleartext", "compat", "http/1.1")
1✔
1881
        }
1✔
1882

1883
        // Start serving
1884
        go func() {
2✔
1885
                var err error
1✔
1886
                if s.config.TLSCertFile != "" && s.config.TLSKeyFile != "" {
1✔
1887
                        err = s.httpServer.ServeTLS(listener, s.config.TLSCertFile, s.config.TLSKeyFile)
×
1888
                } else {
1✔
1889
                        err = s.httpServer.Serve(listener)
1✔
1890
                }
1✔
1891
                if err != nil && err != http.ErrServerClosed {
1✔
1892
                        // Log error but don't crash
×
1893
                        s.log.Error("http server error", "error", err)
×
1894
                }
×
1895
        }()
1896

1897
        // Optional gRPC endpoints (feature-flagged).
1898
        if err := s.startQdrantGRPC(); err != nil {
2✔
1899
                _ = s.httpServer.Shutdown(context.Background())
1✔
1900
                return err
1✔
1901
        }
1✔
1902

1903
        return nil
1✔
1904
}
1905

1906
// Stop gracefully shuts down the server.
1907
func (s *Server) Stop(ctx context.Context) error {
1✔
1908
        if !s.closed.CompareAndSwap(false, true) {
2✔
1909
                return nil // Already closed
1✔
1910
        }
1✔
1911

1912
        s.stopQdrantGRPC()
1✔
1913

1✔
1914
        // Stop rate limiter cleanup goroutine
1✔
1915
        if s.rateLimiter != nil {
2✔
1916
                s.rateLimiter.Stop()
1✔
1917
        }
1✔
1918

1919
        if s.httpServer == nil {
2✔
1920
                return nil
1✔
1921
        }
1✔
1922

1923
        // Cancel the BaseContext handed to all in-flight requests so handlers that
1924
        // honour ctx (Cypher BFS / shortestPath traversals, etc.) abandon work
1925
        // and let http.Server.Shutdown drain promptly. Without this, an unbounded
1926
        // shortestPath traversal could hold shutdown open for the duration of the
1927
        // configured WriteTimeout.
1928
        if s.cancelRequestsCtx != nil {
2✔
1929
                s.cancelRequestsCtx()
1✔
1930
        }
1✔
1931

1932
        // Hard-bound shutdown: even if net/http Shutdown fails to return at ctx deadline
1933
        // (e.g., a stuck handler or an internal deadlock), Stop must return so callers
1934
        // can exit deterministically.
1935
        shutdownDone := make(chan error, 1)
1✔
1936
        go func() {
2✔
1937
                shutdownDone <- s.httpServer.Shutdown(ctx)
1✔
1938
        }()
1✔
1939

1940
        select {
1✔
1941
        case err := <-shutdownDone:
1✔
1942
                if err != nil && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) {
1✔
1943
                        _ = s.httpServer.Close()
×
1944
                }
×
1945
                return err
1✔
1946
        case <-ctx.Done():
1✔
1947
                _ = s.httpServer.Close()
1✔
1948
                return ctx.Err()
1✔
1949
        }
1950
}
1951

1952
// Addr returns the server's listen address.
1953
func (s *Server) Addr() string {
1✔
1954
        if s.listener != nil {
2✔
1955
                return s.listener.Addr().String()
1✔
1956
        }
1✔
1957
        return ""
1✔
1958
}
1959

1960
// Stats returns current server runtime statistics.
1961
//
1962
// Statistics are updated in real-time by middleware and include:
1963
//   - Uptime since server start
1964
//   - Total request count
1965
//   - Total error count
1966
//   - Currently active requests
1967
//
1968
// Example:
1969
//
1970
//        stats := server.Stats()
1971
//        // stats.Uptime: server uptime
1972
//        // stats.RequestCount: total requests
1973
//        // stats.ErrorCount / stats.RequestCount: error rate
1974
//        // stats.ActiveRequests: in-flight requests
1975
//
1976
//        // Use for monitoring/alerting
1977
//        if stats.ErrorCount > 1000 {
1978
//                alert("High error count detected")
1979
//        }
1980
//
1981
// Thread-safe: Can be called concurrently from multiple goroutines.
1982
func (s *Server) Stats() ServerStats {
1✔
1983
        return ServerStats{
1✔
1984
                Uptime:         time.Since(s.started),
1✔
1985
                RequestCount:   s.requestCount.Load(),
1✔
1986
                ErrorCount:     s.errorCount.Load(),
1✔
1987
                ActiveRequests: s.activeRequests.Load(),
1✔
1988
                Version:        buildinfo.Version(),
1✔
1989
                Commit:         buildinfo.ShortCommit(),
1✔
1990
                BuildTime:      buildinfo.BuildTime,
1✔
1991
        }
1✔
1992
}
1✔
1993

1994
// ServerStats holds server metrics.
1995
type ServerStats struct {
1996
        Uptime         time.Duration `json:"uptime"`
1997
        RequestCount   int64         `json:"request_count"`
1998
        ErrorCount     int64         `json:"error_count"`
1999
        ActiveRequests int64         `json:"active_requests"`
2000
        Version        string        `json:"version"`
2001
        Commit         string        `json:"commit"`
2002
        BuildTime      string        `json:"build_time"`
2003
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc