- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed) - 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引) - gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken - config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration - rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh - 14测试+真实二进制e2e, build/vet/test绿+交叉编译 - Docs/api-client v0.25 + api-reference §2.3/2.4 同步
658 lines
33 KiB
Go
658 lines
33 KiB
Go
// 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"
|
|
"strings"
|
|
"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/history"
|
|
"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)
|
|
}
|
|
|
|
// HistoryStore is the persistent chat-history store (design/16). *runtime/history.Store
|
|
// is the production implementation. When nil the session/message endpoints degrade: list
|
|
// sessions falls back to the channel-session view, and the create/messages/delete
|
|
// endpoints answer with a clear 501.
|
|
type HistoryStore interface {
|
|
Append(ctx context.Context, msg history.Message) error
|
|
// AppendFull persists msg like Append but returns the complete stored message
|
|
// (with the assigned id/timestamp) so the orchestrator can broadcast it for
|
|
// cross-end realtime sync (design/16 §4.2).
|
|
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
|
|
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
|
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
|
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
|
|
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
|
|
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
|
|
DeleteSession(ctx context.Context, sessionID string) error
|
|
// RenameSession sets a session's display title (main or sub). An unknown id
|
|
// returns history.ErrSessionNotFound.
|
|
RenameSession(ctx context.Context, sessionID, title string) error
|
|
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
|
ListSessions(ctx context.Context) ([]history.SessionMeta, 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
|
|
}
|
|
|
|
// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the
|
|
// production implementation). It is the credential behind the refreshToken
|
|
// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only
|
|
// hashes are persisted; the plaintext token crosses this interface exactly once
|
|
// per issue/rotate. Optional: when nil login omits refreshToken and the refresh
|
|
// and logout endpoints report a clear not-configured error.
|
|
type RefreshGrantStore interface {
|
|
// Issue mints a refresh token for userID/role and returns its plaintext
|
|
// (visible once) plus its expiry.
|
|
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
|
|
// Rotate validates plaintext and, when opts.Rotate is set, revokes it and
|
|
// returns a replacement. It returns auth.ErrInvalidGrant for an unknown,
|
|
// revoked, or expired token.
|
|
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
|
|
// Revoke marks the token matching plaintext revoked, returning whether a
|
|
// live token was found. It is idempotent.
|
|
Revoke(ctx context.Context, plaintext string) (bool, error)
|
|
// RevokeAll deletes every refresh token (global invalidation on secret
|
|
// rotation) and returns how many were removed.
|
|
RevokeAll(ctx context.Context) (int64, error)
|
|
// RevokeByUser deletes every refresh token for userID (logout by access
|
|
// token, where the individual grant is unknown) and returns how many were
|
|
// removed.
|
|
RevokeByUser(ctx context.Context, userID string) (int64, error)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ChannelConfigStore is the runtime-editable channel-adapter configuration
|
|
// (channels.json, design/04 §3.1). It is implemented by the composition root's
|
|
// channel store: Load returns the effective per-platform accounts (file → env
|
|
// fallback), Path reports the channels.json path, and Reload hot-replaces a
|
|
// platform's running adapters after a panel save (Bridge.Reload). Load returns
|
|
// account configs with real tokens so the owner write path can persist them; the
|
|
// admin read path masks them before returning to the client.
|
|
type ChannelConfigStore interface {
|
|
Path() string
|
|
// Load returns the effective per-platform accounts (file → env fallback),
|
|
// including the secret tokens needed to persist them.
|
|
Load() map[string][]config.ChannelAccount
|
|
// Reload rebuilds and hot-replaces the running adapters for platform.
|
|
Reload(platform string) error
|
|
}
|
|
|
|
// OwnerRegistrar registers a channel account id into the owner whitelist
|
|
// (configs/users/owner.yaml). It is the structural identity red line: a scan-login
|
|
// confirmation stamps the scanner's bound ilink_user_id into the whitelist so the
|
|
// channel bridge recognizes it as the owner via actor metadata, never a prompt.
|
|
// The composition root implements it (cmd/cyrene's ownerRegistrar) to persist the
|
|
// whitelist and hot-update the live owner profile. Registration is best-effort:
|
|
// a failure is logged by the caller but does not fail the already-applied
|
|
// channels.json persist.
|
|
type OwnerRegistrar interface {
|
|
RegisterChannelOwner(ctx context.Context, platform, accountID string) 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
|
|
// History is the persistent chat-history store (design/16). Optional; when nil the
|
|
// session/message endpoints degrade (list falls back to channel sessions, others 501).
|
|
History HistoryStore
|
|
// 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
|
|
// Refresh is the opaque refresh-token store. Optional: when nil login omits
|
|
// refreshToken and /auth/refresh + /auth/logout answer not-configured.
|
|
Refresh RefreshGrantStore
|
|
// 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
|
|
// LLM is the hot-swappable provider stack (models.json, design/04 §3.1).
|
|
// Optional: when nil the admin /llm endpoints fall back to the env config so
|
|
// pre-existing gateway tests keep working.
|
|
LLM *model.RuntimeLLM
|
|
// Channels is the runtime-editable channel-adapter config (channels.json).
|
|
// Optional: when nil the admin /channels/config endpoints return a clear
|
|
// not-configured error, matching how the LLM stack behaves when unset.
|
|
Channels ChannelConfigStore
|
|
// OwnerRegistrar stamps a scan-login's bound owner id into the owner
|
|
// whitelist (configs/users/owner.yaml) and hot-updates the live profile.
|
|
// Optional: when nil the weixin confirm flow still persists channels.json but
|
|
// skips the whitelist registration (best-effort by design).
|
|
OwnerRegistrar OwnerRegistrar
|
|
// 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
|
|
// DatabasePath is the resolved path of the SQLite database file the
|
|
// composition root actually opened (the value derived by resolveSQLitePath,
|
|
// which may differ from cfg.DB.SQLitePath when the data dir default applies).
|
|
// It backs the admin backup/restore endpoints. Optional; when empty those
|
|
// endpoints answer honestly with a clear not-configured error.
|
|
DatabasePath string
|
|
// DataDir is the resolved data directory (the value resolveDataDir returns,
|
|
// which may differ from cfg.DataDir when the OS default applies). It backs
|
|
// the image-upload store (<DataDir>/uploads) and its static route. Optional;
|
|
// when empty the upload handlers fall back to cfg.DataDir.
|
|
DataDir string
|
|
// 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
|
|
history HistoryStore
|
|
iot IoTStore
|
|
auditor SubagentAuditor
|
|
taskOutputs TaskOutputStore
|
|
automations AutomationStore
|
|
users UserStore
|
|
refresh RefreshGrantStore
|
|
backthink BackthinkStore
|
|
rag RagStore
|
|
obs *observability
|
|
voice VoiceService
|
|
streaming bool
|
|
frontend fs.FS
|
|
dbPath string
|
|
// dataDir is the resolved data directory backing the upload store
|
|
// (<dataDir>/uploads). Injected by the composition root; cfg.DataDir is the
|
|
// fallback. See Deps.DataDir.
|
|
dataDir string
|
|
// llm is the hot-swappable provider stack (models.json). When nil the
|
|
// admin /llm endpoints fall back to the env config. It is optional so tests
|
|
// that do not wire it keep working.
|
|
llm *model.RuntimeLLM
|
|
channels ChannelConfigStore
|
|
// ownerRegistrar stamps a scan-login bound owner id into the owner whitelist
|
|
// (best-effort). See Deps.OwnerRegistrar.
|
|
ownerRegistrar OwnerRegistrar
|
|
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,
|
|
history: deps.History,
|
|
iot: deps.IoT,
|
|
auditor: deps.Scheduler,
|
|
taskOutputs: deps.Tasks,
|
|
automations: deps.Automations,
|
|
users: deps.Users,
|
|
refresh: deps.Refresh,
|
|
backthink: deps.Backthink,
|
|
rag: deps.RAG,
|
|
obs: newObservability(deps),
|
|
streaming: deps.Streaming,
|
|
frontend: deps.Frontend,
|
|
llm: deps.LLM,
|
|
channels: deps.Channels,
|
|
ownerRegistrar: deps.OwnerRegistrar,
|
|
webhooks: deps.Webhooks,
|
|
dbPath: deps.DatabasePath,
|
|
dataDir: deps.DataDir,
|
|
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)
|
|
// 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.
|
|
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
|
|
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
|
|
|
// 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)))
|
|
|
|
// Audio (voice) STT/TTS. Owner-only (the owner's private voice assistant),
|
|
// audited. These are the REST surface for native clients to upload audio and
|
|
// fetch synthesized speech, bypassing the 4KB WebSocket frame limit.
|
|
mux.Handle("POST /api/v1/audio/transcribe", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleTranscribe)))
|
|
mux.Handle("POST /api/v1/audio/speech", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleSpeech)))
|
|
mux.Handle("POST /api/v1/audio/voice-clone", s.auth.Authorize(auth.RoleOwner)(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 below is a
|
|
// capability URL (unguessable name) because chat.image / <img> cannot carry a
|
|
// bearer token. Both ride the one gateway port (single-binary red line).
|
|
mux.Handle("POST /api/v1/upload", s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)(http.HandlerFunc(s.handleUpload)))
|
|
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
|
|
|
|
// Session/model reads and writes are the owner's private history; owner/admin only
|
|
// (chat uses the same roles). Session metadata in GET /sessions stays readAuth for
|
|
// any authenticated role, but the message content (and thinking) is owner/admin-only
|
|
// per design/16 §6. Creating and deleting a sub session mutate persistent state.
|
|
historyAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
|
|
mux.Handle("GET /api/v1/sessions/{id}/messages", historyAuth(http.HandlerFunc(s.handleListMessages)))
|
|
mux.Handle("POST /api/v1/sessions", historyAuth(http.HandlerFunc(s.handleCreateSubSession)))
|
|
mux.Handle("DELETE /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleDeleteSession)))
|
|
mux.Handle("PATCH /api/v1/sessions/{id}", historyAuth(http.HandlerFunc(s.handleRenameSession)))
|
|
|
|
// 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/channels/messages", adminAuth(http.HandlerFunc(s.handleChannelMessages)))
|
|
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", adminAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
|
|
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("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("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
|
|
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)))
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
// 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, SessionID: sessionID, Data: data})
|
|
},
|
|
OnPersisted: s.BroadcastPersisted,
|
|
Log: s.log,
|
|
History: s.history,
|
|
})
|
|
}
|
|
|
|
// BroadcastPersisted broadcasts a just-persisted chat message as a WS event so
|
|
// every browser of the same account appends it without a manual refresh
|
|
// (design/16 §4.2). It is shared by the orchestrator (Web turns) and the channel
|
|
// bridge (IM messages), so both paths flow through one frame shape. The event type
|
|
// is split by session: the main web session and its web sub-sessions broadcast
|
|
// `message_persisted` for web-chat realtime sync, while any other session id is an
|
|
// isolated channel adapter session (sess-xxx) and broadcasts `channel_persisted`
|
|
// for the IM adapter log page — the web chat has no handler for the latter, so it
|
|
// is naturally ignored there and never pollutes the conversation. Thinking is
|
|
// stripped to stay consistent with the WS history replay (design/16 §6). It is a
|
|
// no-op-safe call when the hub has broadcast wired.
|
|
func (s *Server) BroadcastPersisted(msg history.Message) {
|
|
wire := toHistoryMessage(msg)
|
|
wire.Thinking = ""
|
|
typ := "channel_persisted"
|
|
if msg.SessionID == history.MainSessionID || strings.HasPrefix(msg.SessionID, history.MainSessionID+":") {
|
|
typ = "message_persisted"
|
|
}
|
|
s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: map[string]any{"message": wire}})
|
|
}
|
|
|
|
// wsCapacityAwaken is the WS event type for a capacity state transition
|
|
// (design/14 §5). Its data field is the frontend AwakenPayload {fromState,toState,tool}.
|
|
const wsCapacityAwaken = "capacity_awaken"
|
|
|
|
// BroadcastCapacityAwaken fans a capacity state transition to the Web UI as a
|
|
// `capacity_awaken` frame (design/14 §5). sessionID is the conversation that
|
|
// transitioned, so the client targets the correct session rather than treating the
|
|
// wake event as global. data matches the frontend AwakenPayload {fromState, toState,
|
|
// tool}; tool may be empty (no single tool was lit, the client then keeps the prior
|
|
// tool badge).
|
|
func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) {
|
|
s.hub.broadcast(wsEvent{
|
|
Type: wsCapacityAwaken,
|
|
SessionID: sessionID,
|
|
Data: map[string]any{
|
|
"fromState": fromState,
|
|
"toState": toState,
|
|
"tool": tool,
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|