refactor(capturehttp): export Authenticate + DecodeRequest for reuse (#55)

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>
This commit is contained in:
2026-06-23 00:21:11 +02:00
co-authored by Claude Opus 4.8
parent c307b72bd5
commit f78a5474a5
+31 -14
View File
@@ -11,6 +11,7 @@ import (
"crypto/subtle"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
@@ -90,19 +91,22 @@ type summaryBody struct {
// 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)
principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
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
}
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
@@ -125,26 +129,39 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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) {
// 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 h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 {
return h.staticPrincipal, true, true
if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 {
return staticPrincipal, true, true
}
if h.validator != nil {
if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" {
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{