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) 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) verdictH := handlers.NewVerdictHandler(topicStore, campStore, verdictStore) 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) }