mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 02:14:43 +00:00
fix(http): harden automation API contracts (#36)
This commit is contained in:
1 parent
ce5ea10d46
commit
e51fe80212
11 files changed
+458
-21
No files matched your search
@@ -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
|
||||
|
||||
+16
-2
@@ -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.*`)
|
||||
|
||||
@@ -165,6 +165,8 @@ On successful API key authentication, `last_used_at` is updated asynchronously (
|
||||
- **Tenant scope**: `X-GoClaw-Tenant-Id: <tenant-uuid-or-slug>` — 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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
+36
-3
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in new issue
Block a user