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:
@@ -16,6 +16,10 @@ import (
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
@@ -119,6 +123,18 @@ func envInt(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// splitList parses a comma-separated env value into a trimmed,
|
||||
// empty-free slice. Used for the capture sovereign-principal allowlist.
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
||||
// caller never has to handle the rare error path.
|
||||
func systemHostname() string {
|
||||
@@ -349,6 +365,30 @@ func main() {
|
||||
|
||||
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||
|
||||
// POST /capture (#53): the uniform capture REST door. Needs a ticket
|
||||
// tracker to file action items, so it only mounts when Gitea is
|
||||
// configured. It reuses the MCP server's graph-wired brain store (one
|
||||
// implementation), the classification tags for the I1 gate, and a slog
|
||||
// audit sink (the loki+buffer sink lands in #54). The handler does its
|
||||
// own auth (static + JWT) because it needs the principal to derive the
|
||||
// trust-zone origin — the chassis middleware hides it.
|
||||
if tracker := mcpSrv.IssueTracker(); tracker != nil {
|
||||
classCfg, cerr := classification.Load(brainDir)
|
||||
if cerr != nil {
|
||||
logger.Error("load classification config", "err", cerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
captureSvc := capture.NewService(
|
||||
mcpSrv.BrainStore(), tracker, nil, classCfg, audit.NewSlogSink(logger))
|
||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli",
|
||||
capturehttp.NewOriginResolver(sovereign))
|
||||
mux.Handle("POST /capture", captureH)
|
||||
logger.Info("capture endpoint enabled", "sovereign_principals", len(sovereign))
|
||||
} else {
|
||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||
}
|
||||
|
||||
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||
// integration UI, which has no static-Bearer field. Setting both
|
||||
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package capturehttp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const staticTok = "static-secret"
|
||||
|
||||
// fakeValidator stands in for the chassis JWT validator.
|
||||
type fakeValidator struct {
|
||||
subject string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeValidator) Validate(context.Context, string) (string, error) {
|
||||
return f.subject, f.err
|
||||
}
|
||||
|
||||
type fakeTracker struct{ failCreate bool }
|
||||
|
||||
func (f fakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
if f.failCreate {
|
||||
return capture.IssueRef{}, errors.New("gitea down")
|
||||
}
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker, sovereign []string) *capturehttp.Handler {
|
||||
t.Helper()
|
||||
cfg, err := classification.Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
||||
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||
}
|
||||
|
||||
func do(t *testing.T, h *capturehttp.Handler, authz string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, "/capture", bytes.NewReader(b))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func internalReq() map[string]any {
|
||||
return map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedWithoutToken(t *testing.T) {
|
||||
h := newHandler(t, fakeValidator{err: errors.New("no")}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "", internalReq())
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestUnauthorizedBadToken(t *testing.T) {
|
||||
h := newHandler(t, fakeValidator{err: errors.New("bad jwt")}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer wrong", internalReq())
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestHappyPathStaticToken(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
assert.Empty(t, rec.Errors)
|
||||
}
|
||||
|
||||
func TestConfidentialViaUSNexusRefused(t *testing.T) {
|
||||
// JWT principal not in the sovereign allowlist ⇒ us-nexus; confidential ⇒ 403.
|
||||
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "sovereignty")
|
||||
}
|
||||
|
||||
func TestConfidentialViaSovereignJWTAllowed(t *testing.T) {
|
||||
// Same confidential payload, but the principal is allowlisted sovereign ⇒ allowed.
|
||||
h := newHandler(t, fakeValidator{subject: "koala-cli"}, fakeTracker{}, []string{"koala-cli"})
|
||||
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestStaticTokenIsSovereignSoConfidentialAllowed(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestValidationRejectedBeforeWrite(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "x", "wing": "hyperguild", "hall": "not-a-hall"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestPartialFailureIs207(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{failCreate: true}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "ok insight", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "fails"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusMultiStatus, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.False(t, rec.Tickets[0].OK)
|
||||
assert.Len(t, rec.Errors, 1)
|
||||
}
|
||||
|
||||
func TestDryRunWritesNothing(t *testing.T) {
|
||||
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||
"dry_run": true,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||
assert.True(t, rec.DryRun)
|
||||
}
|
||||
|
||||
func TestCallerCannotForgeOrigin(t *testing.T) {
|
||||
// Even if the body tried to assert a sovereign harness, a us-nexus JWT
|
||||
// principal + confidential ⇒ refused. (Origin is server-derived.)
|
||||
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||
rr := do(t, h, "Bearer jwt", map[string]any{
|
||||
"context": map[string]any{"harness": "sovereign-soil", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package capturehttp
|
||||
|
||||
import "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
|
||||
// OriginResolver maps an authenticated principal to its trust zone
|
||||
// (spec §4.2). The mapping is server-side and never reads caller input.
|
||||
//
|
||||
// Rules:
|
||||
// - The static-token path is a homelab CLI caller on sovereign soil →
|
||||
// ZoneSovereign.
|
||||
// - A JWT principal in the sovereign allowlist → ZoneSovereign.
|
||||
// - Any other JWT principal (e.g. claude.ai's OAuth identity, or any
|
||||
// unrecognised subject) → ZoneUSNexus.
|
||||
//
|
||||
// The default is the strict one: an unknown principal is treated as
|
||||
// us-nexus so the I1 gate fails safe (refuses confidential), exactly as
|
||||
// an untagged classification target fails safe to confidential (#50).
|
||||
type OriginResolver struct {
|
||||
sovereign map[string]bool
|
||||
}
|
||||
|
||||
// NewOriginResolver builds a resolver whose JWT sovereign principals are
|
||||
// the given subjects. The static-token caller is always sovereign and
|
||||
// need not be listed.
|
||||
func NewOriginResolver(sovereignPrincipals []string) OriginResolver {
|
||||
m := make(map[string]bool, len(sovereignPrincipals))
|
||||
for _, p := range sovereignPrincipals {
|
||||
if p != "" {
|
||||
m[p] = true
|
||||
}
|
||||
}
|
||||
return OriginResolver{sovereign: m}
|
||||
}
|
||||
|
||||
// Resolve returns the trust zone for a principal. viaStatic is true when
|
||||
// the static-token auth path was taken.
|
||||
func (r OriginResolver) Resolve(principal string, viaStatic bool) capture.Zone {
|
||||
if viaStatic || r.sovereign[principal] {
|
||||
return capture.ZoneSovereign
|
||||
}
|
||||
return capture.ZoneUSNexus
|
||||
}
|
||||
@@ -116,6 +116,13 @@ func (s *Server) IssueTracker() capture.IssueTracker {
|
||||
return s.tracker
|
||||
}
|
||||
|
||||
// BrainStore returns the shared brain store (graph-wired once WithGraph
|
||||
// has run), so the capture use-case writes through the exact same
|
||||
// implementation as the MCP handlers.
|
||||
func (s *Server) BrainStore() *brainstore.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||
if r.Method == http.MethodGet {
|
||||
|
||||
Reference in New Issue
Block a user