package gateway import ( "net/http" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) // This file owns the HTTP surface: the route table (REST /api/v1, the realtime // WebSocket hub, /api/v1/admin/* and externally-mounted webhook callbacks) and // the outer middleware chain. Route registration is split by audience so the // RBAC gate guarding each group stays visible: public, authenticated user, // private history, admin reads and owner-only writes. // Handler returns the fully assembled http.Handler: the ServeMux routing // REST + WS + admin, wrapped in recovery, CORS and request logging. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // The RBAC middlewares are built once per role set and shared by every route // that requires it (owner>admin>viewer, DevDocs/02 §九). viewerAuth is "every // authenticated role": it names the read-only monitoring surface the viewer // role exists for (module inventories, runtime metrics/traces, the log tail // and the notification feed) — see registerAdminRoutes. anyAuth := s.auth.Authorize() ownerAuth := s.auth.Authorize(auth.RoleOwner) writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin) viewerAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer) s.registerPublicRoutes(mux) s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth) s.registerHistoryRoutes(mux, writeAuth) s.registerAdminRoutes(mux, ownerAuth, writeAuth, viewerAuth) s.mountWebhooks(mux) return s.withMiddleware(mux) } // registerPublicRoutes mounts the unauthenticated surface: liveness plus the // credential endpoints. Refresh/logout are public by design — the opaque refresh // token is itself the credential, so a client with an expired access token can // still rotate or revoke it. Both fail closed with 401 invalid_grant on a bad // token. func (s *Server) registerPublicRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/health", s.handleHealth) mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh) mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) } // registerUserRoutes mounts the authenticated user-facing surface. Chat is a // write (owner/admin); reads accept any authenticated role so a viewer can still // list personas/sessions. Audio STT/TTS and image upload are owner/admin-only (the // owner's private assistant, audited) and are the REST surface native clients use // to bypass the 4KB WebSocket frame limit. func (s *Server) registerUserRoutes( mux *http.ServeMux, anyAuth func(http.Handler) http.Handler, ownerAuth func(http.Handler) http.Handler, writeAuth func(http.Handler) http.Handler, ) { mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat))) mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions))) mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas))) // Shared client enum tables (Android collaboration B4, design/07 §6): the // authority every client maps ids to labels from, so a source badge or a mood // label cannot drift per client. A read-only projection of // internal/contract/enums with nothing sensitive in it, so any authenticated // role may read it (same gate as /sessions above). mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums))) mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe))) mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech))) mux.Handle("POST /api/v1/audio/voice-clone", ownerAuth(http.HandlerFunc(s.handleVoiceClone))) // Image upload (B1): a multipart POST that stores a raster image under the // resolved data dir and returns a same-origin URL usable directly as // chat.image. Upload is owner/admin + audited; the static route is a // capability URL (unguessable name) because chat.image / cannot carry a // bearer token. Both ride the one gateway port (single-binary red line). mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload))) mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile) } // registerHistoryRoutes mounts the persistent chat-history surface (design/16) // plus the realtime endpoint. Session/model reads and writes are the owner's // private history, so owner/admin only (chat uses the same roles); the message // content (and thinking) is owner/admin-only per design/16 §6, while the session // metadata in GET /sessions above stays readable by any authenticated role. // Creating and deleting a sub session mutate persistent state. func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.Handler) http.Handler) { mux.Handle("GET /api/v1/sessions/{id}/messages", writeAuth(http.HandlerFunc(s.handleListMessages))) mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession))) mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession))) mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession))) // Read cursor (D4 / Android collaboration B3): advancing it clears the session's // unread badge on every end of the account, so it is an owner/admin history write // like the other session mutations. It broadcasts a `session_read` frame. mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead))) // Single-message edit/delete (Android collaboration batch ③a, D3). A message is // addressed by its own opaque id; both are owner/admin-gated history writes. mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage))) mux.Handle("DELETE /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handleDeleteMessage))) // Realtime. mux.HandleFunc("/ws/chat", s.handleWS) } // registerAdminRoutes mounts the admin API. Three read tiers plus two write // tiers, mirroring the role hierarchy (RBAC red line): // // - viewerAuth (owner/admin/viewer): the read-only monitoring surface — module // inventories, runtime metrics/traces, the backend log tail and the // notification feed. Every payload here is a non-sensitive projection with no // credential, no other account's data and no message body, which is what // makes the viewer role a usable dashboard/logs/monitoring account. // - writeAuth reads (owner/admin): everything a viewer must not see — memories, // the LLM provider stack, accounts/API keys, the backthink monitor, the audit // trail, channel message logs/transcripts, full session export. // - writeAuth writes (owner/admin): the audited knowledge search. // - ownerAuth (owner): the audited policy write, every credential-acquiring // channel endpoint (login-qr/login-status) and every other mutation. // // Each write handler persists to the real backing store or answers honestly with // a clear "M10 / not-wired" error rather than fabricating a success. func (s *Server) registerAdminRoutes( mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler, writeAuth func(http.Handler) http.Handler, viewerAuth func(http.Handler) http.Handler, ) { // Admin reads open to viewer: the read-only monitoring/inventory surface. mux.Handle("GET /api/v1/admin/personas", viewerAuth(http.HandlerFunc(s.adminListPersonas))) mux.Handle("GET /api/v1/admin/sessions", viewerAuth(http.HandlerFunc(s.adminListSessions))) mux.Handle("GET /api/v1/admin/plugins", viewerAuth(http.HandlerFunc(s.adminListPlugins))) mux.Handle("GET /api/v1/admin/plugins/installed", viewerAuth(http.HandlerFunc(s.adminListInstalledPlugins))) mux.Handle("GET /api/v1/admin/channels", viewerAuth(http.HandlerFunc(s.adminListChannels))) mux.Handle("GET /api/v1/admin/iot", viewerAuth(http.HandlerFunc(s.adminListIoT))) mux.Handle("GET /api/v1/admin/automations", viewerAuth(http.HandlerFunc(s.adminListAutomations))) // Knowledge-base document list: names/descriptions plus chunk/embedding // status. The `?query=` search rides along here and stays read-only. mux.Handle("GET /api/v1/admin/rag", viewerAuth(http.HandlerFunc(s.adminListRag))) mux.Handle("GET /api/v1/admin/observability/metrics", viewerAuth(http.HandlerFunc(s.adminObservabilityMetrics))) mux.Handle("GET /api/v1/admin/observability/trace", viewerAuth(http.HandlerFunc(s.adminObservabilityTrace))) // Backend log tail: the logger's own bounded ring buffer, injected by the // composition root (501 until then). Newest first, `?level=`/`?limit=` filtered. mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs))) // Unified notification feed (design/10 §3 #16): a read-only projection of the // audit trail + backthink pushes, so the panel's notification page needs no // new persisted model. mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications))) // Admin reads reserved for owner/admin: private content, other accounts' // data and the audit trail. Not open to viewer. mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories))) mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages))) mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog))) mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm))) mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers))) mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink))) mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit))) mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession))) mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag))) mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy))) // Admin writes: owner-only, audited. mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona))) mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona))) mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser))) mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey))) mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey))) mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl))) mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario))) mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule))) mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation))) mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation))) mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation))) mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider))) mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm))) mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen))) mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose))) mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest))) mux.Handle("GET /api/v1/admin/channels/{platform}/login-qr", ownerAuth(http.HandlerFunc(s.adminWeixinLoginQR))) mux.Handle("GET /api/v1/admin/channels/{platform}/login-status", ownerAuth(http.HandlerFunc(s.adminWeixinLoginStatus))) mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView))) mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends))) mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle))) mux.Handle("POST /api/v1/admin/channels/{platform}/message/test", ownerAuth(http.HandlerFunc(s.adminChannelMessageTest))) mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig))) mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount))) mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall))) mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall))) mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory))) mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch))) mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag))) mux.Handle("POST /api/v1/admin/rag/delete", ownerAuth(http.HandlerFunc(s.adminDeleteRag))) mux.Handle("POST /api/v1/admin/system/revoke-keys", ownerAuth(http.HandlerFunc(s.adminRevokeAllKeys))) mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT))) mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup))) mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore))) } // mountWebhooks mounts externally-supplied inbound callbacks (e.g. the webhook // channel) without gateway auth; each handler enforces its own // signature/authorization. func (s *Server) mountWebhooks(mux *http.ServeMux) { for path, hdl := range s.webhooks { if path == "" { continue } mux.Handle(path, hdl) } } // withMiddleware wraps the routed handler in the outer layers, outermost first: // the upload capability-URL guard, the SPA fallback (when a frontend is // configured), panic recovery, CORS and request logging. func (s *Server) withMiddleware(mux http.Handler) http.Handler { var h http.Handler = mux h = guardUploadPaths(h) if s.frontend != nil { h = s.spa(h) } h = s.recover(h) h = s.cors(h) h = s.logging(h) return h }