The HTTP door for the capture capability. Thin: authenticate → derive trust-zone origin → decode → capture.Service → map receipt to status. - Auth mirrors the chassis Bearer precedence (static token wins, then Dex JWT) but returns the resolved principal + auth path, which the chassis middleware hides — capture needs the principal to derive the origin. Depends on a small Validator interface (the chassis *JWTValidator satisfies it) so the JWT/origin path is testable without a live JWKS. - OriginResolver maps principal → trust zone: static-token caller and allowlisted JWT subjects → sovereign; every other principal → us-nexus (fail safe, so the I1 gate refuses confidential by default). Principal and origin are server-set on the input, overwriting any body the caller sent. - HTTP status: 200 all-ok / dry-run, 207 partial, 502 all-failed, 403 on the I1 refusal, 400 on fail-closed validation. - Wired in main behind the same static+JWT credentials as /mcp, reusing the MCP server's graph-wired brain store (one implementation) and the classification tags (#50). Mounts only when a Gitea tracker is configured. Sovereign JWT principals via BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
6.3 KiB
Go
212 lines
6.3 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"
|
|
"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 := h.authenticate(r)
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req request
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
|
|
in := req.toInput()
|
|
// 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 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 wins, then
|
|
// JWT) but returns the resolved principal and whether the static path was
|
|
// taken — the chassis middleware hides both, and capture needs them to
|
|
// derive the origin.
|
|
func (h *Handler) authenticate(r *http.Request) (principal string, viaStatic, ok bool) {
|
|
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !found || raw == "" {
|
|
return "", false, false
|
|
}
|
|
if h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 {
|
|
return h.staticPrincipal, true, true
|
|
}
|
|
if h.validator != nil {
|
|
if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
|
return sub, false, true
|
|
}
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
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)
|
|
}
|