ai-sessions#13: capture's `summary` field wrote a frontmatter shape (title/harness/fidelity/captured_at/repos_touched) that ai-sessions' own extract/audit pipeline can't parse -- silently invisible to that repo's own audit tooling, and bypassing its redaction + Stage2 completeness gates by construction. Only 5 files ever landed this way over 3 weeks; the summary capability's whole value (speed) fights ai-sessions' whole value (redacted, audited, complete), so kill it rather than build a second parse branch. capture now persists insights -> brain and action items -> Gitea tickets only. Removes Summary/SummaryResult/SummaryWriter and all wiring (service, REST body, MCP tool schema); close-session updated to stop assembling a summary payload. specs/capture-*.md kept as historical record with a superseded note -- the feature shipped and is documented, just no longer current behaviour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WPdSHbp9Utb2hPFm9wDG59
218 lines
6.8 KiB
Go
218 lines
6.8 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"`
|
|
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"`
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|