// Package gateway implements the Cyrene v2 HTTP gateway: the only socket the // single binary opens. It serves the versioned REST API (/api/v1), the realtime // WebSocket hub (/ws/chat) and the RBAC-protected admin API (/api/v1/admin/*) // all on one port (:8080). // // It is deliberately lean. All backend capability flows through injected // dependencies (construction-time dependency injection) and the in-process bus, // never through cross-process IPC, so the package never opens a listener of its // own beyond the gateway port. // // Client contract (DevDocs/02-development-constraints.md §九, §十一): // - REST responses are {data} on success and {error:{code,message}} on error. // - Authentication is JWT bearer (internal/auth); authorization is the // owner>admin>viewer RBAC hierarchy (internal/auth). // - Admin writes are audited (who/what/when/result). // - Realtime events follow DevDocs/design/07 §3 (WS protocol). package gateway import ( "context" "fmt" "io" "io/fs" "log/slog" "net" "net/http" "strconv" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) // source is the log provenance tag for every record this package emits. const source = "gateway" // Authenticator abstracts the credential + RBAC service. *auth.JWTService is the // production implementation; it is an interface so tests substitute a mock. type Authenticator interface { // Sign issues a token for sub with the given role and lifetime. Sign(sub string, role auth.Role, ttl time.Duration) (string, error) // Verify validates a token and returns its claims. Verify(token string) (*auth.Claims, error) // Authorize returns an HTTP middleware enforcing at least one required role. Authorize(required ...auth.Role) func(http.Handler) http.Handler } // CapabilityStore abstracts the global capability registry used to enumerate // plugins and channels in the admin API. *capability.Registry is the production // implementation. type CapabilityStore interface { // List returns every capability whose Kind matches, or all when kind is // empty. List(kind capreg.Kind) []capreg.Capability // SetPolicy replaces the reachability markers for the named capability. SetPolicy(name string, p capreg.ToolPolicy) error } // Chatter runs a single agent turn. *loop.Loop is the production implementation. type Chatter interface { // Run drives one turn for sessionID and returns the final answer. Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) } // SessionStore lists known channel sessions. *session.SessionManager is the // production implementation. type SessionStore interface { List(ctx context.Context) ([]session.Session, error) } // PersonaStore lists registered characters (each bound one-to-one to a persona). // *persona/registry.Registry is the production implementation. The gateway maps // each Character to its wire summary; the interface deliberately returns the // domain type so the wire shape stays out of the store contract. type PersonaStore interface { List() []personareg.Character // Persona returns the persona with the given id (to read its visual assets). // The production registry implements it; the wire summary reads Avatar / // ChatBackground from it so each role's visuals stay role-scoped. Persona(id string) (*loader.Persona, bool) } // MemoryStore lists memory entries. *runtime/memory.Store is the production // implementation. The gateway maps each MemoryItem to its wire summary. type MemoryStore interface { List(ctx context.Context) ([]memory.MemoryItem, error) } // IoTStore lists IoT devices. *iot.Manager is the production implementation. type IoTStore interface { List(ctx context.Context) ([]iot.Device, error) } // SubagentAuditor audits a sub-agent's conversation context for the subagent_ask // WS route. *orchestration.Scheduler is the production implementation. type SubagentAuditor interface { AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error) } // TaskOutputStore queries a task's output for the task_output_query WS route. // *task.Manager is the production implementation. type TaskOutputStore interface { TaskOutput(ctx context.Context, id string) (task.OutputEvent, error) } // AutomationStore enumerates the automations and reminders for the admin list. // *automations.Service is the production implementation. The gateway maps each // record to its wire shape. type AutomationStore interface { List() []automations.AutomationRecord } // UserStore enumerates the accounts and API keys visible to the admin panel. // The production implementation reads the configured admin account plus the // auth.APIKey table. The gateway maps each summary to its wire shape. type UserStore interface { List() []auth.UserSummary } // BackthinkStore enumerates the persisted background-think records for the // M9/M10 monitor panel. *backthink.Backthink is the production implementation. type BackthinkStore interface { List(ctx context.Context, limit int) ([]backthink.Record, error) } // RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8). // *runtime/knowledge.Store is the production implementation. Every method is // read-only for this milestone: IndexDocument/DeleteDocument return a clear // "write lands in M10" error rather than a fabricated success. type RagStore interface { // ListDocuments enumerates the knowledge-base documents (project-index // entries). ListDocuments(ctx context.Context) ([]knowledge.Doc, error) // Search answers a knowledge-base query (line-level hierarchy + optional // semantic/vector ranking). Search(ctx context.Context, query string) ([]knowledge.Hit, error) // IndexDocument uploads/chunks/embeds a document. M10; returns an error. IndexDocument(ctx context.Context, doc knowledge.Doc) error // DeleteDocument removes a document. M10; returns an error. DeleteDocument(ctx context.Context, id string) error } // Deps carries the gateway's injected dependencies. Every field is nil-tolerant // except Auth, which is required (authentication backs login and every RBAC // decision). Registry/Loop are satisfied by the concrete *capability.Registry and // *loop.Loop; Sessions/Personas/Memories are satisfied by the concrete session // manager, persona registry and memory store, and the handlers return [] only // when a dependency is left nil. type Deps struct { // Log is the base logger. Nil uses a discard logger. Log *slog.Logger // Auth is the JWT/RBAC service. Required. Auth Authenticator // Bus is the in-process event bus the WS hub subscribes to. Optional. Bus bus.Bus // Registry enumerates capabilities/plugins/channels. Optional. Registry CapabilityStore // Loop runs chat turns. Optional (chat returns an error when unset). Loop Chatter // Session lists channel sessions. Optional. Session SessionStore // Personas lists personas. Optional. Personas PersonaStore // Memories lists memory entries. Optional. Memories MemoryStore // IoT lists IoT devices. Optional. IoT IoTStore // Scheduler audits sub-agent context for the subagent_ask WS route. // Optional (subagent_ask returns a clear error when unset). Scheduler SubagentAuditor // Tasks queries task output for the task_output_query WS route. // Optional (task_output_query returns a clear error when unset). Tasks TaskOutputStore // Automations lists automations and reminders. Optional. Automations AutomationStore // Users lists accounts and API keys. Optional (falls back to the configured // single admin account). Users UserStore // Backthink lists background-think records. Optional. Backthink BackthinkStore // RAG backs the admin knowledge-base RAG module. Optional; list/search // return empty when unset, and write endpoints report a clear 501. RAG RagStore // AuditStore persists the admin audit trail (observability → audit). When nil // the gateway degrades to an in-memory store so the endpoint still returns // data; the composition root injects NewSQLiteAuditStore over a.db for real // persistence. Optional. AuditStore AuditStore // Streaming marks the injected Loop as streaming-capable (its OnStream is // wired to publish ws.stream_chunk). When set the hub skips its own single // final stream_chunk emission, since deltas already stream through OnStream. Streaming bool // Frontend is the SPA filesystem served for all non-API, non-WS routes. // When nil the gateway serves no static assets (API and WS only). The // embedded dist (internal/webui) or an external dist directory is used. Frontend fs.FS // Webhooks are externally-mounted inbound HTTP handlers (endpoint path → // handler), typically from channel adapters that receive webhook callbacks // (e.g. webhook). Each is mounted on the ServeMux without gateway auth; the // handler itself enforces its own signature/authorization (webhook HMAC). // Optional. Webhooks map[string]http.Handler } // Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a // WebSocket hub. It is safe for concurrent use once constructed. type Server struct { cfg *config.Config auth Authenticator bus bus.Bus caps CapabilityStore runner Chatter sessions SessionStore personas PersonaStore memories MemoryStore iot IoTStore auditor SubagentAuditor taskOutputs TaskOutputStore automations AutomationStore users UserStore backthink BackthinkStore rag RagStore obs *observability voice VoiceService streaming bool frontend fs.FS // llmMu guards llmExtra, the process-lifetime providers added at runtime via // the admin "add provider" write. They are reflected in GET /admin/llm but are // not bound to the model router (config is immutable after startup), so they // are reported as temporary. llmMu sync.RWMutex llmExtra []AdminLlmProvider webhooks map[string]http.Handler log *slog.Logger hub *hub } // New validates cfg and deps and returns a ready Server. Auth is required; the // WebSocket hub subscribes to deps.Bus immediately so events published before // ListenAndServe are still relayed to clients. func New(cfg *config.Config, deps Deps) (*Server, error) { if cfg == nil { return nil, fmt.Errorf("gateway: nil config") } if deps.Auth == nil { return nil, fmt.Errorf("gateway: auth dependency required") } s := &Server{ cfg: cfg, auth: deps.Auth, bus: deps.Bus, caps: deps.Registry, runner: deps.Loop, sessions: deps.Session, personas: deps.Personas, memories: deps.Memories, iot: deps.IoT, auditor: deps.Scheduler, taskOutputs: deps.Tasks, automations: deps.Automations, users: deps.Users, backthink: deps.Backthink, rag: deps.RAG, obs: newObservability(deps), streaming: deps.Streaming, frontend: deps.Frontend, webhooks: deps.Webhooks, log: sourcedLog(deps.Log), } s.hub = newHub(s) s.hub.orchestrator = newOrchestrator(s) s.hub.start() return s, nil } // 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() // Public, no auth. mux.HandleFunc("GET /api/v1/health", s.handleHealth) mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) // User side, authenticated. Chat is a write (owner/admin); reads accept any // authenticated role so a viewer can still list personas/sessions. mux.Handle("POST /api/v1/chat", s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)(http.HandlerFunc(s.handleChat))) readAuth := s.auth.Authorize() mux.Handle("GET /api/v1/sessions", readAuth(http.HandlerFunc(s.handleListSessions))) mux.Handle("GET /api/v1/personas", readAuth(http.HandlerFunc(s.handleListPersonas))) // Realtime. mux.HandleFunc("/ws/chat", s.handleWS) // Admin: reads require owner or admin; the audited policy write requires owner. adminAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin) mux.Handle("GET /api/v1/admin/personas", adminAuth(http.HandlerFunc(s.adminListPersonas))) mux.Handle("GET /api/v1/admin/sessions", adminAuth(http.HandlerFunc(s.adminListSessions))) mux.Handle("GET /api/v1/admin/memories", adminAuth(http.HandlerFunc(s.adminListMemories))) mux.Handle("GET /api/v1/admin/plugins", adminAuth(http.HandlerFunc(s.adminListPlugins))) mux.Handle("GET /api/v1/admin/channels", adminAuth(http.HandlerFunc(s.adminListChannels))) mux.Handle("GET /api/v1/admin/iot", adminAuth(http.HandlerFunc(s.adminListIoT))) mux.Handle("GET /api/v1/admin/llm", adminAuth(http.HandlerFunc(s.adminGetLlm))) mux.Handle("GET /api/v1/admin/automations", adminAuth(http.HandlerFunc(s.adminListAutomations))) mux.Handle("GET /api/v1/admin/users", adminAuth(http.HandlerFunc(s.adminListUsers))) mux.Handle("GET /api/v1/admin/backthink", adminAuth(http.HandlerFunc(s.adminListBackthink))) mux.Handle("GET /api/v1/admin/rag", adminAuth(http.HandlerFunc(s.adminListRag))) mux.Handle("POST /api/v1/admin/rag/query", adminAuth(http.HandlerFunc(s.adminSearchRag))) mux.Handle("POST /api/v1/admin/rag/delete", adminAuth(http.HandlerFunc(s.adminDeleteRag))) mux.Handle("GET /api/v1/admin/observability/metrics", adminAuth(http.HandlerFunc(s.adminObservabilityMetrics))) mux.Handle("GET /api/v1/admin/observability/trace", adminAuth(http.HandlerFunc(s.adminObservabilityTrace))) mux.Handle("GET /api/v1/admin/observability/audit", adminAuth(http.HandlerFunc(s.adminObservabilityAudit))) mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy))) // Admin writes: owner-only, audited. Reads above accept owner/admin; writes // are reserved for the owner (RBAC red line). Each handler persists to the // real backing store or answers honestly with a clear "M10 / not-wired" error // rather than fabricating a success. ownerAuth := s.auth.Authorize(auth.RoleOwner) 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("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/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch))) mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession))) // External webhook callbacks (e.g. the webhook channel) are mounted without // gateway auth; each handler enforces its own signature/authorization. for path, hdl := range s.webhooks { if path == "" { continue } mux.Handle(path, hdl) } var h http.Handler = mux if s.frontend != nil { h = s.spa(h) } h = s.recover(h) h = s.cors(h) h = s.logging(h) return h } // Start launches the gateway in a goroutine, returning a channel that carries the // ListenAndServe result. The server runs until ctx is canceled. func (s *Server) Start(ctx context.Context) (<-chan error, error) { errCh := make(chan error, 1) go func() { errCh <- s.ListenAndServe(ctx) }() return errCh, nil } // ListenAndServe binds the configured host:port (default :8080) and blocks until // ctx is canceled, then gracefully drains in-flight requests. func (s *Server) ListenAndServe(ctx context.Context) error { if ctx == nil { ctx = context.Background() } addr := net.JoinHostPort(s.cfg.Server.Host, strconv.Itoa(s.cfg.Server.Port)) httpSrv := &http.Server{Addr: addr, Handler: s.Handler()} done := make(chan struct{}) go func() { <-ctx.Done() s.log.Info("gateway shutting down", "addr", addr) s.hub.close() shCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() _ = httpSrv.Shutdown(shCtx) close(done) }() s.log.Info("gateway listening", "addr", addr) err := httpSrv.ListenAndServe() if err != nil && err != http.ErrServerClosed { return fmt.Errorf("gateway: listen: %w", err) } <-done return nil } // Close frees the hub's bus subscription and closes live connections. func (s *Server) Close() { s.hub.close() } // newOrchestrator builds the session-message orchestrator (design/15) bound to // this server's hub. The TurnRunner shares the hub's runTurn (so a turn always // emits the same stream contract), and the StateSink fans per-session state // transitions out through the hub broadcast for the frontend. It is wrapped in a // function so the composition root can later override the injection (an explicit // wiring point rather than a hard-coded construct). func newOrchestrator(s *Server) *Orchestrator { return NewOrchestrator(OrchestratorOptions{ Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) { return s.hub.runTurn(ctx, sessionID, messages, env) }, Emit: func(sessionID, typ string, data map[string]any) { s.hub.broadcast(wsEvent{Type: typ, Data: data}) }, Log: s.log, }) } // sourcedLog returns a logger tagged with source "gateway", substituting a // discard logger for nil so Server can always log safely. func sourcedLog(l *slog.Logger) *slog.Logger { if l == nil { l = slog.New(slog.NewTextHandler(io.Discard, nil)) } return logger.WithSource(l, source) }