package httpapi import ( "context" "errors" "log/slog" "net/http" "time" "textmachine/platform/internal/auth" "textmachine/platform/internal/reqid" ) // APIPrefix is the contract's base path. const APIPrefix = "/v0" // readyProbeTimeout bounds the readiness ping. The endpoint is unauthenticated, and without its own // deadline a stuck database turns every probe into a held connection (PD-14). const readyProbeTimeout = 2 * time.Second // LoginSurface is the sign-in flow, as this package needs to see it. type LoginSurface interface { Routes(guard func(http.Handler) http.Handler) http.Handler } // Prober is what readiness needs from the database: not "is it reachable" but "is it the database // this build was made for". Reachability alone reported ready against a Postgres with no schema. type Prober interface { Ready(ctx context.Context) error } // Deps is everything the HTTP surface is built from. type Deps struct { Log *slog.Logger // DB is nil when no database is configured: the service still starts and still answers // liveness, and readiness reports why it is not ready. DB Prober Auth *auth.Authenticator // TrustedOrigins are origins besides our own allowed to make unsafe requests. TrustedOrigins []string // HSTS asks browsers never to speak plain http to this host again. Off in the dev profile: the // policy is pinned per host and localhost would keep it long after the experiment. HSTS bool // Login, when set, is mounted at /auth/. It is handed the session guard rather than sitting // behind one: the surface that CREATES a session cannot require one, and the surface that ends // a session must. Login LoginSurface // Library and Runs are the contract surface. Both nil leaves /v0 a guarded 404, which is what a // service started without a database serves — the routes below all read one. Library Library Runs Runs } // New builds the handler. // // Contract routes are registered on the SAME mux as the ops ones, with the version prefix written // into the pattern, and each is wrapped in guard(). One mux is what keeps Request.Pattern // meaningful all the way out to the access log — a nested mux behind http.StripPrefix hands the // inner handler a copy, and the pattern the copy learns never comes back. func New(d Deps) (http.Handler, error) { if d.Auth == nil { return nil, errors.New("httpapi: no authenticator: the API subtree may not be served unguarded") } csrf, err := auth.CSRF(d.TrustedOrigins, d.Auth.Cookies.SessionName(), ProblemHandler(http.StatusForbidden, "Cross-origin request rejected")) if err != nil { return nil, err } // Every API route goes through this. An anonymous caller therefore gets 401 before 404, which // is deliberate: the shape of the surface is not public information. // // The body limit is per ROUTE, not a blanket outer layer: MaxBytesReader wrapping an already // wrapped body keeps the tighter limit, so an upload route could never raise its own above a // shared default. The book upload registers guard(maxUpload, …) when it lands. guard := func(maxBody int64, h http.Handler) http.Handler { return LimitBody(maxBody)(csrf(d.Auth.Require(h))) } mux := http.NewServeMux() mux.Handle("GET /healthz", http.HandlerFunc(healthz)) mux.Handle("GET /readyz", readyz(d.DB, d.Log)) if d.Login != nil { // The subtree still gets the body cap and the CSRF check — sign-out is a POST, and a // cross-site page must not be able to make one. Rate limiting lives inside the flow, which // knows which of its endpoints is the unauthenticated one. mux.Handle("/auth/", LimitBody(DefaultMaxBody)(csrf(d.Login.Routes(d.Auth.Require)))) } // The contract's base path is written here and nowhere else. Ops endpoints stay outside it: a // health check is not part of the versioned surface and must not move when the surface does. contractRoutes(mux, d, guard) // Everything else under the prefix, including the contract routes that have not been built yet, // answers a guarded 404: the anonymous caller still meets 401 first, so the shape of the surface // stays private either way. mux.Handle(APIPrefix+"/", guard(DefaultMaxBody, ProblemHandler(http.StatusNotFound, "Object not found"))) // Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas // a panic unwinding past the logger produces none. return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(Recover(d.Log)(mux)))), nil } // healthz is liveness: the process is up and serving. It touches nothing, so a database outage // cannot make a supervisor kill a healthy process. func healthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) } // readyz is readiness: this instance can serve traffic, which means the database answers. func readyz(db Prober, log *slog.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if db == nil { WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "no database configured") return } ctx, cancel := context.WithTimeout(r.Context(), readyProbeTimeout) defer cancel() if err := db.Ready(ctx); err != nil { // The reason goes to the LOG and not to the wire. Both halves are deliberate: a // swallowed dependency failure is a defect of its own (PD-16), and /readyz is // unauthenticated, so "the schema is two migrations behind" is a fact about our rollout // that no anonymous caller needs. An operator has the log line. log.ErrorContext(r.Context(), "readiness probe failed", "err", err) WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "database not ready") return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ready\n")) }) }