Files
Dialectic.Backend/internal/httpapi/routes.go
hzhang a43ff2de62 feat: per-topic announce target (move guild+channel from env to topic row)
Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.

Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
                  announce_channel_id     VARCHAR(64)  NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
  topic creator opted out of broadcasts (announcer skips with log).

handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.

fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.

orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.

verdict.go: same per-topic target extraction at completion.

config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").

FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
2026-05-23 17:53:30 +01:00

197 lines
6.8 KiB
Go

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/fabric"
"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)
campStore := store.NewCampStore(db)
roundStore := store.NewRoundStore(db)
argStore := store.NewArgumentStore(db)
verdictStore := store.NewVerdictStore(db)
health := handlers.NewHealthHandler(db, version)
topicsH := handlers.NewTopicsHandler(topicStore)
signupsH := handlers.NewSignupsHandler(topicStore, signupStore)
argsH := handlers.NewArgumentsHandler(topicStore, campStore, roundStore, argStore)
announcer := fabric.NewAnnouncer(cfg.FabricSystemAPIKey)
verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore, announcer)
adminH := handlers.NewAdminHandler(db, cfg.AgentAPIKeyPepper, cfg.DialecticAdminAPIKey)
// 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.Get("/topics/{id}/arguments", argsH.List)
r.Get("/topics/{id}/verdict", verdictH.Get)
})
r.Group(func(r chi.Router) {
r.Use(requireAnyAuth)
r.Post("/topics", topicsH.Create)
r.Put("/topics/{id}/visibility", topicsH.SetVisibility)
})
// Signups, arguments, verdict POST: agent-only.
r.Group(func(r chi.Router) {
r.Use(requireAgent)
r.Post("/topics/{id}/signups", signupsH.Create)
r.Post("/topics/{id}/arguments", argsH.Post)
r.Post("/topics/{id}/verdict", verdictH.Submit)
})
// List signups: any authenticated caller.
r.Group(func(r chi.Router) {
r.Use(requireAnyAuth)
r.Get("/topics/{id}/signups", signupsH.List)
})
// Admin: provision an agent api key. Auth is its own header
// (x-dialectic-admin-key against env DIALECTIC_ADMIN_API_KEY),
// not bearer — admin lifecycle is separate from agent identity.
r.Post("/admin/agent-keys", adminH.ProvisionAgentKey)
})
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)
}