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.
132 lines
3.8 KiB
Go
132 lines
3.8 KiB
Go
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
|
|
}
|