// Package capturehttp is the REST adapter for the capture use-case: the // POST /capture door (#53). It is deliberately thin — authenticate, derive // the trust-zone origin from the authenticated principal, decode the // request, call capture.Service, map the receipt to an HTTP status. No // business logic lives here; the I1 gate, validation, and orchestration // are all in the use-case. package capturehttp import ( "context" "crypto/subtle" "encoding/json" "errors" "io" "net/http" "strings" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" ) // Validator validates a Bearer JWT and returns its subject. The chassis // *auth.JWTValidator satisfies it (including its nil-receiver "disabled" // behaviour), and tests can substitute a fake without a live JWKS. type Validator interface { Validate(ctx context.Context, rawToken string) (string, error) } // Handler serves POST /capture. type Handler struct { svc *capture.Service validator Validator // nil ⇒ JWT auth disabled staticToken string // "" ⇒ static auth disabled staticPrincipal string // principal name attributed to static-token callers resolver OriginResolver } // New constructs a capture HTTP handler. staticToken callers are // attributed to staticPrincipal (a sovereign homelab identity); JWT // callers are attributed to their token subject. func New(svc *capture.Service, validator Validator, staticToken, staticPrincipal string, resolver OriginResolver) *Handler { if staticPrincipal == "" { staticPrincipal = "local-cli" } return &Handler{ svc: svc, validator: validator, staticToken: staticToken, staticPrincipal: staticPrincipal, resolver: resolver, } } // wire types — the POST /capture request body. type request struct { Context contextBody `json:"context"` Insights []insightBody `json:"insights"` Tickets []ticketBody `json:"tickets"` Summary *summaryBody `json:"summary,omitempty"` DryRun bool `json:"dry_run"` } type contextBody struct { Harness string `json:"harness"` SessionRef string `json:"session_ref"` Fidelity string `json:"fidelity"` Actor string `json:"actor"` Classification string `json:"classification"` } type insightBody struct { Text string `json:"text"` Wing string `json:"wing"` Hall string `json:"hall"` SupersedeSlug string `json:"supersede_slug,omitempty"` } type ticketBody struct { Repo string `json:"repo"` Action string `json:"action"` Number int `json:"number,omitempty"` Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` } type summaryBody struct { Title string `json:"title"` Body string `json:"body"` ReposTouched []string `json:"repos_touched,omitempty"` } // ServeHTTP authenticates, derives origin, runs the use-case, and maps the // result to an HTTP status. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(r.Body) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body"}) return } in, err := DecodeRequest(body) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } // Principal and origin are server-derived — overwrite anything the // caller may have tried to put in the body. in.Context.Principal = principal in.Context.Origin = h.resolver.Resolve(principal, viaStatic) rec, err := h.svc.Capture(r.Context(), in) switch { case errors.Is(err, capture.ErrSovereigntyRefused): writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return case errors.Is(err, capture.ErrAuditUnavailable): // I5 refusal: confidential + audit sink down, or the all-tiers floor. writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return case err != nil: // Pre-write validation failure (fail-closed). writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, statusFor(rec), rec) } // Authenticate mirrors the chassis Bearer precedence (static token wins, // then Dex JWT) and returns the resolved principal plus whether the static // path was taken — the chassis middleware hides both, and capture (REST or // MCP) needs them to derive the trust-zone origin. ok is false when no // credential matched. func Authenticate(r *http.Request, staticToken, staticPrincipal string, validator Validator) (principal string, viaStatic, ok bool) { raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !found || raw == "" { return "", false, false } if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 { return staticPrincipal, true, true } if validator != nil { if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" { return sub, false, true } } return "", false, false } // DecodeRequest parses a capture request body into a CaptureInput. Shared // by the REST adapter and the MCP capture tool so the wire shape has one // definition. Principal and Origin are NOT set here — the caller sets them // from the authenticated identity. func DecodeRequest(data []byte) (capture.CaptureInput, error) { var b request if err := json.Unmarshal(data, &b); err != nil { return capture.CaptureInput{}, err } return b.toInput(), nil } func (b request) toInput() capture.CaptureInput { in := capture.CaptureInput{ Context: capture.CaptureContext{ Harness: b.Context.Harness, SessionRef: b.Context.SessionRef, Fidelity: b.Context.Fidelity, Actor: b.Context.Actor, Classification: b.Context.Classification, }, DryRun: b.DryRun, } for _, i := range b.Insights { in.Insights = append(in.Insights, capture.Insight{ Text: i.Text, Wing: i.Wing, Hall: i.Hall, SupersedeSlug: i.SupersedeSlug, }) } for _, t := range b.Tickets { in.Tickets = append(in.Tickets, capture.Ticket{ Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body, }) } if b.Summary != nil { in.Summary = &capture.Summary{ Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched, } } return in } // statusFor maps a receipt to an HTTP status: 200 all-ok (or dry-run), // 207 partial, 502 everything-failed. func statusFor(rec capture.CaptureReceipt) int { if rec.DryRun { return http.StatusOK } var ok, fail int for _, i := range rec.Insights { count(&ok, &fail, i.OK) } for _, t := range rec.Tickets { count(&ok, &fail, t.OK) } if rec.Summary != nil { count(&ok, &fail, rec.Summary.OK) } switch { case fail == 0: return http.StatusOK case ok == 0: return http.StatusBadGateway // every persistence attempt failed default: return http.StatusMultiStatus // 207: partial success } } func count(ok, fail *int, isOK bool) { if isOK { *ok++ } else { *fail++ } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }