Lifts the Bearer principal-derivation and the request→CaptureInput decode out of the REST handler into exported package funcs, so the MCP capture tool (#55 relay) reuses the exact same auth precedence and wire shape — one implementation, not two. No behaviour change to POST /capture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
7.2 KiB
Go
233 lines
7.2 KiB
Go
// 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)
|
|
}
|