fix(http): harden automation API contracts (#36)

This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-20 22:17:13 +07:00
1 parent ce5ea10d46
commit e51fe80212
11 files changed
+458 -21

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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.*`)
+2
View File
@@ -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`
+16
View File
@@ -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
+7
View File
@@ -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
+9 -7
View File
@@ -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 {
+234
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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
}
+32 -8
View File
@@ -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"`
+90
View File
@@ -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 ""
}