// Command api is the Krow HTTP API. // // Configuration, a verified PostgreSQL pool, /health, the sign-in endpoints, // and the entity and current-user endpoints described in docs/api-contract.md. // // Every request outside the public allowlist carries a session cookie that this // process resolves to a real user. The development organization that used to be // injected into every request is gone: identity now comes from the sessions // table, and a database with no users is a database nobody can sign in to, // which is the correct behaviour rather than a gap. package main import ( "context" "log/slog" "os" "os/signal" "syscall" "time" "github.com/krow/krow-backend/go-api/internal/auth" "github.com/krow/krow-backend/go-api/internal/config" "github.com/krow/krow-backend/go-api/internal/db" "github.com/krow/krow-backend/go-api/internal/httpserver" ) // version is stamped at link time: // // go build -ldflags="-X main.version=$(git rev-parse --short HEAD)" // // The Dockerfile passes its VERSION build arg through to this. "dev" is what an // unstamped local build reports, which is honest — it says the binary was not // built by the release path rather than inventing a number. var version = "dev" func main() { if err := run(); err != nil { slog.Error("fatal", "error", err) os.Exit(1) } } func run() error { cfg, err := config.Load() if err != nil { return err } log := newLogger(cfg.Log.Level) log.Info("starting krow-api", "version", version, "env", cfg.AppEnv, "database", cfg.DB.Redacted()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() database, err := db.Open(ctx, cfg.DB) if err != nil { return err } defer database.Close() log.Info("database connected", "schema", cfg.DB.Schema) server, err := httpserver.New(cfg, database, log, httpserver.WithBuildVersion(version)) if err != nil { return err } // The sweepers' context is cancelled by the same signal that stops the // server, so the tickers go away with the process rather than outliving // the pool they query. go sweepSessions(ctx, server.Sessions(), log) // OAuth codes and tokens, and the rate-limit counters. Returns immediately // when the deployment does not serve MCP, so this line costs an unconfigured // deployment one nil check at startup and nothing after. go httpserver.SweepMaintenance(ctx, server.Maintenance(), log) errCh := make(chan error, 1) go func() { errCh <- server.Start() }() log.Info("listening", "addr", server.Addr(), "endpoints", server.Endpoints(), "health", "http://"+server.Addr()+"/health", "cors_origins", cfg.HTTP.CORSOrigins) select { case err := <-errCh: return err case <-ctx.Done(): log.Info("shutdown signal received, draining") // context.Background: ctx is already cancelled, and Shutdown needs a // live deadline of its own to drain in-flight requests. return server.Shutdown(context.Background()) } } // sweepInterval is how often dead sessions are collected. // // Sweeping is housekeeping, not correctness: Manager.Authenticate already // refuses an expired session and deletes the row as it finds it, so a session // is never usable between its expiry and the next sweep. This only collects the // rows nobody comes back for. Fifteen minutes keeps the table from growing // without putting a DELETE on any hot path. const sweepInterval = 15 * time.Minute // sweepSessions deletes expired sessions until the context is cancelled. // // It runs once immediately so a process that has been down for a while does not // carry a backlog for a further fifteen minutes, then on the ticker. A failed // sweep is logged and retried at the next tick: the table being briefly larger // than it should be is not worth stopping the API for. func sweepSessions(ctx context.Context, sessions *auth.Manager, log *slog.Logger) { ticker := time.NewTicker(sweepInterval) defer ticker.Stop() sweep := func() { // A deadline of its own, so a slow or wedged DELETE cannot leave this // goroutine blocked past shutdown. sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() n, err := sessions.Sweep(sweepCtx) switch { case err != nil && ctx.Err() != nil: // Shutting down; the cancellation is expected, not a failure. case err != nil: log.Warn("session sweep failed", "error", err) case n > 0: log.Info("swept expired sessions", "deleted", n) default: log.Debug("session sweep found nothing to delete") } } sweep() for { select { case <-ctx.Done(): log.Debug("session sweeper stopped") return case <-ticker.C: sweep() } } } func newLogger(level string) *slog.Logger { var lvl slog.Level if err := lvl.UnmarshalText([]byte(level)); err != nil { lvl = slog.LevelInfo } return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) }