feat(capturehttp): POST /capture REST adapter + OAuth2 + origin resolver (#53)
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>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user