package main import ( "errors" "net/url" "os" "os/signal" "strings" "syscall" "time" "doormile/config" "doormile/controllers" "doormile/db" "doormile/internal/ai/playground" "doormile/internal/ai/telemetry" "doormile/internal/assignment" "doormile/internal/notify" "doormile/internal/routing" "doormile/internal/sms" "doormile/internal/worker" "doormile/middlewares" "doormile/migrations" "doormile/routes" "doormile/utils" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/limiter" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/joho/godotenv" ) // errorHandler converts anything a handler returns — including a panic already // turned into an error by the recover middleware — into the same // {success, message} envelope the utils helpers emit, so clients never receive // Fiber's default plain-text error body. // isLoopbackOrigin reports whether an Origin header names this machine, on any // port. It exists for Flutter Web, whose dev server picks a fresh random port // on every launch — no fixed allowlist can name it in advance. // // Deliberately strict about what counts as loopback: the host must be exactly // localhost, 127.0.0.1 or [::1]. A prefix match would admit // http://localhost.attacker.com, which is a different machine entirely and is // precisely the mistake this kind of check usually makes. func isLoopbackOrigin(origin string) bool { u, err := url.Parse(origin) if err != nil { return false } if u.Scheme != "http" && u.Scheme != "https" { return false } switch u.Hostname() { case "localhost", "127.0.0.1", "::1": return true default: return false } } func errorHandler(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError msg := "internal server error" var fe *fiber.Error if errors.As(err, &fe) { code = fe.Code msg = fe.Message } // 5xx means we broke, not the caller — log it with the route for triage. if code >= fiber.StatusInternalServerError { utils.Error("request failed", "method", c.Method(), "path", c.Path(), "status", code, "error", err.Error(), ) } return c.Status(code).JSON(fiber.Map{"success": false, "message": msg}) } func main() { // 1. Load configuration env variables _ = godotenv.Load() cfg := config.Load() // Refuse to boot production on a committed fallback secret. Checked before // anything connects, so a misconfigured deploy fails loudly at start rather // than serving traffic with a JWT secret that is in the git history. if missing := cfg.MissingProductionSecrets(); len(missing) > 0 { utils.Error("Refusing to start: required secrets are not set in production", "missing", strings.Join(missing, ",")) os.Exit(1) } utils.Info("Starting Doormile Backend...") // 2. Connect to Postgres, Redis & NATS db.Connect(cfg) db.InitRedis(cfg) db.InitNATS(cfg) notify.InitFCM() // Install the SMS gateway. Until this call existed, sms.Register had no // callers anywhere in the tree, so every customer verification code was // written to this log and no text was ever sent — the single blocker on // customer sign-in, and the reason bookings were being made in the app's // offline mode and never reaching the console. sms.Configure() // 3. Run GORM migrations for logistics tables if db.DB != nil { err := migrations.Migrate(db.DB) if err != nil { utils.Error("⚠️ Migration failed, continuing server boot...", "error", err.Error()) } } // 4. Initialize Fiber App fiberCfg := fiber.Config{ AppName: "Doormile Logistics Service API v1", ErrorHandler: errorHandler, } // Rate limiting keys on c.IP(). Behind a TLS-terminating reverse proxy the // socket peer is the proxy, so without this every caller shares a single // limit bucket and the whole fleet gets throttled together. Only honour // X-Forwarded-For from proxies we explicitly trust — otherwise any client // could spoof the header to dodge the limit entirely. if cfg.TrustedProxies != "" { proxies := strings.Split(cfg.TrustedProxies, ",") for i := range proxies { proxies[i] = strings.TrimSpace(proxies[i]) } fiberCfg.EnableTrustedProxyCheck = true fiberCfg.TrustedProxies = proxies fiberCfg.ProxyHeader = fiber.HeaderXForwardedFor utils.Info("Trusting X-Forwarded-For from configured proxies", "proxies", proxies) } else { utils.Warn("TRUSTED_PROXIES is unset — rate limits key on the socket peer address. " + "If this service runs behind a reverse proxy, set TRUSTED_PROXIES or all clients will share one limit bucket.") } app := fiber.New(fiberCfg) // Panic recovery — must be the outermost middleware so it also catches // panics raised inside the ones registered below. Without this a single // nil-pointer dereference in any handler takes the whole process down. app.Use(recover.New(recover.Config{EnableStackTrace: true})) // CORS policy. // // The named list is production and the well-known dev-server ports. It cannot // cover local development on its own: `flutter run -d chrome` binds a RANDOM // high port on every launch (65256 one run, something else the next), so a // fixed allowlist misses it every time and the browser rejects the request // with PreflightMissingAllowOriginHeader before the handler is ever reached. // // AllowOriginsFunc is consulted only when the static list has already missed, // so it widens nothing in production — it just admits loopback origins on any // port while developing. It is NOT enabled when ENV=production: a live API // that accepts credentialed requests from any localhost page is a real, if // modest, hole — a developer visiting a hostile page served from their own // machine would have that page able to call this API as them. // // A wildcard is not an option regardless: AllowCredentials with // AllowOrigins "*" is rejected by the CORS spec, and Fiber panics on it. allowLoopbackOrigins := !strings.EqualFold(cfg.Env, "production") if allowLoopbackOrigins { utils.Info("CORS: loopback origins on any port are allowed (non-production)", "env", cfg.Env) } app.Use(cors.New(cors.Config{ // Idempotency-Key is sent by the rider app on pickup-complete, payment // and the base handover. A browser client that could not send it would // lose retry safety on exactly the calls that most need it. AllowHeaders: "Origin,Content-Type,Accept,Authorization,Idempotency-Key", AllowOrigins: "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:8080,http://localhost:8081,https://doormile.com,https://www.doormile.com,https://admin.doormile.com,https://api.doormile.com,https://crm.doormile.com,https://console.doormile.com,https://app.doormile.com,https://hub.doormile.com", AllowOriginsFunc: func(origin string) bool { return allowLoopbackOrigins && isLoopbackOrigin(origin) }, AllowCredentials: true, AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS", })) // Echo X-Request-Id on every response, errors included, minting one when the // caller did not send it. The customer app funnels every failure into a // single retryable error state, so without this a support conversation // about "it failed" has nothing to correlate against the logs. Registered // before the logger so the id is available to it. app.Use(middlewares.RequestID()) // Structured Zap logger middleware app.Use(middlewares.ZapLogger()) // Global per-IP rate limit. Deliberately generous — this is an abuse // backstop, not a quota. Auth endpoints get a much tighter limit of their // own in routes.go. Health probes and websocket upgrades are exempt so // orchestrator checks and long-lived tracking sockets are never throttled. app.Use(limiter.New(limiter.Config{ Max: 300, Expiration: 1 * time.Minute, Next: func(c *fiber.Ctx) bool { p := c.Path() return strings.HasSuffix(p, "/health") || strings.HasSuffix(p, "/ready") || strings.HasPrefix(p, "/ws/") }, LimitReached: func(c *fiber.Ctx) error { return c.Status(fiber.StatusTooManyRequests). JSON(fiber.Map{"success": false, "message": "too many requests, please slow down"}) }, })) // 5. Register routes routes.RegisterRoutes(app, cfg) // 6. Pre-warm Redis pricing cache from Postgres controllers.WarmPricingCache() // 7. Start NATS booking worker in background go worker.StartBookingWorker() // 8. Start the assignment worker. Every replica runs one; they share a // durable consumer, so JetStream hands each booking to exactly one of them. go assignment.StartAssignmentWorker() // 9. Point the stop sequencer at the Route Optimization API. routing.BaseURL = cfg.RouteOptimizerURL // 10. Record AI_engine agent runs (telemetry.task) and live state // (telemetry.agent). Observability only: a nil NATS connection logs and // skips, and nothing here can block a request. if db.DB != nil { telemetry.NewRecorder(db.DB, db.Rdb).Start(db.Nc) } // Agent Studio Test playground: switched on only when a model API key is // configured. Without one the endpoint answers 503 and the console says so. if cfg.PlaygroundLLMAPIKey != "" { controllers.PlaygroundModel = playground.NewOpenAICompat(cfg.PlaygroundLLMBaseURL, cfg.PlaygroundLLMAPIKey, cfg.PlaygroundLLMModel) utils.Info("ai playground: enabled", "base_url", cfg.PlaygroundLLMBaseURL, "model", cfg.PlaygroundLLMModel) } // 7. Startup server in a background thread go func() { utils.Info("Server starting", "port", cfg.Port) if err := app.Listen(":" + cfg.Port); err != nil { utils.Logger.Fatalf("Server failed to bind: %v", err) } }() // Graceful shutdown listener gracefulShutdown(app) } func gracefulShutdown(app *fiber.App) { c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) <-c utils.Info("Shutting down Doormile Backend...") // Shutdown fiber if err := app.Shutdown(); err != nil { utils.Error("Fiber shutdown failed", "error", err) } // Close database pools db.CloseDB() time.Sleep(1 * time.Second) utils.Info("Shutdown completed cleanly.") }