feat: greenfield Go rewrite (Phase 2A + 2B + 2C core)
Replaces the Python v1 (preserved on archive/python-v1 branch).
Stack: Go 1.23 + chi router + sqlx + MySQL 8. Distroless static
container. 12-factor config from env. Embedded SQL migrations.
Schema (internal/db/migrations/001_init.sql):
- topics: 议题 with 4-timestamp lifecycle (signup_open/close +
debate_start/end), visibility (default private), status state machine,
verdict_schema FK
- signups: agent self-enrollment with willing_camps (JSON array of
pro|con|judge), pre_validated audit flag, (topic,agent) unique
- camps: post-allocation lock (one row per topic+camp) — written by
Phase 2D allocator
- rounds + arguments: chronological debate transcript
- verdicts: judge structured output, one per topic, with token-cost
trail for future budgeting
- agent_keys + system_keys: peppered sha256 hashes, never raw
- verdict_schemas: seeded with binary, claim-resolution (for
analyze-intel), policy-recommendation, free-form
Auth (internal/auth):
- AgentAPIKey: real bearer-token middleware against agent_keys;
best-effort last_used_at touch on success
- OIDCBrowser: Phase 2 stub. Dev mode accepts x-dev-bypass header
(constant-time compare); prod 401s with a Phase-4-pending hint.
Real Keycloak JWKS verification lands with the frontend rewrite.
HTTP API (internal/httpapi):
- /api/healthz — db ping + version + uptime
- GET /api/topics — list with status/visibility/limit/offset filters;
anonymous callers see public only
- GET /api/topics/{id} — visibility-gated (private → 404 hide)
- POST /api/topics — create with RFC3339 lifecycle validation
(signup_open < signup_close <= debate_start < debate_end)
- PUT /api/topics/{id}/visibility — dialectic-admin role gate
- POST /api/topics/{id}/signups — agent self-enroll; rejects when
topic.status != signup_open OR outside signup window; idempotent
upsert per (topic, agent)
- GET /api/topics/{id}/signups — list (any authed caller)
Auth chains:
- optionalAuth: try bearer → try oidc → fall through anonymous
(handlers branch on Caller.Kind == ""). Uses captureWriter to demote
inner 401s to "try next" without leaking response bytes.
- requireAnyAuth: chain that 401s if neither succeeds.
- requireAgent: strict bearer-only (signup POST).
Run: `docker compose -f docker-compose.dev.yml up --build`. Migrations
auto-apply on first connect; idempotent on reboot. README documents
env vars, dev bypass usage, agent-key provisioning SQL, and the
Phase 2D/E/3/4/5 roadmap.
go vet clean, gofmt clean, single 11M static binary.
This commit is contained in:
38
internal/httpapi/handlers/health.go
Normal file
38
internal/httpapi/handlers/health.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
db *sqlx.DB
|
||||
version string
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func NewHealthHandler(db *sqlx.DB, version string) *HealthHandler {
|
||||
return &HealthHandler{db: db, version: version, startedAt: time.Now()}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
dbOK := h.db.PingContext(ctx) == nil
|
||||
status := http.StatusOK
|
||||
if !dbOK {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": dbOK,
|
||||
"version": h.version,
|
||||
"uptime_s": int(time.Since(h.startedAt).Seconds()),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
131
internal/httpapi/handlers/signups.go
Normal file
131
internal/httpapi/handlers/signups.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store"
|
||||
)
|
||||
|
||||
type SignupsHandler struct {
|
||||
topics *store.TopicStore
|
||||
signups *store.SignupStore
|
||||
}
|
||||
|
||||
func NewSignupsHandler(t *store.TopicStore, s *store.SignupStore) *SignupsHandler {
|
||||
return &SignupsHandler{topics: t, signups: s}
|
||||
}
|
||||
|
||||
type signupBody struct {
|
||||
WillingCamps []models.Camp `json:"willing_camps"`
|
||||
PreValidated bool `json:"pre_validated"`
|
||||
}
|
||||
|
||||
// POST /api/topics/{id}/signups
|
||||
//
|
||||
// Agent self-enrollment. Only CallerAgent is allowed — browsers can't
|
||||
// sign up on behalf of an agent (would defeat the on_call pre-check
|
||||
// that the plugin does before calling this endpoint).
|
||||
//
|
||||
// Body: { willing_camps: [pro|con|judge ...], pre_validated: bool }
|
||||
//
|
||||
// pre_validated is the agent's plugin's claim that it verified the
|
||||
// agent has an on_call HF slot covering [debate_start_at, debate_end_at].
|
||||
// Backend trusts but logs — Phase N may add server-side verification.
|
||||
//
|
||||
// Topic must be in status `signup_open`. Outside that window → 409.
|
||||
func (h *SignupsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
caller := auth.FromContext(r.Context())
|
||||
if caller.Kind != auth.CallerAgent {
|
||||
http.Error(w, "signup is agent-only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
topicID := chi.URLParam(r, "id")
|
||||
t, err := h.topics.GetByID(r.Context(), topicID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "topic not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if t.Status != models.TopicStatusSignupOpen {
|
||||
http.Error(w, "signup window not open (status="+string(t.Status)+")", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Before(t.SignupOpenAt) {
|
||||
http.Error(w, "signup not yet open", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if now.After(t.SignupCloseAt) {
|
||||
http.Error(w, "signup closed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
var body signupBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.WillingCamps) == 0 {
|
||||
http.Error(w, "willing_camps required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Dedup the camps so a buggy plugin can't insert duplicates.
|
||||
camps := dedupCamps(body.WillingCamps)
|
||||
view, err := h.signups.Upsert(r.Context(), store.UpsertSignupInput{
|
||||
TopicID: topicID,
|
||||
AgentID: caller.ID,
|
||||
WillingCamps: camps,
|
||||
PreValidated: body.PreValidated,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "upsert failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, view)
|
||||
}
|
||||
|
||||
// GET /api/topics/{id}/signups — list all signups for a topic.
|
||||
//
|
||||
// Visible to topic creator + admins + agents. Public anonymous see
|
||||
// nothing (avoid leaking who-signed-up for private topics).
|
||||
func (h *SignupsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
caller := auth.FromContext(r.Context())
|
||||
if caller.Kind == "" {
|
||||
http.Error(w, "auth required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
topicID := chi.URLParam(r, "id")
|
||||
if _, err := h.topics.GetByID(r.Context(), topicID); err != nil {
|
||||
http.Error(w, "topic not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rows, err := h.signups.ListByTopic(r.Context(), topicID)
|
||||
if err != nil {
|
||||
http.Error(w, "list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"signups": rows, "count": len(rows)})
|
||||
}
|
||||
|
||||
func dedupCamps(in []models.Camp) []models.Camp {
|
||||
seen := map[models.Camp]struct{}{}
|
||||
out := make([]models.Camp, 0, len(in))
|
||||
for _, c := range in {
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
221
internal/httpapi/handlers/topics.go
Normal file
221
internal/httpapi/handlers/topics.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store"
|
||||
)
|
||||
|
||||
type TopicsHandler struct {
|
||||
store *store.TopicStore
|
||||
}
|
||||
|
||||
func NewTopicsHandler(s *store.TopicStore) *TopicsHandler { return &TopicsHandler{store: s} }
|
||||
|
||||
// GET /api/topics?status=...&visibility=...&limit=...&offset=...
|
||||
//
|
||||
// Visibility filter is enforced at the auth layer: anonymous callers
|
||||
// only see visibility=public; authenticated users (CallerUser) see all
|
||||
// they're entitled to (Phase 2 v1: all; Phase 4 may add per-user ACLs).
|
||||
// Agent callers (CallerAgent) see all — they're acting as system on
|
||||
// behalf of the platform.
|
||||
func (h *TopicsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
caller := auth.FromContext(r.Context())
|
||||
f := store.ListFilter{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Visibility: r.URL.Query().Get("visibility"),
|
||||
}
|
||||
if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 {
|
||||
f.Limit = v
|
||||
}
|
||||
if v, _ := strconv.Atoi(r.URL.Query().Get("offset")); v > 0 {
|
||||
f.Offset = v
|
||||
}
|
||||
|
||||
// Anonymous: force visibility=public.
|
||||
if caller.Kind == "" {
|
||||
f.Visibility = string(models.VisibilityPublic)
|
||||
}
|
||||
|
||||
rows, err := h.store.List(r.Context(), f)
|
||||
if err != nil {
|
||||
http.Error(w, "list failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"topics": rows, "count": len(rows)})
|
||||
}
|
||||
|
||||
// GET /api/topics/{id}
|
||||
func (h *TopicsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
http.Error(w, "missing id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
t, err := h.store.GetByID(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "topic not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "get failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Visibility gate: anonymous can only see public; authenticated see all.
|
||||
caller := auth.FromContext(r.Context())
|
||||
if caller.Kind == "" && t.Visibility != models.VisibilityPublic {
|
||||
http.Error(w, "not found", http.StatusNotFound) // 404 not 403 — hide existence
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
type createTopicBody struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Visibility string `json:"visibility"` // default "private"
|
||||
VerdictSchemaID string `json:"verdict_schema_id"` // default "free-form"
|
||||
SignupOpenAt string `json:"signup_open_at"` // RFC3339
|
||||
SignupCloseAt string `json:"signup_close_at"`
|
||||
DebateStartAt string `json:"debate_start_at"`
|
||||
DebateEndAt string `json:"debate_end_at"`
|
||||
}
|
||||
|
||||
// POST /api/topics
|
||||
//
|
||||
// Allowed callers: agent or authenticated user. Anonymous rejected
|
||||
// (the route wires the auth-required middleware).
|
||||
func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
caller := auth.FromContext(r.Context())
|
||||
if caller.Kind == "" {
|
||||
http.Error(w, "auth required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var body createTopicBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Title == "" || body.Summary == "" {
|
||||
http.Error(w, "title and summary required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Visibility == "" {
|
||||
body.Visibility = string(models.VisibilityPrivate)
|
||||
}
|
||||
if body.VerdictSchemaID == "" {
|
||||
body.VerdictSchemaID = "free-form"
|
||||
}
|
||||
if err := validateLifecycleTimes(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := h.store.Create(r.Context(), store.CreateTopicInput{
|
||||
Title: body.Title,
|
||||
Summary: body.Summary,
|
||||
Visibility: models.Visibility(body.Visibility),
|
||||
VerdictSchemaID: body.VerdictSchemaID,
|
||||
SignupOpenAt: body.SignupOpenAt,
|
||||
SignupCloseAt: body.SignupCloseAt,
|
||||
DebateStartAt: body.DebateStartAt,
|
||||
DebateEndAt: body.DebateEndAt,
|
||||
CreatorUserID: caller.ID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// validateLifecycleTimes enforces:
|
||||
//
|
||||
// signup_open < signup_close <= debate_start < debate_end
|
||||
//
|
||||
// All four timestamps must be parsable as RFC3339; failure → 400.
|
||||
func validateLifecycleTimes(b createTopicBody) error {
|
||||
type p struct {
|
||||
name string
|
||||
raw string
|
||||
}
|
||||
parts := []p{
|
||||
{"signup_open_at", b.SignupOpenAt},
|
||||
{"signup_close_at", b.SignupCloseAt},
|
||||
{"debate_start_at", b.DebateStartAt},
|
||||
{"debate_end_at", b.DebateEndAt},
|
||||
}
|
||||
parsed := make([]time.Time, 4)
|
||||
for i, x := range parts {
|
||||
t, err := time.Parse(time.RFC3339, x.raw)
|
||||
if err != nil {
|
||||
return errors.New(x.name + ": must be RFC3339")
|
||||
}
|
||||
parsed[i] = t
|
||||
}
|
||||
if !parsed[0].Before(parsed[1]) {
|
||||
return errors.New("signup_open_at must be before signup_close_at")
|
||||
}
|
||||
if parsed[1].After(parsed[2]) {
|
||||
return errors.New("signup_close_at must be <= debate_start_at")
|
||||
}
|
||||
if !parsed[2].Before(parsed[3]) {
|
||||
return errors.New("debate_start_at must be before debate_end_at")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT /api/topics/{id}/visibility — admin-only flip (Phase 2 stub: any
|
||||
// authenticated user; Phase 4 will check the dialectic-admin role from JWT).
|
||||
func (h *TopicsHandler) SetVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
caller := auth.FromContext(r.Context())
|
||||
if caller.Kind == "" {
|
||||
http.Error(w, "auth required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if caller.Kind == auth.CallerUser && !hasRole(caller, "dialectic-admin") {
|
||||
http.Error(w, "dialectic-admin role required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
var body struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
v := models.Visibility(body.Visibility)
|
||||
if v != models.VisibilityPublic && v != models.VisibilityPrivate {
|
||||
http.Error(w, "visibility must be public|private", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
t, err := h.store.SetVisibility(r.Context(), id, v, caller.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func hasRole(c auth.Caller, role string) bool {
|
||||
for _, r := range c.Roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
177
internal/httpapi/routes.go
Normal file
177
internal/httpapi/routes.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/httpapi/handlers"
|
||||
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store"
|
||||
)
|
||||
|
||||
// Mount returns the root router with all v2 endpoints wired. Owners of
|
||||
// individual middleware chains:
|
||||
//
|
||||
// - /api/healthz : public (no auth)
|
||||
// - /api/topics : mixed — list/get optional auth (anon
|
||||
// sees public only); create requires CallerAgent or CallerUser
|
||||
// - /api/topics/{id}/signups : agent-only (CallerAgent)
|
||||
//
|
||||
// Browser-side OIDC and agent-side bearer middlewares co-exist on the
|
||||
// same route by being "optional auth" — if either succeeds, Caller is
|
||||
// attached; otherwise the handler sees anonymous and decides whether
|
||||
// to 401 or fall through to public behavior.
|
||||
func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Boilerplate middleware — these run on every request.
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.Timeout(30 * time.Second))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: cfg.CORSAllowOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "x-dev-bypass"},
|
||||
ExposedHeaders: []string{},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Auth middlewares — composed as "try agent, then user, else pass anonymous".
|
||||
optionalAuth := optionalAuthChain(db, cfg)
|
||||
requireAgent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper) // strict bearer
|
||||
requireAnyAuth := requireAnyAuthChain(db, cfg)
|
||||
|
||||
// Handler instances.
|
||||
topicStore := store.NewTopicStore(db)
|
||||
signupStore := store.NewSignupStore(db)
|
||||
health := handlers.NewHealthHandler(db, version)
|
||||
topicsH := handlers.NewTopicsHandler(topicStore)
|
||||
signupsH := handlers.NewSignupsHandler(topicStore, signupStore)
|
||||
|
||||
// Routes.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/healthz", health.Healthz)
|
||||
|
||||
// Topics: list+get optional-auth (visibility-gated by handler);
|
||||
// create+visibility-flip require any auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(optionalAuth)
|
||||
r.Get("/topics", topicsH.List)
|
||||
r.Get("/topics/{id}", topicsH.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireAnyAuth)
|
||||
r.Post("/topics", topicsH.Create)
|
||||
r.Put("/topics/{id}/visibility", topicsH.SetVisibility)
|
||||
})
|
||||
|
||||
// Signups: agent-only.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireAgent)
|
||||
r.Post("/topics/{id}/signups", signupsH.Create)
|
||||
})
|
||||
// List signups: any authenticated caller.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireAnyAuth)
|
||||
r.Get("/topics/{id}/signups", signupsH.List)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// optionalAuthChain: if either auth method succeeds, attach Caller;
|
||||
// otherwise let the request through anonymous. Handlers decide what
|
||||
// to do with anonymous (typically: serve public subset, hide private).
|
||||
func optionalAuthChain(db *sqlx.DB, cfg *config.Config) func(http.Handler) http.Handler {
|
||||
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
|
||||
oidc := auth.OIDCBrowser(cfg.IsDev(), cfg.OIDCDevBypassToken)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Bearer present → try agent path; on success it ServeHTTPs next.
|
||||
// On failure it 401s, which we want to demote to "anonymous" for
|
||||
// optional auth. The pattern is: capture the response; if it's
|
||||
// 401, fall through to OIDC; if OIDC also 401s, finally fall
|
||||
// through to next (anonymous).
|
||||
if r.Header.Get("authorization") != "" {
|
||||
rw := &captureWriter{ResponseWriter: w}
|
||||
agent(next).ServeHTTP(rw, r)
|
||||
if rw.status != http.StatusUnauthorized {
|
||||
return
|
||||
}
|
||||
// reset captured state and try anon path (since OIDC
|
||||
// won't apply if there's no cookie / bypass header)
|
||||
}
|
||||
if r.Header.Get("x-dev-bypass") != "" {
|
||||
rw := &captureWriter{ResponseWriter: w}
|
||||
oidc(next).ServeHTTP(rw, r)
|
||||
if rw.status != http.StatusUnauthorized {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Anonymous — call next with no Caller attached.
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requireAnyAuthChain: 401 if neither agent nor user auth succeeds.
|
||||
func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config) func(http.Handler) http.Handler {
|
||||
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
|
||||
oidc := auth.OIDCBrowser(cfg.IsDev(), cfg.OIDCDevBypassToken)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("authorization") != "" {
|
||||
rw := &captureWriter{ResponseWriter: w}
|
||||
agent(next).ServeHTTP(rw, r)
|
||||
if rw.status != http.StatusUnauthorized {
|
||||
return
|
||||
}
|
||||
}
|
||||
oidc(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// captureWriter records the status so the optional-auth chain can
|
||||
// distinguish "401 from inner middleware (try next)" from "actual
|
||||
// response from handler (deliver)". Body bytes are passed through
|
||||
// when status != 401.
|
||||
type captureWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
suppressing bool
|
||||
}
|
||||
|
||||
func (c *captureWriter) WriteHeader(s int) {
|
||||
c.status = s
|
||||
c.wroteHeader = true
|
||||
if s == http.StatusUnauthorized {
|
||||
// don't actually write — we may fall through
|
||||
c.suppressing = true
|
||||
return
|
||||
}
|
||||
c.ResponseWriter.WriteHeader(s)
|
||||
}
|
||||
|
||||
func (c *captureWriter) Write(b []byte) (int, error) {
|
||||
if c.suppressing {
|
||||
// swallow; caller will fall through to next chain step
|
||||
return len(b), nil
|
||||
}
|
||||
if !c.wroteHeader {
|
||||
c.ResponseWriter.WriteHeader(http.StatusOK)
|
||||
c.wroteHeader = true
|
||||
}
|
||||
return c.ResponseWriter.Write(b)
|
||||
}
|
||||
Reference in New Issue
Block a user