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:
h z
2026-05-23 11:51:48 +01:00
parent e049b1c4bd
commit e706f3d6ef
51 changed files with 1700 additions and 2324 deletions

144
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,144 @@
// Package auth holds the two middlewares Dialectic v2 uses:
//
// - AgentAPIKey: validates `Authorization: Bearer <raw>` against
// the `agent_keys` table (hashed with the configured pepper).
// Used by Dialectic.OpenclawPlugin → backend calls.
//
// - OIDCBrowser: validates a Keycloak-issued JWT in the
// `dialectic_session` cookie. Used by the React frontend.
// Phase 2C ships a stub that accepts a dev-mode bypass token; the
// real JWKS verification + claim mapping lands with Phase 4.
//
// Both middlewares attach a typed Caller to the request context so
// downstream handlers can read identity uniformly.
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
"github.com/jmoiron/sqlx"
)
type CallerKind string
const (
CallerAgent CallerKind = "agent"
CallerUser CallerKind = "user"
CallerSystem CallerKind = "system"
)
type Caller struct {
Kind CallerKind
ID string // agentId for CallerAgent; userId for CallerUser; key-name for CallerSystem
Roles []string // populated for CallerUser (from JWT claims); empty otherwise
}
type ctxKey struct{}
func WithCaller(ctx context.Context, c Caller) context.Context {
return context.WithValue(ctx, ctxKey{}, c)
}
// FromContext returns the caller attached by an auth middleware. The
// zero Caller (Kind == "") indicates an unauthenticated request reached
// a public route.
func FromContext(ctx context.Context) Caller {
c, _ := ctx.Value(ctxKey{}).(Caller)
return c
}
// HashKey peppers + sha256-hashes a raw API key. Constant pepper; same
// raw key always produces the same hash so lookups can equal-match on
// the key_hash column.
func HashKey(pepper, raw string) string {
h := sha256.Sum256([]byte(pepper + ":" + raw))
return hex.EncodeToString(h[:])
}
// AgentAPIKey middleware: extracts Bearer token, looks up agent_keys,
// 401 on miss. Updates last_used_at lazily (best-effort; failure here
// doesn't block the request).
func AgentAPIKey(db *sqlx.DB, pepper string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r)
if raw == "" {
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
hash := HashKey(pepper, raw)
var agentID string
err := db.GetContext(r.Context(), &agentID,
`SELECT agent_id FROM agent_keys WHERE key_hash = ? AND revoked_at IS NULL`, hash)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "invalid agent key", http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
return
}
go func(h string) {
// best-effort touch — independent ctx so it survives
// even if the request was cancelled mid-handler.
_, _ = db.Exec(
`UPDATE agent_keys SET last_used_at = CURRENT_TIMESTAMP WHERE key_hash = ?`, h)
}(hash)
ctx := WithCaller(r.Context(), Caller{Kind: CallerAgent, ID: agentID})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// OIDCBrowser middleware (Phase 2C stub):
// - Dev mode + `x-dev-bypass: <token>` header → admit as a fake user.
// - Otherwise: 401 with a hint pointing to the (not-yet-wired)
// Keycloak redirect path. The real JWKS-verifying middleware lands
// when the frontend is wired up; until then, browser callers can
// only reach the API via the dev bypass.
func OIDCBrowser(devMode bool, devBypassToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devMode && devBypassToken != "" {
if subtleEqual(r.Header.Get("x-dev-bypass"), devBypassToken) {
ctx := WithCaller(r.Context(), Caller{
Kind: CallerUser,
ID: "dev-operator",
Roles: []string{"dialectic-admin"},
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
// Production path goes through Keycloak — Phase 4.
http.Error(w, "oidc login required (Phase 4: not yet wired)", http.StatusUnauthorized)
})
}
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("authorization")
const prefix = "Bearer "
if strings.HasPrefix(h, prefix) {
return strings.TrimSpace(h[len(prefix):])
}
if strings.HasPrefix(h, "bearer ") {
return strings.TrimSpace(h[len("bearer "):])
}
return ""
}
func subtleEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

141
internal/config/config.go Normal file
View File

@@ -0,0 +1,141 @@
// Package config loads runtime configuration from environment variables.
//
// Conventions:
// - 12-factor: every config knob is an env var; no config files.
// - Sensible dev defaults for local docker-compose; prod sets via env.
// - Sensitive values (DB password, system api key) are *required* in
// prod; LoadFromEnv() fails fast if absent and ENV_MODE != "dev".
package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
// "dev" | "prod". Dev relaxes required-field checks and enables a
// dev-mode auth bypass token. Prod requires every sensitive field.
Mode string
// HTTP server bind. e.g. "0.0.0.0:8090".
HTTPAddr string
// CORS allowed origins (comma-separated; "*" allowed only in dev).
CORSAllowOrigins []string
// MySQL DSN parts.
DBHost string
DBPort string
DBName string
DBUser string
DBPassword string
// Auth.
//
// SystemAPIKey: Phase-1 system key for posting to announce channels
// in Fabric. Mirrored here so Dialectic backend itself can post topic
// announcements via Fabric's POST /channels/:id/messages with
// x-fabric-system-key header.
//
// AgentAPIKeyPepper: HMAC pepper for hashing agent API keys at rest
// (we store sha256(pepper || raw) not the raw key). Rotating the
// pepper invalidates all keys — that's intentional, an emergency
// kill switch.
//
// OIDCDevBypassToken: dev-mode only. If set AND Mode == "dev", a
// browser request with header `x-dev-bypass: <token>` bypasses OIDC
// and is treated as user "dev-operator" with role "dialectic-admin".
// Prod ignores this even if set.
SystemAPIKey string
AgentAPIKeyPepper string
OIDCDevBypassToken string
// OIDC issuer URL (Keycloak realm endpoint). e.g.
// https://auth.hangman-lab.top/realms/hangman-lab
// Phase 2C ships this as configured-but-not-verified; Phase 4 wires
// real JWKS validation.
OIDCIssuer string
OIDCClientID string
}
func LoadFromEnv() (*Config, error) {
c := &Config{
Mode: getenv("ENV_MODE", "dev"),
HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"),
CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")),
DBHost: getenv("DB_HOST", "127.0.0.1"),
DBPort: getenv("DB_PORT", "3306"),
DBName: getenv("DB_NAME", "dialectic"),
DBUser: getenv("DB_USER", "dialectic"),
DBPassword: os.Getenv("DB_PASSWORD"),
SystemAPIKey: os.Getenv("SYSTEM_API_KEY"),
AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"),
OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"),
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
}
if c.Mode != "dev" && c.Mode != "prod" {
return nil, fmt.Errorf("ENV_MODE must be dev|prod, got %q", c.Mode)
}
if c.Mode == "prod" {
var missing []string
if c.DBPassword == "" {
missing = append(missing, "DB_PASSWORD")
}
if c.AgentAPIKeyPepper == "" {
missing = append(missing, "AGENT_API_KEY_PEPPER")
}
if c.OIDCIssuer == "" {
missing = append(missing, "OIDC_ISSUER")
}
if c.OIDCClientID == "" {
missing = append(missing, "OIDC_CLIENT_ID")
}
if len(missing) > 0 {
return nil, fmt.Errorf("prod mode requires env: %s", strings.Join(missing, ", "))
}
// In prod, "*" CORS is never accepted.
for _, o := range c.CORSAllowOrigins {
if o == "*" {
return nil, fmt.Errorf("prod mode forbids CORS_ALLOW_ORIGINS='*'")
}
}
}
return c, nil
}
func (c *Config) IsDev() bool { return c.Mode == "dev" }
func (c *Config) DSN() string {
// MySQL DSN: user:pass@tcp(host:port)/dbname?params
return fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci",
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName,
)
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

