From e51fe8021200414658afed3ca74d47310b8969a6 Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Wed, 20 May 2026 22:17:13 +0700 Subject: [PATCH] fix(http): harden automation API contracts (#36) --- cmd/gateway_http_wiring.go | 2 +- docs/18-http-api.md | 18 ++- docs/20-api-keys-auth.md | 2 + docs/project-changelog.md | 16 ++ internal/gateway/server.go | 7 + internal/http/agents.go | 16 +- internal/http/api_contracts_test.go | 234 ++++++++++++++++++++++++++++ internal/http/api_not_found.go | 15 ++ internal/http/memory.go | 39 ++++- internal/http/memory_handlers.go | 40 ++++- internal/http/sessions.go | 90 +++++++++++ 11 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 internal/http/api_contracts_test.go create mode 100644 internal/http/api_not_found.go create mode 100644 internal/http/sessions.go diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index 7ac461a9..94d52245 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -226,7 +226,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( // Memory management API if d.pgStores != nil && d.pgStores.Memory != nil { - d.server.SetMemoryHandler(httpapi.NewMemoryHandler(d.pgStores.Memory)) + d.server.SetMemoryHandler(httpapi.NewMemoryHandler(d.pgStores.Memory, d.pgStores.Agents)) } // Knowledge graph API diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 7b07ccc0..2cb11b98 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -537,9 +537,23 @@ Per-agent vector memory using pgvector. Optional query parameter `?user_id=` for per-user scoping. +`{agentID}` accepts either the agent UUID or `agent_key`; invalid IDs return a structured `INVALID_REQUEST`/`NOT_FOUND` response instead of surfacing storage parse errors. + --- -## 11. Episodic Memory +## 11. Sessions + +Read-only session listing is available over HTTP for automation clients. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/sessions` | List sessions with `agentId`/`agent_id`, `channel`, `limit`, and `offset` filters | + +Admins and system-level admin API keys can list all sessions within the resolved tenant. Non-admin callers must have an effective `X-GoClaw-User-Id` context and are filtered to their own sessions. + +--- + +## 12. Episodic Memory Episodic memory captures conversation summaries per user session for long-term context continuity. @@ -1496,7 +1510,7 @@ Error messages are localized based on the `Accept-Language` header. HTTP status The following operations are **only available via WebSocket RPC**, not HTTP: -- **Sessions:** List, preview, patch, delete, reset (use WebSocket method `sessions.*`) +- **Sessions:** Preview, patch, delete, reset (use WebSocket method `sessions.*`; HTTP supports read-only list) - **Cron jobs:** List, create, update, delete, logs (use WebSocket method `cron.*`) - **Send messages:** Send to channels (use WebSocket method `send.*`) - **Config management:** Get, apply, patch (use WebSocket method `config.*`) diff --git a/docs/20-api-keys-auth.md b/docs/20-api-keys-auth.md index a0b28c42..15234c59 100644 --- a/docs/20-api-keys-auth.md +++ b/docs/20-api-keys-auth.md @@ -165,6 +165,8 @@ On successful API key authentication, `last_used_at` is updated asynchronously ( - **Tenant scope**: `X-GoClaw-Tenant-Id: ` — owner/system-key scope narrowing; non-owner gateway token and browser-pairing callers must already belong to the requested tenant - **Locale**: `Accept-Language` — user's preferred language (en, vi, zh; default: en) +User context is still required for user-scoped read paths. Admin API keys without an `owner_id` can call tenant-scoped admin list endpoints such as `GET /v1/agents` and `GET /v1/sessions` without `X-GoClaw-User-Id`; non-admin keys without an effective user receive a structured `INVALID_REQUEST`. User-bound API keys always force the stored `owner_id` and ignore spoofed user headers. + ### Tenant Scope Rules - **Gateway token + owner user ID**: may narrow to any tenant via `X-GoClaw-Tenant-Id` diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 9384a8b9..ef5732da 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,22 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-20 + +### HTTP API contract hardening + +**Fixes** + +- Fixed memory API `{agentID}` handling so agent keys are resolved before storage access and invalid IDs return structured client errors instead of leaking UUID parse failures as HTTP 500. +- Allowed system/admin API-key automation to list agents and sessions without an extra `X-GoClaw-User-Id` header while preserving user filtering for non-admin callers. +- Added structured `/v1/*` not-found responses and a read-only `GET /v1/sessions` compatibility endpoint for automation clients. + +**Tests** + +- Added regression coverage for memory agent-key resolution, invalid memory IDs, session list auth behavior, and structured API 404s. + +--- + ## 2026-05-18 ### Packages: GitHub installer runtime path diff --git a/internal/gateway/server.go b/internal/gateway/server.go index a0f72cec..e808137b 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -170,6 +170,11 @@ func (s *Server) BuildMux() *http.ServeMux { mux.Handle("/v1/tools/invoke", toolsHandler) } + // Read-only HTTP compatibility for automation clients. + if s.sessions != nil { + httpapi.NewSessionsHandler(s.sessions, s.cfg.Gateway.OwnerIDs).RegisterRoutes(mux) + } + // Register all HTTP API handlers (agents, skills, teams, storage, etc.) for _, h := range s.handlers { if h != nil { @@ -177,6 +182,8 @@ func (s *Server) BuildMux() *http.ServeMux { } } + httpapi.RegisterAPINotFoundRoute(mux) + // MCP bridge: expose GoClaw tools to Claude CLI via streamable-http. // Only listens on localhost (CLI runs on the same machine). // Protected by gateway token; disabled when no token is configured to diff --git a/internal/http/agents.go b/internal/http/agents.go index 44a56fc9..f7e506af 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -185,17 +185,19 @@ func (h *AgentsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) { userID := store.UserIDFromContext(r.Context()) - if userID == "" { - locale := store.LocaleFromContext(r.Context()) - writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader)) - return - } var agents []store.AgentData var err error - if h.isOwnerUser(userID) { + switch { + case userID == "" && permissions.HasMinRole(permissions.Role(store.RoleFromContext(r.Context())), permissions.RoleAdmin): + agents, err = h.agents.List(r.Context(), "") + case userID == "": + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader)) + return + case h.isOwnerUser(userID): agents, err = h.agents.List(r.Context(), "") // owners see all agents - } else { + default: agents, err = h.agents.ListAccessible(r.Context(), userID) } if err != nil { diff --git a/internal/http/api_contracts_test.go b/internal/http/api_contracts_test.go new file mode 100644 index 00000000..863031d2 --- /dev/null +++ b/internal/http/api_contracts_test.go @@ -0,0 +1,234 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func TestMemoryHandler_ResolvesAgentKeyBeforeStore(t *testing.T) { + setupTestToken(t, "") + setupTestNoAuthFallback(t, true) + agentID := uuid.New() + mem := &recordingMemoryStore{} + h := &MemoryHandler{ + store: mem, + agents: &memoryAgentResolver{id: agentID}, + } + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/agents/goclaw/memory/documents", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if mem.listAllAgentID != agentID.String() { + t.Fatalf("agentID passed to store = %q, want %q", mem.listAllAgentID, agentID.String()) + } +} + +func TestMemoryHandler_InvalidAgentIDReturnsStructured400(t *testing.T) { + setupTestToken(t, "") + setupTestNoAuthFallback(t, true) + h := &MemoryHandler{store: &recordingMemoryStore{}} + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/agents/not-a-uuid/memory/documents", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + var body map[string]ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json error envelope: %v", err) + } + if body["error"].Code != protocol.ErrInvalidRequest { + t.Fatalf("error code = %q, want %q", body["error"].Code, protocol.ErrInvalidRequest) + } +} + +func TestSessionsHandler_AdminAPIContextCanListWithoutUserHeader(t *testing.T) { + setupTestToken(t, "") + setupTestNoAuthFallback(t, true) + sessions := &recordingSessionStore{ + result: store.SessionListRichResult{ + Sessions: []store.SessionInfoRich{{SessionInfo: store.SessionInfo{Key: "agent:goclaw:ws:abc"}}}, + Total: 1, + }, + } + mux := http.NewServeMux() + NewSessionsHandler(sessions, nil).RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/sessions?limit=3&offset=1&agentId=goclaw", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if sessions.opts.UserID != "" { + t.Fatalf("UserID filter = %q, want empty for admin context", sessions.opts.UserID) + } + if sessions.opts.AgentID != "goclaw" || sessions.opts.Limit != 3 || sessions.opts.Offset != 1 { + t.Fatalf("opts = %+v", sessions.opts) + } +} + +func TestSessionsHandler_ReadAPIKeyWithoutUserHeaderIsRejected(t *testing.T) { + token := "read-key" + setupTestToken(t, "") + setupTestNoAuthFallback(t, false) + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {ID: uuid.New(), Scopes: []string{"operator.read"}}, + }) + mux := http.NewServeMux() + NewSessionsHandler(&recordingSessionStore{}, nil).RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/sessions", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestRegisterAPINotFoundRoute_UsesStructuredError(t *testing.T) { + mux := http.NewServeMux() + RegisterAPINotFoundRoute(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/tools/custom", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + var body map[string]ErrorResponse + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json error envelope: %v", err) + } + if body["error"].Code != protocol.ErrNotFound { + t.Fatalf("error code = %q, want %q", body["error"].Code, protocol.ErrNotFound) + } +} + +type memoryAgentResolver struct { + id uuid.UUID +} + +func (r *memoryAgentResolver) GetByKey(context.Context, string) (*store.AgentData, error) { + return &store.AgentData{BaseModel: store.BaseModel{ID: r.id}, AgentKey: "goclaw"}, nil +} + +type recordingMemoryStore struct { + listAllAgentID string +} + +func (s *recordingMemoryStore) GetDocument(context.Context, string, string, string) (string, error) { + return "", nil +} +func (s *recordingMemoryStore) PutDocument(context.Context, string, string, string, string) error { + return nil +} +func (s *recordingMemoryStore) DeleteDocument(context.Context, string, string, string) error { + return nil +} +func (s *recordingMemoryStore) ListDocuments(context.Context, string, string) ([]store.DocumentInfo, error) { + return nil, nil +} +func (s *recordingMemoryStore) ListAllDocumentsGlobal(context.Context) ([]store.DocumentInfo, error) { + return nil, nil +} +func (s *recordingMemoryStore) ListAllDocuments(_ context.Context, agentID string) ([]store.DocumentInfo, error) { + s.listAllAgentID = agentID + return []store.DocumentInfo{}, nil +} +func (s *recordingMemoryStore) GetDocumentDetail(context.Context, string, string, string) (*store.DocumentDetail, error) { + return nil, nil +} +func (s *recordingMemoryStore) ListChunks(context.Context, string, string, string) ([]store.ChunkInfo, error) { + return nil, nil +} +func (s *recordingMemoryStore) Search(context.Context, string, string, string, store.MemorySearchOptions) ([]store.MemorySearchResult, error) { + return nil, nil +} +func (s *recordingMemoryStore) IndexDocument(context.Context, string, string, string) error { + return nil +} +func (s *recordingMemoryStore) IndexAll(context.Context, string, string) error { return nil } +func (s *recordingMemoryStore) SetEmbeddingProvider(store.EmbeddingProvider) {} +func (s *recordingMemoryStore) Close() error { return nil } + +type recordingSessionStore struct { + opts store.SessionListOpts + result store.SessionListRichResult +} + +func (s *recordingSessionStore) GetOrCreate(context.Context, string) *store.SessionData { + return nil +} +func (s *recordingSessionStore) Get(context.Context, string) *store.SessionData { return nil } +func (s *recordingSessionStore) AddMessage(context.Context, string, providers.Message) {} +func (s *recordingSessionStore) GetHistory(context.Context, string) []providers.Message { + return nil +} +func (s *recordingSessionStore) GetSummary(context.Context, string) string { return "" } +func (s *recordingSessionStore) SetSummary(context.Context, string, string) {} +func (s *recordingSessionStore) GetLabel(context.Context, string) string { return "" } +func (s *recordingSessionStore) SetLabel(context.Context, string, string) {} +func (s *recordingSessionStore) SetAgentInfo(context.Context, string, uuid.UUID, string) {} +func (s *recordingSessionStore) TruncateHistory(context.Context, string, int) {} +func (s *recordingSessionStore) SetHistory(context.Context, string, []providers.Message) {} +func (s *recordingSessionStore) Reset(context.Context, string) {} +func (s *recordingSessionStore) Delete(context.Context, string) error { return nil } +func (s *recordingSessionStore) Save(context.Context, string) error { return nil } +func (s *recordingSessionStore) UpdateMetadata(context.Context, string, string, string, string) { +} +func (s *recordingSessionStore) AccumulateTokens(context.Context, string, int64, int64) {} +func (s *recordingSessionStore) IncrementCompaction(context.Context, string) {} +func (s *recordingSessionStore) GetCompactionCount(context.Context, string) int { return 0 } +func (s *recordingSessionStore) GetMemoryFlushCompactionCount(context.Context, string) int { + return 0 +} +func (s *recordingSessionStore) SetMemoryFlushDone(context.Context, string) {} +func (s *recordingSessionStore) GetSessionMetadata(context.Context, string) map[string]string { + return nil +} +func (s *recordingSessionStore) SetSessionMetadata(context.Context, string, map[string]string) {} +func (s *recordingSessionStore) SetSpawnInfo(context.Context, string, string, int) {} +func (s *recordingSessionStore) SetContextWindow(context.Context, string, int) {} +func (s *recordingSessionStore) GetContextWindow(context.Context, string) int { return 0 } +func (s *recordingSessionStore) SetLastPromptTokens(context.Context, string, int, int) {} +func (s *recordingSessionStore) GetLastPromptTokens(context.Context, string) (int, int) { + return 0, 0 +} +func (s *recordingSessionStore) List(context.Context, string) []store.SessionInfo { return nil } +func (s *recordingSessionStore) ListPaged(context.Context, store.SessionListOpts) store.SessionListResult { + return store.SessionListResult{} +} +func (s *recordingSessionStore) ListPagedRich(_ context.Context, opts store.SessionListOpts) store.SessionListRichResult { + s.opts = opts + return s.result +} +func (s *recordingSessionStore) LastUsedChannel(context.Context, string) (string, string) { + return "", "" +} + +var _ store.MemoryStore = (*recordingMemoryStore)(nil) +var _ store.SessionStore = (*recordingSessionStore)(nil) diff --git a/internal/http/api_not_found.go b/internal/http/api_not_found.go new file mode 100644 index 00000000..73fe9cd2 --- /dev/null +++ b/internal/http/api_not_found.go @@ -0,0 +1,15 @@ +package http + +import ( + "net/http" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// RegisterAPINotFoundRoute keeps unknown /v1/* paths in the structured API envelope. +func RegisterAPINotFoundRoute(mux *http.ServeMux) { + mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(extractLocale(r), i18n.MsgNotFound, "API route", r.URL.Path)) + }) +} diff --git a/internal/http/memory.go b/internal/http/memory.go index ee59a865..6b3ea4c8 100644 --- a/internal/http/memory.go +++ b/internal/http/memory.go @@ -1,19 +1,32 @@ package http import ( + "context" "net/http" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // MemoryHandler handles memory document management endpoints. type MemoryHandler struct { - store store.MemoryStore + store store.MemoryStore + agents agentKeyResolver } // NewMemoryHandler creates a handler for memory management endpoints. -func NewMemoryHandler(s store.MemoryStore) *MemoryHandler { - return &MemoryHandler{store: s} +func NewMemoryHandler(s store.MemoryStore, agents ...store.AgentStore) *MemoryHandler { + h := &MemoryHandler{store: s} + if len(agents) > 0 { + h.agents = agents[0] + } + return h +} + +type agentKeyResolver interface { + GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) } // RegisterRoutes registers all memory routes on the given mux. @@ -32,3 +45,23 @@ func (h *MemoryHandler) RegisterRoutes(mux *http.ServeMux) { func (h *MemoryHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth("", next) } + +func (h *MemoryHandler) resolveAgentID(w http.ResponseWriter, r *http.Request) (string, bool) { + rawID := r.PathValue("agentID") + if id, err := uuid.Parse(rawID); err == nil { + return id.String(), true + } + + locale := store.LocaleFromContext(r.Context()) + if h.agents == nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent")) + return "", false + } + + agent, err := h.agents.GetByKey(r.Context(), rawID) + if err != nil { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", rawID)) + return "", false + } + return agent.ID.String(), true +} diff --git a/internal/http/memory_handlers.go b/internal/http/memory_handlers.go index c6333231..8b4fe0af 100644 --- a/internal/http/memory_handlers.go +++ b/internal/http/memory_handlers.go @@ -22,7 +22,10 @@ func (h *MemoryHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Re } func (h *MemoryHandler) handleListDocuments(w http.ResponseWriter, r *http.Request) { - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } userID := r.URL.Query().Get("user_id") var docs []store.DocumentInfo @@ -45,7 +48,10 @@ func (h *MemoryHandler) handleListDocuments(w http.ResponseWriter, r *http.Reque func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } path := r.PathValue("path") userID := r.URL.Query().Get("user_id") @@ -60,7 +66,10 @@ func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } path := r.PathValue("path") var body struct { @@ -80,7 +89,10 @@ func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request } func (h *MemoryHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } path := r.PathValue("path") userID := r.URL.Query().Get("user_id") @@ -94,7 +106,10 @@ func (h *MemoryHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Requ func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } path := r.URL.Query().Get("path") userID := r.URL.Query().Get("user_id") @@ -117,7 +132,10 @@ func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request) func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } var body struct { Path string `json:"path"` @@ -141,7 +159,10 @@ func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Reque func (h *MemoryHandler) handleIndexAll(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } var body struct { UserID string `json:"user_id"` @@ -163,7 +184,10 @@ func (h *MemoryHandler) handleIndexAll(w http.ResponseWriter, r *http.Request) { func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - agentID := r.PathValue("agentID") + agentID, ok := h.resolveAgentID(w, r) + if !ok { + return + } var body struct { Query string `json:"query"` diff --git a/internal/http/sessions.go b/internal/http/sessions.go new file mode 100644 index 00000000..17b6b83b --- /dev/null +++ b/internal/http/sessions.go @@ -0,0 +1,90 @@ +package http + +import ( + "net/http" + "slices" + "strconv" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// SessionsHandler exposes read-only HTTP session discovery for automation. +type SessionsHandler struct { + sessions store.SessionStore + ownerIDs []string +} + +func NewSessionsHandler(s store.SessionStore, ownerIDs []string) *SessionsHandler { + return &SessionsHandler{sessions: s, ownerIDs: ownerIDs} +} + +func (h *SessionsHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/sessions", requireAuth("", h.handleList)) +} + +func (h *SessionsHandler) handleList(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + userID := store.UserIDFromContext(r.Context()) + role := permissions.Role(store.RoleFromContext(r.Context())) + + limit := parsePositiveInt(r.URL.Query().Get("limit"), 20) + offset := parseNonNegativeInt(r.URL.Query().Get("offset"), 0) + opts := store.SessionListOpts{ + AgentID: firstSessionQueryValue(r.URL.Query().Get("agent_id"), r.URL.Query().Get("agentId")), + Channel: r.URL.Query().Get("channel"), + Limit: limit, + Offset: offset, + TenantID: store.TenantIDFromContext(r.Context()), + } + + if !canSeeAllHTTP(role, h.ownerIDs, userID) { + if userID == "" { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader)) + return + } + opts.UserID = userID + } + + result := h.sessions.ListPagedRich(r.Context(), opts) + writeJSON(w, http.StatusOK, map[string]any{ + "sessions": result.Sessions, + "total": result.Total, + "limit": limit, + "offset": offset, + }) +} + +func canSeeAllHTTP(role permissions.Role, ownerIDs []string, userID string) bool { + if permissions.HasMinRole(role, permissions.RoleAdmin) { + return true + } + return userID != "" && slices.Contains(ownerIDs, userID) +} + +func parsePositiveInt(raw string, fallback int) int { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return fallback + } + return n +} + +func parseNonNegativeInt(raw string, fallback int) int { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return fallback + } + return n +} + +func firstSessionQueryValue(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +}