190 lines
9 KiB
Go
190 lines
9 KiB
Go
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
|
|
// Intake is the book upload. Nil where this deployment has nowhere to put a file or no engine to
|
|
// cut it with, and then POST /books is a guarded 404 like every other unbuilt route: an instance
|
|
// that accepted uploads it could only reject would be worse than one that says it takes none.
|
|
Intake Intake
|
|
// Bank is the correction door. Nil where this deployment has no engine to apply corrections
|
|
// with, and then the path answers the guarded 404 the canon promises for
|
|
// `bank_corrections_enabled: false` — the capability and the mount are one fact.
|
|
Bank Bank
|
|
// Exports is the export door. Nil where this deployment declares no format it can build, and
|
|
// then the three export routes answer the guarded 404 while `export_formats` answers `[]` — the
|
|
// capability and the mount are one fact, like the correction door's.
|
|
Exports Exports
|
|
// Upload bounds the one route that carries a file.
|
|
Upload UploadLimits
|
|
// Capabilities is what `GET /capabilities` answers: what this deployment can do.
|
|
Capabilities Capabilities
|
|
// Keys is the store of `Idempotency-Key` records. Nil means the header is accepted and ignored,
|
|
// which is what an instance with no database can honestly offer.
|
|
Keys IdempotencyKeys
|
|
// Observe wraps every request in this deployment's telemetry. A plain middleware rather than an
|
|
// interface so this package stays free of a metrics library; nil is a service that measures
|
|
// nothing.
|
|
Observe func(http.Handler) http.Handler
|
|
}
|
|
|
|
// UploadLimits is what the intake route is allowed to cost.
|
|
//
|
|
// Both numbers are the operator's, and both are refusals rather than sizings: a book is tens of
|
|
// megabytes and minutes of a domestic connection, and the defaults of the surface — a one-megabyte
|
|
// body and a thirty-second read — are correct for every OTHER route and would make this one
|
|
// impossible.
|
|
type UploadLimits struct {
|
|
// MaxBytes caps the request body of the intake route (http.MaxBytesReader, the second half of
|
|
// PD-2 and the whole of PD-72).
|
|
MaxBytes int64
|
|
// Deadline is how long the body of ONE upload may take to arrive. It replaces the server's
|
|
// ReadTimeout for this route through http.ResponseController and is still a finite bound — the
|
|
// forbidden thing is CLEARING the deadline, which puts a half-fed request back to being
|
|
// unbounded (STACK_DECISIONS §12, register row PD-51).
|
|
Deadline time.Duration
|
|
}
|
|
|
|
// DefaultUploadLimits are the intake's bounds when an operator chooses none: a book file large
|
|
// enough for the acceptance corpus (23 MB) with room around it, and long enough to arrive over a
|
|
// slow domestic uplink.
|
|
func DefaultUploadLimits() UploadLimits {
|
|
return UploadLimits{MaxBytes: 64 << 20, Deadline: 10 * time.Minute}
|
|
}
|
|
|
|
// 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(),
|
|
CauseHandler(CodeForbidden, CauseOriginRejected),
|
|
CauseHandler(CodeForbidden, CauseClientHeaderMissing))
|
|
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 is the route that needed it and registers guard(MaxBytes, …).
|
|
guard := func(maxBody int64, h http.Handler) http.Handler {
|
|
return LimitBody(maxBody)(csrf(d.Auth.Require(h)))
|
|
}
|
|
if d.Upload.MaxBytes <= 0 || d.Upload.Deadline <= 0 {
|
|
// A zero here would be a route with no bound at all — MaxBytesReader treats a negative count
|
|
// as zero and a zero deadline as none — so it is filled rather than trusted. The intake is the
|
|
// one route where an unset limit is a hole and not an inconvenience.
|
|
d.Upload = DefaultUploadLimits()
|
|
}
|
|
|
|
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(CodeNotFound)))
|
|
|
|
// Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas
|
|
// a panic unwinding past the logger produces none. Telemetry sits beside the log and for the same
|
|
// reason — it reads the route the mux matched and the status the recovery produced.
|
|
served := http.Handler(Recover(d.Log)(mux))
|
|
if d.Observe != nil {
|
|
served = d.Observe(served)
|
|
}
|
|
return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(served))), 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 {
|
|
WriteStatusProblem(w, r, 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)
|
|
WriteStatusProblem(w, r, 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"))
|
|
})
|
|
}
|