119
internal/db/db.go Normal file
View File

@@ -0,0 +1,119 @@
// Package db wraps sqlx and runs embedded SQL migrations on startup.
//
// Migrations are flat files in migrations/, named NNN_*.sql. They run in
// lexical order. Each is executed in its own transaction; a missing
// schema_migrations row indicates "not yet applied". This is a
// deliberately simple migration runner — for this project's size + team
// size, pulling in golang-migrate or atlas adds complexity without
// payback. If migration count grows past ~20, revisit.
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"sort"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func Open(ctx context.Context, dsn string) (*sqlx.DB, error) {
d, err := sqlx.ConnectContext(ctx, "mysql", dsn)
if err != nil {
return nil, fmt.Errorf("connect mysql: %w", err)
}
d.SetMaxOpenConns(25)
d.SetMaxIdleConns(5)
d.SetConnMaxLifetime(5 * time.Minute)
return d, nil
}
// RunMigrations applies any migrations that aren't yet present in the
// schema_migrations table. Idempotent — safe to call on every startup.
func RunMigrations(ctx context.Context, d *sqlx.DB) error {
// Bootstrap the tracker table itself.
if _, err := d.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, name := range files {
var found string
err := d.GetContext(ctx, &found, `SELECT name FROM schema_migrations WHERE name = ?`, name)
if err == nil {
continue // already applied
}
if err != sql.ErrNoRows {
return fmt.Errorf("check migration %s: %w", name, err)
}
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
// MySQL doesn't support multi-statement in a single Exec by default
// — split on ';' boundaries and run each individually. Comments are
// passed through (server-side parser handles).
statements := splitSQL(string(content))
tx, err := d.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("tx for %s: %w", name, err)
}
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.ExecContext(ctx, stmt); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply %s [statement: %q]: %w", name, firstLine(stmt), err)
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(name) VALUES (?)`, name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
}
return nil
}
func splitSQL(s string) []string {
// Crude but adequate for our migrations (no string-literal semicolons).
// If we ever need to embed semicolons inside strings, switch to a
// proper SQL tokenizer.
return strings.Split(s, ";")
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}

View File

@@ -0,0 +1,141 @@
-- 001_init.sql — Dialectic v2 schema (greenfield, replaces Python v1).
-- See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md for the design.
-- Verdict schemas — declared at topic-creation time; judge produces output matching.
CREATE TABLE verdict_schemas (
id VARCHAR(64) NOT NULL PRIMARY KEY,
description TEXT NOT NULL,
shape_json JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Seed v1 schemas.
INSERT INTO verdict_schemas (id, description, shape_json) VALUES
('binary', 'pro|con|draw with confidence + key reasoning', JSON_OBJECT('decision', 'pro|con|draw', 'confidence', 'number 0..1', 'key_reasoning', 'string')),
('claim-resolution', 'analyze-intel contested-cluster resolution', JSON_OBJECT('verdict', 'resolved-toward-A|resolved-toward-B|irreducibly-contested', 'winning_claim', 'string', 'dissenting_points', 'array of string', 'confidence', 'number 0..1')),
('policy-recommendation', 'recommended action with alternatives and risks', JSON_OBJECT('recommended_action', 'string', 'alternatives', 'array of string', 'conditions_for_alternatives', 'array of string', 'risks_noted', 'array of string')),
('free-form', 'unstructured summary escape hatch', JSON_OBJECT('summary', 'string'));
-- Topics (议题) — the unit of debate.
CREATE TABLE topics (
id CHAR(36) NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
summary TEXT NOT NULL,
visibility ENUM('public','private') NOT NULL DEFAULT 'private',
verdict_schema_id VARCHAR(64) NOT NULL,
status ENUM('created','signup_open','signup_closed','debating','completed','cancelled') NOT NULL DEFAULT 'created',
-- Lifecycle timestamps (per section 3 of design doc)
signup_open_at TIMESTAMP NOT NULL,
signup_close_at TIMESTAMP NOT NULL,
debate_start_at TIMESTAMP NOT NULL,
debate_end_at TIMESTAMP NOT NULL,
-- Audit
creator_user_id CHAR(36) NOT NULL,
visibility_changed_by CHAR(36) NULL,
visibility_changed_at TIMESTAMP NULL,
cancelled_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_topics_status (status, signup_open_at),
INDEX idx_topics_visibility (visibility, created_at),
CONSTRAINT fk_topics_schema FOREIGN KEY (verdict_schema_id) REFERENCES verdict_schemas(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Signups: an agent volunteers for one or more camps on a topic.
-- willing_camps is a JSON array of camp names (subset of {pro, con, judge}).
-- (agent_id, topic_id) is unique — re-signup updates willing_camps.
CREATE TABLE signups (
id CHAR(36) NOT NULL PRIMARY KEY,
topic_id CHAR(36) NOT NULL,
agent_id VARCHAR(64) NOT NULL,
willing_camps JSON NOT NULL,
-- Pre-validation result captured at signup time (plugin verifies the
-- agent has an on_call slot covering the debate window; backend
-- records what the agent told it for audit).
pre_validated BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_signups (topic_id, agent_id),
CONSTRAINT fk_signups_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Camps: the post-allocation assignment. One row per (topic, camp) with
-- the locked-in agent. Written by camp-allocation algorithm at
-- signup_close_at; immutable afterwards (no drop-out / replacement in v1).
CREATE TABLE camps (
id CHAR(36) NOT NULL PRIMARY KEY,
topic_id CHAR(36) NOT NULL,
camp ENUM('pro','con','judge') NOT NULL,
agent_id VARCHAR(64) NOT NULL,
allocated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_camps (topic_id, camp),
INDEX idx_camps_agent (agent_id),
CONSTRAINT fk_camps_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Rounds: chronological partition of arguments. Each topic has N rounds
-- (typically 3-5); round 0 is the opening. Round transitions are driven
-- by the orchestrator on a schedule (or all-participants-posted).
CREATE TABLE rounds (
id CHAR(36) NOT NULL PRIMARY KEY,
topic_id CHAR(36) NOT NULL,
round_no INT NOT NULL,
opened_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
UNIQUE KEY uq_rounds (topic_id, round_no),
CONSTRAINT fk_rounds_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Arguments: an individual contribution within a round by a camp's agent.
-- For pro/con these are claims/rebuttals; for judge these are clarifying
-- questions (judge is silent observer in v1 except for clarifications).
CREATE TABLE arguments (
id CHAR(36) NOT NULL PRIMARY KEY,
topic_id CHAR(36) NOT NULL,
round_id CHAR(36) NOT NULL,
camp ENUM('pro','con','judge') NOT NULL,
agent_id VARCHAR(64) NOT NULL,
content MEDIUMTEXT NOT NULL,
posted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_arguments_round (round_id, posted_at),
INDEX idx_arguments_topic (topic_id, posted_at),
CONSTRAINT fk_arguments_round FOREIGN KEY (round_id) REFERENCES rounds(id) ON DELETE CASCADE,
CONSTRAINT fk_arguments_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Verdicts: judge's structured output, one per topic (one verdict per
-- debate). verdict_json shape matches the topic's verdict_schema_id.
CREATE TABLE verdicts (
id CHAR(36) NOT NULL PRIMARY KEY,
topic_id CHAR(36) NOT NULL UNIQUE,
judge_agent_id VARCHAR(64) NOT NULL,
verdict_json JSON NOT NULL,
rationale TEXT NOT NULL,
-- Token cost trail for accounting (Phase 1: not enforced; Phase N: budget gate)
tokens_input INT NOT NULL DEFAULT 0,
tokens_output INT NOT NULL DEFAULT 0,
produced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_verdicts_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Agent API keys: provisioned per agent at recruitment time. Stored as
-- sha256(pepper || raw); pepper rotation invalidates all keys.
CREATE TABLE agent_keys (
agent_id VARCHAR(64) NOT NULL PRIMARY KEY,
key_hash CHAR(64) NOT NULL UNIQUE,
issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
INDEX idx_agent_keys_hash (key_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- System keys: out-of-band credentials for non-agent callers (e.g. the
-- analyze-intel workflow running via a system identity that creates
-- topics on behalf of the analyzing agent). Also stored as hash.
CREATE TABLE system_keys (
name VARCHAR(64) NOT NULL PRIMARY KEY,
key_hash CHAR(64) NOT NULL UNIQUE,
description TEXT NULL,
issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP NULL,
INDEX idx_system_keys_hash (key_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View 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),
})
}

View 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
}

View 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
View 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)
}

37
internal/models/signup.go Normal file
View File

@@ -0,0 +1,37 @@
package models
import "time"
type Signup struct {
ID string `db:"id" json:"id"`
TopicID string `db:"topic_id" json:"topic_id"`
AgentID string `db:"agent_id" json:"agent_id"`
WillingCamps []byte `db:"willing_camps" json:"-"` // JSON column; surface as typed via View()
PreValidated bool `db:"pre_validated" json:"pre_validated"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// SignupView is the JSON-friendly projection that decodes WillingCamps.
type SignupView struct {
ID string `json:"id"`
TopicID string `json:"topic_id"`
AgentID string `json:"agent_id"`
WillingCamps []Camp `json:"willing_camps"`
PreValidated bool `json:"pre_validated"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Signup) View() (SignupView, error) {
var camps SignupCampsJSON
if err := camps.UnmarshalDB(s.WillingCamps); err != nil {
return SignupView{}, err
}
return SignupView{
ID: s.ID,
TopicID: s.TopicID,
AgentID: s.AgentID,
WillingCamps: camps,
PreValidated: s.PreValidated,
CreatedAt: s.CreatedAt,
}, nil
}

78
internal/models/topic.go Normal file
View File

@@ -0,0 +1,78 @@
package models
import (
"encoding/json"
"time"
)
type Visibility string
const (
VisibilityPublic Visibility = "public"
VisibilityPrivate Visibility = "private"
)
type TopicStatus string
const (
TopicStatusCreated TopicStatus = "created"
TopicStatusSignupOpen TopicStatus = "signup_open"
TopicStatusSignupClosed TopicStatus = "signup_closed"
TopicStatusDebating TopicStatus = "debating"
TopicStatusCompleted TopicStatus = "completed"
TopicStatusCancelled TopicStatus = "cancelled"
)
type Camp string
const (
CampPro Camp = "pro"
CampCon Camp = "con"
CampJudge Camp = "judge"
)
// AllCamps is the canonical iteration order used by the allocation algorithm.
var AllCamps = [3]Camp{CampPro, CampCon, CampJudge}
type Topic struct {
ID string `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Summary string `db:"summary" json:"summary"`
Visibility Visibility `db:"visibility" json:"visibility"`
VerdictSchemaID string `db:"verdict_schema_id" json:"verdict_schema_id"`
Status TopicStatus `db:"status" json:"status"`
SignupOpenAt time.Time `db:"signup_open_at" json:"signup_open_at"`
SignupCloseAt time.Time `db:"signup_close_at" json:"signup_close_at"`
DebateStartAt time.Time `db:"debate_start_at" json:"debate_start_at"`
DebateEndAt time.Time `db:"debate_end_at" json:"debate_end_at"`
CreatorUserID string `db:"creator_user_id" json:"creator_user_id"`
VisibilityChangedBy *string `db:"visibility_changed_by" json:"visibility_changed_by,omitempty"`
VisibilityChangedAt *time.Time `db:"visibility_changed_at" json:"visibility_changed_at,omitempty"`
CancelledReason *string `db:"cancelled_reason" json:"cancelled_reason,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// IsCampValid returns true iff c is one of pro|con|judge.
func IsCampValid(c Camp) bool {
for _, k := range AllCamps {
if k == c {
return true
}
}
return false
}
// SignupCampsJSON is a typed wrapper around the JSON-stored willing_camps
// column. We marshal/unmarshal at the boundary so handlers can work with
// the typed slice.
type SignupCampsJSON []Camp
func (s SignupCampsJSON) Marshal() ([]byte, error) { return json.Marshal(s) }
func (s *SignupCampsJSON) UnmarshalDB(raw []byte) error {
if len(raw) == 0 {
*s = nil
return nil
}
return json.Unmarshal(raw, s)
}

View File

@@ -0,0 +1,95 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
)
type SignupStore struct {
db *sqlx.DB
}
func NewSignupStore(db *sqlx.DB) *SignupStore { return &SignupStore{db: db} }
type UpsertSignupInput struct {
TopicID string
AgentID string
WillingCamps []models.Camp
PreValidated bool
}
// Upsert creates or updates an agent's signup for a topic. Re-signup
// replaces willing_camps (intentional: lets an agent change their mind
// before signup_close_at).
func (s *SignupStore) Upsert(ctx context.Context, in UpsertSignupInput) (*models.SignupView, error) {
if len(in.WillingCamps) == 0 {
return nil, fmt.Errorf("willing_camps must be non-empty")
}
for _, c := range in.WillingCamps {
if !models.IsCampValid(c) {
return nil, fmt.Errorf("invalid camp %q", c)
}
}
raw, err := json.Marshal(in.WillingCamps)
if err != nil {
return nil, err
}
// Try insert; on duplicate (topic, agent), update.
id := uuid.NewString()
_, err = s.db.ExecContext(ctx, `
INSERT INTO signups (id, topic_id, agent_id, willing_camps, pre_validated)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
willing_camps = VALUES(willing_camps),
pre_validated = VALUES(pre_validated)`,
id, in.TopicID, in.AgentID, raw, in.PreValidated)
if err != nil {
return nil, fmt.Errorf("upsert signup: %w", err)
}
return s.GetByPair(ctx, in.TopicID, in.AgentID)
}
func (s *SignupStore) GetByPair(ctx context.Context, topicID, agentID string) (*models.SignupView, error) {
var row models.Signup
err := s.db.GetContext(ctx, &row,
`SELECT * FROM signups WHERE topic_id = ? AND agent_id = ?`, topicID, agentID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
v, err := row.View()
if err != nil {
return nil, err
}
return &v, nil
}
// ListByTopic returns all signups for a topic. Used by the allocation
// algorithm at signup_close_at and by the topic-detail UI.
func (s *SignupStore) ListByTopic(ctx context.Context, topicID string) ([]models.SignupView, error) {
var rows []models.Signup
if err := s.db.SelectContext(ctx, &rows,
`SELECT * FROM signups WHERE topic_id = ? ORDER BY created_at ASC`, topicID); err != nil {
return nil, err
}
out := make([]models.SignupView, 0, len(rows))
for _, r := range rows {
v, err := r.View()
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}

View File

@@ -0,0 +1,106 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
)
var ErrNotFound = errors.New("not found")
type TopicStore struct {
db *sqlx.DB
}
func NewTopicStore(db *sqlx.DB) *TopicStore { return &TopicStore{db: db} }
type CreateTopicInput struct {
Title string
Summary string
Visibility models.Visibility
VerdictSchemaID string
SignupOpenAt string // RFC3339; parsed by SQL
SignupCloseAt string
DebateStartAt string
DebateEndAt string
CreatorUserID string
}
func (s *TopicStore) Create(ctx context.Context, in CreateTopicInput) (*models.Topic, error) {
id := uuid.NewString()
_, err := s.db.ExecContext(ctx, `
INSERT INTO topics (id, title, summary, visibility, verdict_schema_id,
signup_open_at, signup_close_at, debate_start_at, debate_end_at, creator_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, in.Title, in.Summary, in.Visibility, in.VerdictSchemaID,
in.SignupOpenAt, in.SignupCloseAt, in.DebateStartAt, in.DebateEndAt, in.CreatorUserID)
if err != nil {
return nil, fmt.Errorf("insert topic: %w", err)
}
return s.GetByID(ctx, id)
}
func (s *TopicStore) GetByID(ctx context.Context, id string) (*models.Topic, error) {
var t models.Topic
err := s.db.GetContext(ctx, &t, `SELECT * FROM topics WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &t, nil
}
type ListFilter struct {
Status string // empty = all
Visibility string // empty = all
Limit int // 0 = default 50
Offset int
}
func (s *TopicStore) List(ctx context.Context, f ListFilter) ([]models.Topic, error) {
if f.Limit <= 0 || f.Limit > 200 {
f.Limit = 50
}
q := "SELECT * FROM topics"
args := []any{}
var clauses []string
if f.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, f.Status)
}
if f.Visibility != "" {
clauses = append(clauses, "visibility = ?")
args = append(args, f.Visibility)
}
if len(clauses) > 0 {
q += " WHERE " + strings.Join(clauses, " AND ")
}
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, f.Limit, f.Offset)
var rows []models.Topic
if err := s.db.SelectContext(ctx, &rows, q, args...); err != nil {
return nil, err
}
return rows, nil
}
// SetVisibility flips public/private; records who/when. Returns updated row.
func (s *TopicStore) SetVisibility(ctx context.Context, id string, v models.Visibility, byUserID string) (*models.Topic, error) {
_, err := s.db.ExecContext(ctx, `
UPDATE topics SET visibility = ?, visibility_changed_by = ?, visibility_changed_at = CURRENT_TIMESTAMP
WHERE id = ?`, v, byUserID, id)
if err != nil {
return nil, err
}
return s.GetByID(ctx, id)
}