// Package httpapi implements the Oikos REST API. The contract is // api/openapi.yaml (contract-first, ADR-0004); handlers implement the // oapi-codegen strict-server interface in gen/. Errors map to RFC 9457 // problem+json via domain sentinels. package httpapi import ( "context" "crypto/subtle" "log/slog" "net/http" "strings" "time" "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // Server implements gen.StrictServerInterface over the DB layer. type Server struct { pool *db.Pool cfg config.Config } // NewHandler builds the full HTTP handler: /healthz (unauthenticated, // SG18) + the OpenAPI surface under /api/v1 behind bearer auth. func NewHandler(pool *db.Pool, cfg config.Config) http.Handler { s := &Server{pool: pool, cfg: cfg} r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.RequestID) r.Use(requestLogger) // Liveness — no auth, no audit (plan SG18). Not exposed via Caddy. r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second) defer cancel() if err := pool.Ping(ctx); err != nil { writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "") return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) }) strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) }, ResponseErrorHandlerFunc: writeProblemFromErr, }) gen.HandlerWithOptions(strict, gen.ChiServerOptions{ BaseURL: "/api/v1", BaseRouter: r, Middlewares: []gen.MiddlewareFunc{bearerAuth(cfg)}, ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) }, }) return r } // bearerAuth is the interim Phase 2 auth: a static bearer token // (OIKOS_API_TOKEN / OIKOS_MCP_BEARER_TOKEN from Infisical in prod). In // dev mode with no token configured, requests pass as the operator. // Authentik OIDC JWT validation (operator/viewer scopes) lands later in // Phase 2 — tracked in the plan's AuthN/AuthZ table. func bearerAuth(cfg config.Config) func(http.Handler) http.Handler { tokens := [][]byte{} if cfg.APIToken != "" { tokens = append(tokens, []byte(cfg.APIToken)) } if cfg.MCPBearerToken != "" { tokens = append(tokens, []byte(cfg.MCPBearerToken)) } devOpen := cfg.APIEnv == "dev" && len(tokens) == 0 return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if devOpen { next.ServeHTTP(w, r) return } auth := r.Header.Get("Authorization") raw, ok := strings.CutPrefix(auth, "Bearer ") if ok { for _, t := range tokens { if subtle.ConstantTimeCompare([]byte(raw), t) == 1 { next.ServeHTTP(w, r) return } } } writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token") }) } } // requestLogger logs one line per request with method, path, status, // duration, and the chi request id. func requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(ww, r) slog.Info("http", "method", r.Method, "path", r.URL.Path, "status", ww.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", middleware.GetReqID(r.Context()), ) }) } // ListenAndServe runs the API server with graceful shutdown on ctx cancel // (SG4): stop accepting, drain in-flight for up to 30s, then exit. func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error { srv := &http.Server{ Addr: cfg.APIListen, Handler: NewHandler(pool, cfg), ReadHeaderTimeout: 10 * time.Second, } errCh := make(chan error, 1) go func() { slog.Info("api listening", "addr", cfg.APIListen) errCh <- srv.ListenAndServe() }() select { case err := <-errCh: return err case <-ctx.Done(): slog.Info("api shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) } }