diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go
deleted file mode 100644
index 9e80842..0000000
--- a/server/handlers/integration_test.go
+++ /dev/null
@@ -1,5194 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/golang-jwt/jwt/v5"
- "github.com/google/uuid"
-
- "switchboard-core/config"
- authpkg "switchboard-core/auth"
- "switchboard-core/database"
- "switchboard-core/middleware"
- "switchboard-core/models"
- "switchboard-core/roles"
- "switchboard-core/store"
- postgres "switchboard-core/store/postgres"
- sqlite "switchboard-core/store/sqlite"
- "switchboard-core/treepath"
-)
-
-// ── Test Harness ────────────────────────────
-
-// dialectSQL converts Postgres-style $N placeholders to ? for SQLite,
-// strips ::jsonb casts, and converts boolean literals to integers.
-// Allows raw SQL in tests to work on both backends.
-func dialectSQL(q string) string {
- if !database.IsSQLite() {
- return q
- }
- result := q
- // Replace high-to-low to avoid $1 matching inside $10
- for i := 20; i >= 1; i-- {
- result = strings.ReplaceAll(result, fmt.Sprintf("$%d", i), "?")
- }
- result = strings.ReplaceAll(result, "::jsonb", "")
- result = strings.ReplaceAll(result, "::text", "")
- // Boolean literals: true/false → 1/0 (only bare keywords, not string 'true')
- result = strings.ReplaceAll(result, "= true", "= 1")
- result = strings.ReplaceAll(result, "= false", "= 0")
- result = strings.ReplaceAll(result, ", true)", ", 1)")
- result = strings.ReplaceAll(result, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
- // Time functions
- result = strings.ReplaceAll(result, "NOW()", "datetime('now')")
- // NULL sort
- result = strings.ReplaceAll(result, "NULLS LAST", "")
- return result
-}
-
-// seedID returns a new UUID for use in test seed data.
-func seedID() string {
- return uuid.New().String()
-}
-
-// seedInsertReturningID executes an INSERT with RETURNING id on Postgres,
-// or injects a generated UUID id on SQLite and returns it.
-func seedInsertReturningID(t *testing.T, query string, args ...interface{}) string {
- t.Helper()
- if !database.IsSQLite() {
- var id string
- err := database.TestDB.QueryRow(query, args...).Scan(&id)
- if err != nil {
- t.Fatalf("seedInsertReturningID: %v", err)
- }
- return id
- }
- id := seedID()
- q := dialectSQL(query)
- // Remove RETURNING clause
- if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
- q = strings.TrimSpace(q[:idx])
- }
- // Inject id column
- q = database.InjectIDForTest(q)
- newArgs := append([]interface{}{id}, args...)
- _, err := database.TestDB.Exec(q, newArgs...)
- if err != nil {
- t.Fatalf("seedInsertReturningID: %v", err)
- }
- return id
-}
-
-// seedExec executes an INSERT via dialectSQL; on SQLite it also injects
-// a generated id column and value (first arg) so that TEXT PRIMARY KEY
-// tables that lack a DEFAULT get a proper UUID.
-func seedExec(t *testing.T, query string, args ...interface{}) {
- t.Helper()
- q := dialectSQL(query)
- if database.IsSQLite() {
- q = database.InjectIDForTest(q)
- args = append([]interface{}{seedID()}, args...)
- }
- _, err := database.TestDB.Exec(q, args...)
- if err != nil {
- t.Fatalf("seedExec: %v\n query: %s", err, q)
- }
-}
-
-const testJWTSecret = "test-secret-key-for-integration-tests"
-
-type testHarness struct {
- router *gin.Engine
- t *testing.T
-}
-
-// setupHarness creates a full API router backed by the test database.
-// Call database.RequireTestDB(t) + database.TruncateAll(t) before using.
-func setupHarness(t *testing.T) *testHarness {
- t.Helper()
- database.RequireTestDB(t)
- database.TruncateAll(t)
-
- cfg := &config.Config{
- JWTSecret: testJWTSecret,
- BasePath: "",
- }
-
- var stores store.Stores
- if database.IsSQLite() {
- stores = sqlite.NewStores(database.TestDB)
- } else {
- stores = postgres.NewStores(database.TestDB)
- }
- treepath.Stores = &stores
- userCache := middleware.NewUserStatusCache()
-
- // Roles resolver (nil vault — test-fire won't work, but CRUD will)
- roleResolver := roles.NewResolver(stores, nil)
-
- r := gin.New()
- api := r.Group("/api/v1")
-
- // Auth (unprotected)
- auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
- authGroup := api.Group("/auth")
- authGroup.POST("/register", auth.Register)
- authGroup.POST("/login", auth.Login)
-
- // Public settings
- adm := NewAdminHandler(stores, nil, nil, nil)
- api.GET("/settings/public", adm.PublicSettings)
-
- // Protected routes
- protected := api.Group("")
- protected.Use(middleware.Auth(cfg, stores.Users, userCache))
-
- // Models
- models := NewModelHandler(stores)
- protected.GET("/models/enabled", models.ListEnabledModels)
-
- // Model prefs
- modelPrefs := NewModelPrefsHandler(stores)
- protected.GET("/models/preferences", modelPrefs.GetPreferences)
- protected.PUT("/models/preferences", modelPrefs.SetPreference)
- protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
-
- // User providers
- provCfg := NewProviderConfigHandler(stores, nil)
- protected.GET("/api-configs", provCfg.ListConfigs)
- protected.POST("/api-configs", provCfg.CreateConfig)
- protected.GET("/api-configs/:id", provCfg.GetConfig)
- protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
- protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
- protected.GET("/api-configs/:id/models", provCfg.ListModels)
- protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
-
- // Team self-service (same route group as production)
- teams := NewTeamHandler(stores, nil)
- protected.GET("/teams/mine", teams.MyTeams)
-
- teamScoped := protected.Group("/teams/:teamId")
- teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams))
- {
- // Team members (team admin self-service)
- teamScoped.GET("/members", teams.ListMembers)
- teamScoped.POST("/members", teams.AddMember)
- teamScoped.PUT("/members/:memberId", teams.UpdateMember)
- teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
-
- // Team providers
- teamScoped.GET("/providers", teams.ListTeamProviders)
- teamScoped.POST("/providers", teams.CreateTeamProvider)
- teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
- teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
- teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
-
- // Team audit
- teamScoped.GET("/audit", teams.ListTeamAuditLog)
- teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
-
- // Team groups
- teamGroupH := NewGroupHandler(stores)
- teamScoped.GET("/groups", teamGroupH.ListTeamGroups)
-
- // Team usage
- teamUsage := NewUsageHandler(stores)
- teamScoped.GET("/usage", teamUsage.TeamUsage)
-
- // Team roles
- teamRoles := NewRolesHandler(stores, roleResolver)
- teamScoped.GET("/roles", teamRoles.ListTeamRoles)
- teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
- teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
-
- // Team tasks — admin CRUD (v0.28.0-audit)
- teamTaskH := NewTaskHandler(stores)
- teamScoped.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
- teamScoped.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Update)
- teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.Delete)
- teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.RunNow)
- teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), teamTaskH.KillRun)
-
- // Team personas (v0.28.0-audit)
- teamPersonas := NewPersonaHandler(stores)
- teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
- teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
- teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
- teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
- teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs)
- teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs)
- teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants)
- teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants)
- teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar)
- teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar)
- }
-
- // Team task viewing for all members (v0.28.0-audit)
- // NOTE: Uses RequireTeamMember which has raw $1 placeholders — admin
- // users bypass the DB query so these tests work on both dialects when
- // the caller is an admin. Non-admin member tests require Postgres.
- teamMemberRoutes := protected.Group("/teams/:teamId")
- teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
- {
- teamMemberTaskH := NewTaskHandler(stores)
- teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
- teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
- }
-
- // User usage
- usage := NewUsageHandler(stores)
- protected.GET("/usage", usage.PersonalUsage)
-
- // Profile permissions (v0.37.1)
- permH := NewProfilePermissionsHandler(stores)
- protected.GET("/profile/permissions", permH.GetMyPermissions)
-
- // Boot payload (v0.37.15)
- bootH := NewProfileBootstrapHandler(stores)
- protected.GET("/profile/bootstrap", bootH.GetBootstrap)
-
- // Profile / Settings
- settings := NewSettingsHandler(stores, nil)
- protected.GET("/profile", settings.GetProfile)
- protected.PUT("/profile", settings.UpdateProfile)
- protected.POST("/profile/password", settings.ChangePassword)
- protected.POST("/profile/avatar", settings.UploadAvatar)
- protected.DELETE("/profile/avatar", settings.DeleteAvatar)
- protected.GET("/settings", settings.GetSettings)
- protected.PUT("/settings", settings.UpdateSettings)
-
- // Personas
- personas := NewPersonaHandler(stores)
- protected.GET("/personas", personas.ListUserPersonas)
- protected.POST("/personas", middleware.RequirePermission(authpkg.PermPersonaCreate, stores), personas.CreateUserPersona)
- protected.PUT("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.UpdateUserPersona)
- protected.DELETE("/personas/:id", middleware.RequirePermission(authpkg.PermPersonaManage, stores), personas.DeleteUserPersona)
- protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
- protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
- protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
- protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
- protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
- protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
-
- // Persona Groups
- pgH := NewPersonaGroupHandler(stores)
- protected.GET("/persona-groups", pgH.List)
- protected.POST("/persona-groups", pgH.Create)
- protected.GET("/persona-groups/:id", pgH.Get)
- protected.PUT("/persona-groups/:id", pgH.Update)
- protected.DELETE("/persona-groups/:id", pgH.Delete)
- protected.POST("/persona-groups/:id/members", pgH.AddMember)
- protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
-
- // Notes
- notes := NewNoteHandler(stores)
- protected.GET("/notes", notes.List)
- protected.POST("/notes", notes.Create)
- protected.GET("/notes/:id", notes.Get)
- protected.PUT("/notes/:id", notes.Update)
- protected.DELETE("/notes/:id", notes.Delete)
-
- // Channels
- channels := NewChannelHandler(stores)
- protected.GET("/channels", channels.ListChannels)
- protected.POST("/channels", middleware.RequirePermission(authpkg.PermChannelCreate, stores), channels.CreateChannel)
- protected.GET("/channels/:id", channels.GetChannel)
- protected.DELETE("/channels/:id", channels.DeleteChannel)
-
- // Channel participants
- partH := NewParticipantHandler(stores)
- protected.GET("/channels/:id/participants", partH.List)
- protected.POST("/channels/:id/participants", middleware.RequirePermission(authpkg.PermChannelInvite, stores), partH.Add)
-
- // Knowledge Bases (nil storage/ingester/embedder — CRUD works, upload/rebuild return 503)
- kbH := NewKnowledgeBaseHandler(stores, nil, nil, nil)
- protected.POST("/knowledge-bases", middleware.RequirePermission(authpkg.PermKBCreate, stores), kbH.CreateKB)
- protected.GET("/knowledge-bases", kbH.ListKBs)
- protected.GET("/knowledge-bases/:id", kbH.GetKB)
- protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UpdateKB)
- protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteKB)
- protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.UploadDocument)
- protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
- protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
- protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.DeleteDocument)
- protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(authpkg.PermKBRead, stores), kbH.SearchKB)
- protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(authpkg.PermKBWrite, stores), kbH.RebuildKB)
- protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
- protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
- protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
- protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
-
- // Files (nil storage = upload returns 503, but metadata works)
- fileH := NewFileHandler(stores, nil, nil)
- protected.POST("/channels/:id/files", fileH.Upload)
- protected.GET("/channels/:id/files", fileH.ListByChannel)
- protected.GET("/files/:id", fileH.GetMetadata)
- protected.GET("/files/:id/download", fileH.Download)
- protected.DELETE("/files/:id", fileH.Delete)
-
- // Completions
- completions := NewCompletionHandler(nil, stores, nil, nil, nil)
- protected.POST("/chat/completions", middleware.RequirePermission(authpkg.PermModelUse, stores), completions.Complete)
-
- // Messages
- msgs := NewMessageHandler(nil, stores, nil, nil)
- protected.GET("/channels/:id/messages", msgs.ListMessages)
- protected.POST("/channels/:id/messages", msgs.CreateMessage)
- protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
- protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
- protected.GET("/channels/:id/path", msgs.GetActivePath)
-
- // Workflows (v0.37.1 — perm enforcement)
- wfH := NewWorkflowHandler(stores)
- protected.POST("/workflows", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Create)
- protected.GET("/workflows", wfH.List)
- protected.PATCH("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Update)
- protected.DELETE("/workflows/:id", middleware.RequirePermission(authpkg.PermWorkflowCreate, stores), wfH.Delete)
-
- // Tasks (v0.28.0)
- taskH := NewTaskHandler(stores)
- protected.GET("/tasks", taskH.ListMine)
-
- // Memory (v0.18.0, audit v0.28.0)
- memH := NewMemoryHandler(stores)
- protected.GET("/memories", memH.ListMyMemories)
- protected.PUT("/memories/:id", memH.UpdateMemory)
- protected.DELETE("/memories/:id", memH.DeleteMemory)
- protected.POST("/memories/:id/approve", memH.ApproveMemory)
- protected.POST("/memories/:id/reject", memH.RejectMemory)
- protected.GET("/memories/count", memH.MemoryCount)
-
- protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create)
- protected.GET("/tasks/:id", taskH.Get)
- protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update)
- protected.DELETE("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Delete)
- protected.GET("/tasks/:id/runs", taskH.ListRuns)
- protected.POST("/tasks/:id/run", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.RunNow)
- protected.POST("/tasks/:id/kill", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.KillRun)
-
- // Webhook trigger (unauthenticated, token-based)
- triggerH := NewTriggerHandler(stores)
- api.POST("/hooks/t/:token", triggerH.Handle)
-
- // Admin routes
- admin := api.Group("/admin")
- admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin())
- admin.GET("/users", adm.ListUsers)
- admin.POST("/users", adm.CreateUser)
- admin.PUT("/users/:id/active", adm.ToggleUserActive)
- admin.GET("/settings", adm.ListGlobalSettings)
- admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
- admin.GET("/configs", adm.ListGlobalConfigs)
- admin.POST("/configs", adm.CreateGlobalConfig)
- admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
- admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
- admin.GET("/models", adm.ListModelConfigs)
- admin.PUT("/models/:id", adm.UpdateModelConfig)
- admin.PUT("/models/bulk", adm.BulkUpdateModels)
- admin.POST("/models/fetch", adm.FetchModels)
- admin.GET("/teams", teams.ListTeams)
- admin.POST("/teams", teams.CreateTeam)
- admin.GET("/teams/:id", teams.GetTeam)
- admin.PUT("/teams/:id", teams.UpdateTeam)
- admin.DELETE("/teams/:id", teams.DeleteTeam)
- admin.GET("/teams/:id/members", teams.ListMembers)
- admin.POST("/teams/:id/members", teams.AddMember)
- admin.PUT("/teams/:id/members/:memberId", teams.UpdateMember)
- admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
- admin.GET("/personas", personas.ListAdminPersonas)
- admin.POST("/personas", personas.CreateAdminPersona)
- admin.PUT("/personas/:id", personas.UpdateAdminPersona)
- admin.DELETE("/personas/:id", personas.DeleteAdminPersona)
- admin.POST("/personas/:id/avatar", func(c *gin.Context) { UploadPersonaAvatar(stores.Personas, c) })
- admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { DeletePersonaAvatar(stores.Personas, c) })
- admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
- admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
- admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
- admin.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
-
- // Admin groups (v0.16.0)
- groupAdm := NewGroupHandler(stores)
- admin.GET("/groups", groupAdm.ListGroups)
- admin.POST("/groups", groupAdm.CreateGroup)
- admin.GET("/groups/:id", groupAdm.GetGroup)
- admin.PUT("/groups/:id", groupAdm.UpdateGroup)
- admin.DELETE("/groups/:id", groupAdm.DeleteGroup)
- admin.GET("/groups/:id/members", groupAdm.ListMembers)
- admin.POST("/groups/:id/members", groupAdm.AddMember)
- admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)
-
- // Admin resource grants (v0.16.0)
- admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
- admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
- admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
-
- // Admin permissions (v0.16.0)
- admin.GET("/permissions", groupAdm.ListPermissions)
- admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)
-
- // User groups (v0.16.0)
- protected.GET("/groups/mine", groupAdm.MyGroups)
-
- // Admin roles
- rolesH := NewRolesHandler(stores, roleResolver)
- admin.GET("/roles", rolesH.ListRoles)
- admin.GET("/roles/:role", rolesH.GetRole)
- admin.PUT("/roles/:role", rolesH.UpdateRole)
-
- // Admin usage + pricing
- usageH := NewUsageHandler(stores)
- admin.GET("/usage", usageH.AdminUsage)
- admin.GET("/usage/users/:id", usageH.AdminUserUsage)
- admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
- admin.GET("/pricing", usageH.ListPricing)
- admin.PUT("/pricing", usageH.UpsertPricing)
- admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
-
- // Admin tasks
- taskAdm := NewTaskHandler(stores)
- admin.GET("/tasks", taskAdm.ListAll)
- admin.POST("/tasks/:id/run", taskAdm.RunNow)
- admin.POST("/tasks/:id/kill", taskAdm.KillRun)
- admin.DELETE("/tasks/:id", taskAdm.Delete)
-
- // Admin memory review
- adminMemH := NewMemoryHandler(stores)
- admin.GET("/memories/pending", adminMemH.ListPendingReview)
- admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
-
- return &testHarness{router: r, t: t}
-}
-
-// makeToken generates a valid JWT for a test user.
-func makeToken(userID, email, role string) string {
- claims := Claims{
- UserID: userID,
- Email: email,
- Role: role,
- RegisteredClaims: jwt.RegisteredClaims{
- IssuedAt: jwt.NewNumericDate(time.Now()),
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
- Issuer: "switchboard-core",
- },
- }
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- s, _ := token.SignedString([]byte(testJWTSecret))
- return s
-}
-
-// request makes an HTTP request and returns the response recorder.
-func (h *testHarness) request(method, path, token string, body interface{}) *httptest.ResponseRecorder {
- h.t.Helper()
- var reader io.Reader
- if body != nil {
- b, _ := json.Marshal(body)
- reader = bytes.NewReader(b)
- }
- req := httptest.NewRequest(method, path, reader)
- req.Header.Set("Content-Type", "application/json")
- if token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
- w := httptest.NewRecorder()
- h.router.ServeHTTP(w, req)
- return w
-}
-
-// decode JSON response into target.
-func decode(w *httptest.ResponseRecorder, target interface{}) error {
- return json.Unmarshal(w.Body.Bytes(), target)
-}
-
-// ── Seed Helpers ────────────────────────────
-
-// registerUser registers a user via the API and returns the user_id + token.
-func (h *testHarness) registerUser(username, email, password string) (userID, token string) {
- h.t.Helper()
- w := h.request("POST", "/api/v1/auth/register", "", map[string]string{
- "username": username, "email": email, "password": password,
- })
- if w.Code != http.StatusCreated && w.Code != http.StatusOK {
- h.t.Fatalf("register %s: want 200/201, got %d: %s", username, w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- token, _ = resp["access_token"].(string)
- if token == "" {
- h.t.Fatalf("register %s: no access_token in response (user inactive? default_user_active policy may not have committed): %s",
- username, w.Body.String())
- }
-
- // Extract user_id from profile
- w2 := h.request("GET", "/api/v1/profile", token, nil)
- var profile map[string]interface{}
- decode(w2, &profile)
- userID, _ = profile["id"].(string)
- return userID, token
-}
-
-// createAdminUser seeds an admin directly in DB, returns user_id + token.
-func (h *testHarness) createAdminUser(username, email string) (userID, token string) {
- h.t.Helper()
- userID = database.SeedTestUser(h.t, username, email)
- // Make admin
- database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
- token = makeToken(userID, email, "admin")
- return
-}
-
-// ═══════════════════════════════════════════
-// TESTS
-// ═══════════════════════════════════════════
-
-// ── 1. Auth ─────────────────────────────────
-
-func TestIntegration_Auth_Register(t *testing.T) {
- h := setupHarness(t)
-
- // Enable registration policy
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- w := h.request("POST", "/api/v1/auth/register", "", map[string]string{
- "username": "alice", "email": "alice@test.com", "password": "password123",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("register: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- var resp map[string]interface{}
- decode(w, &resp)
- if resp["access_token"] == nil || resp["access_token"] == "" {
- t.Fatal("register should return access_token")
- }
-}
-
-func TestIntegration_Auth_Login(t *testing.T) {
- h := setupHarness(t)
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- h.registerUser("bob", "bob@test.com", "password123")
-
- w := h.request("POST", "/api/v1/auth/login", "", map[string]string{
- "login": "bob@test.com", "password": "password123",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("login: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- if resp["access_token"] == nil {
- t.Fatal("login should return access_token")
- }
-}
-
-func TestIntegration_Auth_ProfileRequiresToken(t *testing.T) {
- h := setupHarness(t)
- w := h.request("GET", "/api/v1/profile", "", nil)
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("profile without token: want 401, got %d", w.Code)
- }
-}
-
-// ── 2. Admin Users ──────────────────────────
-
-func TestIntegration_AdminListUsers(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // page/per_page support
- w := h.request("GET", "/api/v1/admin/users?page=1&per_page=50", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin list users: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- users, ok := resp["data"].([]interface{})
- if !ok {
- t.Fatalf("response must have 'data' array, got %T", resp["data"])
- }
- if len(users) < 1 {
- t.Fatal("should have at least 1 user (admin)")
- }
- total, ok := resp["total"].(float64)
- if !ok || total < 1 {
- t.Fatalf("response must have 'total' >= 1, got %v", resp["total"])
- }
-}
-
-func TestIntegration_AdminListUsers_NonAdmin403(t *testing.T) {
- h := setupHarness(t)
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("user1", "user1@test.com", "password123")
-
- w := h.request("GET", "/api/v1/admin/users?page=1&per_page=50", userToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("non-admin list users: want 403, got %d", w.Code)
- }
-}
-
-// ── 3. Admin Settings / Policies ────────────
-
-func TestIntegration_AdminPolicyRoundtrip(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- policies := []struct {
- key string
- value string
- }{
- {"allow_registration", "false"},
- {"allow_user_byok", "true"},
- {"allow_user_personas", "true"},
- {"default_user_active", "true"},
- }
-
- for _, p := range policies {
- w := h.request("PUT", "/api/v1/admin/settings/"+p.key, adminToken,
- map[string]interface{}{"value": p.value})
- if w.Code != http.StatusOK {
- t.Fatalf("set policy %s: want 200, got %d: %s", p.key, w.Code, w.Body.String())
- }
- }
-
- // Verify via ListGlobalSettings
- w := h.request("GET", "/api/v1/admin/settings", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get settings: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- respPolicies, ok := resp["policies"].(map[string]interface{})
- if !ok {
- t.Fatalf("settings response must have 'policies' map, got %v", resp)
- }
- if respPolicies["allow_user_byok"] != "true" {
- t.Errorf("allow_user_byok: want 'true', got %v", respPolicies["allow_user_byok"])
- }
- if respPolicies["allow_user_personas"] != "true" {
- t.Errorf("allow_user_personas: want 'true', got %v", respPolicies["allow_user_personas"])
- }
-}
-
-func TestIntegration_PublicSettingsExposePolicies(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Set a policy
- h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "true"})
-
- // Public settings (no auth required)
- w := h.request("GET", "/api/v1/settings/public", "", nil)
- if w.Code != http.StatusOK {
- t.Fatalf("public settings: want 200, got %d", w.Code)
- }
- var resp map[string]interface{}
- decode(w, &resp)
- policies, ok := resp["policies"].(map[string]interface{})
- if !ok {
- t.Fatalf("public settings must have 'policies', got %v", resp)
- }
- if policies["allow_user_byok"] != "true" {
- t.Errorf("public allow_user_byok: want 'true', got %v", policies["allow_user_byok"])
- }
- if policies["allow_user_personas"] == nil {
- t.Error("public settings should expose allow_user_personas")
- }
-}
-
-// ── 4. Provider Configs (Admin) ──────────────
-
-func TestIntegration_AdminProviderConfigCRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "Test OpenAI", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test123",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- configID := created["id"].(string)
-
- // ── Verify API key is actually stored in DB ──
- var storedKey string
- err := database.TestDB.QueryRow(
- dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
- ).Scan(&storedKey)
- if err != nil {
- t.Fatalf("query stored key: %v", err)
- }
- if storedKey != "sk-test123" {
- t.Fatalf("API key not stored: want 'sk-test123', got %q", storedKey)
- }
-
- // List
- w = h.request("GET", "/api/v1/admin/configs", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list configs: want 200, got %d", w.Code)
- }
-
- // ── Update API key via PUT ──
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/configs/%s", configID), adminToken,
- map[string]interface{}{"api_key": "sk-updated456"})
- if w.Code != http.StatusOK {
- t.Fatalf("update config: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify the key was actually updated
- err = database.TestDB.QueryRow(
- dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
- ).Scan(&storedKey)
- if err != nil {
- t.Fatalf("query updated key: %v", err)
- }
- if storedKey != "sk-updated456" {
- t.Fatalf("API key not updated: want 'sk-updated456', got %q", storedKey)
- }
-
- // Delete
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/configs/%s", configID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// TestIntegration_AdminProviderAPIKeyUsedByFetch verifies that the stored API
-// key is actually passed to the provider when fetching models. This catches
-// the json:"-" bug where CreateGlobalConfig silently dropped the API key.
-func TestIntegration_AdminProviderAPIKeyUsedByFetch(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create provider with a deliberate bad key — we expect the fetch to
- // return an auth error FROM the provider, proving the key was sent.
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "KeyTest", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-badkey-for-test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var cfg map[string]interface{}
- decode(w, &cfg)
- configID := cfg["id"].(string)
-
- // Verify key stored in DB
- var storedKey string
- database.TestDB.QueryRow(
- dialectSQL("SELECT api_key_enc FROM provider_configs WHERE id = $1"), configID,
- ).Scan(&storedKey)
- if storedKey != "sk-badkey-for-test" {
- t.Fatalf("key not stored: want 'sk-badkey-for-test', got %q — json:\"-\" bug is back", storedKey)
- }
-
- // Fetch models — this will fail (bad key) but the error message should
- // contain an auth/HTTP error from OpenAI, NOT a DNS or empty-key error.
- // We're not testing OpenAI connectivity here, just that the key reaches
- // the provider layer.
- w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
- map[string]interface{}{"provider_config_id": configID})
-
- // Accept either 502 (single provider fetch fails) or 200 with errors array.
- // The key point: the handler TRIED to call the provider with our key.
- var fetchResp map[string]interface{}
- decode(w, &fetchResp)
-
- if w.Code == http.StatusOK {
- // Multi-provider path returns 200 with errors
- if errs, ok := fetchResp["errors"]; ok {
- errList := errs.([]interface{})
- if len(errList) > 0 {
- errMsg := errList[0].(string)
- t.Logf(" Provider returned error (expected): %s", errMsg)
- }
- }
- } else if w.Code == http.StatusBadGateway {
- // Single-provider path returns 502
- errMsg, _ := fetchResp["error"].(string)
- t.Logf(" Provider returned error (expected): %s", errMsg)
- } else {
- t.Fatalf("fetch models: unexpected status %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── 5. Model Visibility / Resolution ─────────
-
-func TestIntegration_ModelVisibilityResolution(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
- _ = adminID
-
- // Create global provider config
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "TestProvider", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var cfg map[string]interface{}
- decode(w, &cfg)
- configID := cfg["id"].(string)
-
- // Insert a model into catalog directly (simulating fetch)
- seedExec(t, `
- INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
- VALUES ($1, 'gpt-4o', 'GPT-4o', 'disabled')
- `, configID)
-
- // As admin, models/enabled should return empty (model disabled)
- w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var modelsResp map[string]interface{}
- decode(w, &modelsResp)
- modelsList := modelsResp["data"].([]interface{})
- if len(modelsList) != 0 {
- t.Errorf("disabled model should not appear, got %d models", len(modelsList))
- }
-
- // Enable the model
- var catalogID string
- database.TestDB.QueryRow(dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID).Scan(&catalogID)
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken,
- map[string]interface{}{"visibility": "enabled"})
- if w.Code != http.StatusOK {
- t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Now models/enabled should return 1 model
- w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String())
- }
- decode(w, &modelsResp)
- modelsList = modelsResp["data"].([]interface{})
- if len(modelsList) != 1 {
- t.Errorf("enabled model should appear, want 1 got %d", len(modelsList))
- }
-}
-
-// ── 6. Teams ─────────────────────────────────
-
-func TestIntegration_TeamMemberManagement(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create regular user
- userID := database.SeedTestUser(t, "alice", "alice@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
-
- // Create team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "Engineering", "description": "Eng team",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // List users (verify response has 'users' field, not 'data')
- w = h.request("GET", "/api/v1/admin/users?page=1&per_page=200", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list users: want 200, got %d", w.Code)
- }
- var usersResp map[string]interface{}
- decode(w, &usersResp)
- if _, ok := usersResp["data"]; !ok {
- t.Fatal("admin/users response MUST have 'data' key")
- }
- users := usersResp["data"].([]interface{})
- if len(users) < 2 {
- t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users))
- }
-
- // Add member
- w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": userID, "role": "member"})
- if w.Code != http.StatusOK && w.Code != http.StatusCreated {
- t.Fatalf("add member: want 200/201, got %d: %s", w.Code, w.Body.String())
- }
-
- // List members
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list members: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var membersResp map[string]interface{}
- decode(w, &membersResp)
- members := membersResp["data"].([]interface{})
- if len(members) != 1 {
- t.Fatalf("expected 1 member, got %d", len(members))
- }
-
- // Verify the user shows up in teams/mine
- userToken := makeToken(userID, "alice@test.com", "user")
- w = h.request("GET", "/api/v1/teams/mine", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("teams/mine: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── 7. Personas (Policy Gated) ───────────────
-
-func TestIntegration_PersonaCreation_PolicyGated(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
- _ = adminID
-
- // Create a provider config (personas need a valid model reference)
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "TestProvider", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- var cfg map[string]interface{}
- decode(w, &cfg)
- configID := cfg["id"].(string)
-
- // Ensure allow_user_personas = false
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")
-
- // Try to create a persona as admin (role=admin but policy says no)
- w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
- "name": "My Persona", "base_model_id": "gpt-4o",
- "provider_config_id": configID, "system_prompt": "You are helpful",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("persona create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Enable the policy
- h.request("PUT", "/api/v1/admin/settings/allow_user_personas", adminToken,
- map[string]interface{}{"value": "true"})
-
- // Now creation should succeed
- w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
- "name": "My Persona", "base_model_id": "gpt-4o",
- "provider_config_id": configID, "system_prompt": "You are helpful",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("persona create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // List personas
- w = h.request("GET", "/api/v1/personas", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── 8. User Provider BYOK (Policy Gated) ────
-
-func TestIntegration_UserBYOK_PolicyGated(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Ensure allow_user_byok = false
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- _, userToken := h.registerUser("bob", "bob@test.com", "password123")
-
- // Try to create a personal provider — should fail
- w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "MyKey", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-user123",
- })
- if w.Code == http.StatusCreated {
- t.Fatal("BYOK create should be blocked when allow_user_byok=false")
- }
-
- // Enable BYOK
- h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "true"})
-
- // Now should succeed
- w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "MyKey", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-user123",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("BYOK create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- cfgID := created["id"].(string)
-
- // List should only show personal configs, NOT global ones
- w = h.request("GET", "/api/v1/api-configs", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list configs: want 200, got %d", w.Code)
- }
-
- // Update
- w = h.request("PUT", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken,
- map[string]interface{}{"name": "MyKey-Updated"})
- if w.Code != http.StatusOK {
- t.Fatalf("update config: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Delete
- w = h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── 9. Notes CRUD ────────────────────────────
-
-func TestIntegration_NotesCRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create
- w := h.request("POST", "/api/v1/notes", adminToken, map[string]interface{}{
- "title": "Test Note", "content": "Hello world", "folder": "general",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create note: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var note map[string]interface{}
- decode(w, ¬e)
- noteID := note["id"].(string)
-
- // Get
- w = h.request("GET", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get note: want 200, got %d", w.Code)
- }
-
- // Update
- w = h.request("PUT", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken,
- map[string]interface{}{"title": "Updated", "content": "Updated content"})
- if w.Code != http.StatusOK {
- t.Fatalf("update note: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // List
- w = h.request("GET", "/api/v1/notes", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list notes: want 200, got %d", w.Code)
- }
-
- // Delete
- w = h.request("DELETE", fmt.Sprintf("/api/v1/notes/%s", noteID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete note: want 200, got %d", w.Code)
- }
-}
-
-// ── 10. Cross-User Isolation ─────────────────
-
-func TestIntegration_CrossUserIsolation(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- _, userAToken := h.registerUser("alice", "alice@test.com", "password123")
- _, userBToken := h.registerUser("charlie", "charlie@test.com", "password123")
-
- // Alice creates a provider
- w := h.request("POST", "/api/v1/api-configs", userAToken, map[string]interface{}{
- "name": "AliceKey", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-alice",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("alice create config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var aliceCfg map[string]interface{}
- decode(w, &aliceCfg)
- aliceCfgID := aliceCfg["id"].(string)
-
- // Bob should NOT see Alice's provider
- w = h.request("GET", "/api/v1/api-configs", userBToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("bob list configs: want 200, got %d", w.Code)
- }
-
- // Bob should NOT be able to delete Alice's provider
- w = h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", aliceCfgID), userBToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("bob delete alice's config: want 403, got %d", w.Code)
- }
-
- // Admin should NOT see personal providers in admin list (they're in /admin/configs for global only)
- _ = adminToken
-}
-
-// ── 9. Admin Model Fetch → Enable → User Visibility ─────
-
-func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
- _ = adminID
-
- // Create global provider config
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "TestProvider", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var cfg map[string]interface{}
- decode(w, &cfg)
- configID := cfg["id"].(string)
-
- // Insert models directly (simulating successful provider fetch)
- for _, mid := range []string{"gpt-4o", "gpt-4o-mini", "o1-preview"} {
- seedExec(t, `
- INSERT INTO model_catalog (provider_config_id, model_id, display_name,
- capabilities, visibility)
- VALUES ($1, $2, $3, '{"streaming":true,"tool_calling":true}'::jsonb, 'disabled')
- `, configID, mid, mid)
- }
-
- // ── Admin list should show ALL models (including disabled) ──
- w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin list models: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var adminResp map[string]interface{}
- decode(w, &adminResp)
- adminModels := adminResp["data"].([]interface{})
- if len(adminModels) != 3 {
- t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels))
- }
-
- // Verify admin response is non-null array (not {"data": null})
- if adminResp["data"] == nil {
- t.Fatal("admin models must be [] not null — causes frontend fallback chain to break")
- }
-
- // ── User should see 0 models (all disabled) ──
- userID := database.SeedTestUser(t, "testuser", "user@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
- userToken := makeToken(userID, "user@test.com", "user")
- w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("user models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var userResp map[string]interface{}
- decode(w, &userResp)
- userModels := userResp["data"].([]interface{})
- if len(userModels) != 0 {
- t.Errorf("disabled models must not appear for user, got %d", len(userModels))
- }
-
- // Verify user response is non-null array
- if userResp["data"] == nil {
- t.Fatal("user models must be [] not null — causes '📋 Loaded 0 models' to crash")
- }
-
- // ── Admin enables one model ──
- var catalogID string
- database.TestDB.QueryRow(
- dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"),
- configID,
- ).Scan(&catalogID)
-
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/models/%s", catalogID), adminToken,
- map[string]interface{}{"visibility": "enabled"})
- if w.Code != http.StatusOK {
- t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── User should now see 1 model ──
- w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("user models/enabled after enable: want 200, got %d: %s", w.Code, w.Body.String())
- }
- decode(w, &userResp)
- userModels = userResp["data"].([]interface{})
- if len(userModels) != 1 {
- t.Fatalf("user should see 1 enabled model, got %d", len(userModels))
- }
-
- // ── Validate response shape matches frontend contract ──
- m := userModels[0].(map[string]interface{})
-
- // Backend MUST send provider_config_id (Go struct canonical)
- if m["provider_config_id"] == nil || m["provider_config_id"] == "" {
- t.Error("MISSING: provider_config_id — Go struct canonical field")
- }
-
- // Backend MUST send config_id alias (frontend reads this)
- if m["config_id"] == nil || m["config_id"] == "" {
- t.Error("MISSING: config_id — frontend alias, composite IDs will break")
- }
-
- // Alias must match canonical
- if m["config_id"] != m["provider_config_id"] {
- t.Errorf("config_id (%v) must equal provider_config_id (%v)",
- m["config_id"], m["provider_config_id"])
- }
-
- // model_id required for composite ID construction
- if m["model_id"] == nil || m["model_id"] == "" {
- t.Error("MISSING: model_id — required for composite ID")
- }
-
- // source required for persona detection
- if m["source"] == nil || m["source"] == "" {
- t.Error("MISSING: source — frontend uses this to distinguish catalog vs persona")
- }
-
- // provider_name required for display
- if m["provider_name"] == nil || m["provider_name"] == "" {
- t.Error("MISSING: provider_name — frontend model selector display")
- }
-
- // capabilities must be object not null
- if m["capabilities"] == nil {
- t.Error("capabilities must not be null")
- }
-}
-
-
-// ═══════════════════════════════════════════════════
-// USER JOURNEY TESTS — API calls only
-// ═══════════════════════════════════════════════════
-// These tests exercise the ACTUAL user experience.
-// No insertModel(). No insertProvider(). Only API calls.
-//
-// The ONLY raw SQL allowed is labeled [SIMULATED FETCH]
-// to represent what an external provider API would return,
-// since integration tests can't hit real OpenAI/Venice APIs.
-// ═══════════════════════════════════════════════════
-
-// ── Test helpers ──
-
-// getModels calls GET /models/enabled and returns the model list.
-func (h *testHarness) getModels(token string) []interface{} {
- h.t.Helper()
- w := h.request("GET", "/api/v1/models/enabled", token, nil)
- if w.Code != http.StatusOK {
- h.t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- if resp["data"] == nil {
- h.t.Fatal("models response must never be null")
- }
- return resp["data"].([]interface{})
-}
-
-func getModelIDs(models []interface{}) []string {
- ids := make([]string, 0, len(models))
- for _, raw := range models {
- m := raw.(map[string]interface{})
- if mid, ok := m["model_id"].(string); ok {
- ids = append(ids, mid)
- }
- }
- return ids
-}
-
-func hasModel(models []interface{}, modelID string) bool {
- for _, raw := range models {
- m := raw.(map[string]interface{})
- if m["model_id"] == modelID {
- return true
- }
- }
- return false
-}
-
-func hasModelWithScope(models []interface{}, modelID, scope string) bool {
- for _, raw := range models {
- m := raw.(map[string]interface{})
- if m["model_id"] == modelID && m["scope"] == scope {
- return true
- }
- }
- return false
-}
-
-// simulateFetch inserts models into model_catalog as if a provider API returned them.
-// This is the ONLY raw SQL in journey tests — clearly labeled because integration
-// tests cannot hit real external APIs (OpenAI, Venice, etc).
-func simulateFetch(t *testing.T, providerConfigID string, models []string, visibility string) {
- t.Helper()
- for _, modelID := range models {
- seedExec(t, `
- INSERT INTO model_catalog (provider_config_id, model_id, display_name, visibility)
- VALUES ($1, $2, $3, $4)
- `, providerConfigID, modelID, modelID, visibility)
- }
-}
-
-// ── Journey 1: Admin creates provider → user sees models ──
-
-func TestUserJourney_AdminProvider_UserSeesModels(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Regular user — no special role, no team
- userID := database.SeedTestUser(t, "alice", "alice@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
- userToken := makeToken(userID, "alice@test.com", "user")
-
- // Step 1: Admin creates provider via API
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "TestOpenAI", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("admin create config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var cfg map[string]interface{}
- decode(w, &cfg)
- configID := cfg["id"].(string)
-
- // Step 2: [SIMULATED FETCH] — represents POST /admin/models/fetch hitting OpenAI API
- // In production: admin clicks "Fetch Models" → backend calls OpenAI → inserts into catalog
- // In test: we insert directly because we can't call a real API
- simulateFetch(t, configID, []string{"gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"}, "disabled")
-
- // Step 3: User should see 0 models (all disabled)
- userModels := h.getModels(userToken)
- if len(userModels) != 0 {
- t.Fatalf("before admin enables: user should see 0 models, got %d", len(userModels))
- }
-
- // Step 4: Admin enables one model via API
- var catalogID string
- database.TestDB.QueryRow(
- dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), configID,
- ).Scan(&catalogID)
-
- w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
- map[string]interface{}{"visibility": "enabled"})
- if w.Code != http.StatusOK {
- t.Fatalf("admin enable model: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Step 5: User should now see exactly 1 model
- userModels = h.getModels(userToken)
- if len(userModels) != 1 {
- t.Fatalf("after admin enables gpt-4o: user should see 1 model, got %d: %v",
- len(userModels), getModelIDs(userModels))
- }
-
- // Step 6: Verify the model has all required frontend fields
- m := userModels[0].(map[string]interface{})
- if m["model_id"] != "gpt-4o" {
- t.Errorf("expected model_id=gpt-4o, got %v", m["model_id"])
- }
- if m["config_id"] == nil || m["config_id"] == "" {
- t.Error("MISSING config_id — frontend needs this for composite model ID")
- }
- if m["provider_name"] == nil || m["provider_name"] == "" {
- t.Error("MISSING provider_name — frontend model selector display")
- }
- if m["scope"] != "global" {
- t.Errorf("expected scope=global, got %v", m["scope"])
- }
-}
-
-// ── Journey 2: User creates BYOK provider → auto-fetch triggers ──
-//
-// After the fix in apiconfigs.go, CreateConfig now auto-fetches models
-// from the provider API and auto-enables them. In integration tests,
-// the real API isn't reachable so we get a warning — but the provider
-// is still created (201). The Live Venice test validates the real flow.
-
-func TestUserJourney_BYOK_AutoFetchTriggered(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Enable BYOK policy
- h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "true"})
-
- // Regular user
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("bob", "bob@test.com", "password123")
-
- // Step 1: User creates BYOK provider via API
- w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "My OpenAI Key", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-fake-key",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // Step 2: Response should include the provider ID and a fetch result.
- // In integration tests, the real API isn't reachable, so we expect:
- // - id: present (provider was created)
- // - warning: present (fetch failed — no real API in test)
- // - models_fetched: 0 (or absent)
- var created map[string]interface{}
- decode(w, &created)
- cfgID := created["id"].(string)
- if cfgID == "" {
- t.Fatal("provider creation should return an id")
- }
-
- // Warning is expected in integration tests (can't reach real OpenAI API)
- if created["warning"] != nil {
- t.Logf("expected warning in test env: %v", created["warning"])
- }
-
- // Step 3: models/enabled → 0 personal models (fetch failed, catalog empty)
- // This is CORRECT behavior in test env. Live Venice test validates real flow.
- userModels := h.getModels(userToken)
- personalCount := 0
- for _, raw := range userModels {
- m := raw.(map[string]interface{})
- if m["scope"] == "personal" {
- personalCount++
- }
- }
- t.Logf("personal models after auto-fetch (test env, no real API): %d", personalCount)
-}
-
-// ── Journey 3: BYOK models exist in catalog but NULL scan kills them ──
-//
-// Even if we manually populate the catalog for a BYOK provider
-// (simulating what auto-fetch WOULD do), the provider scan fails
-// because model_default is NULL and scanProviders uses bare string.
-//
-// This test FAILS before the provider.go fix, PASSES after.
-
-func TestUserJourney_BYOK_NullScanKillsModels(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Enable BYOK
- h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "true"})
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("alice", "alice@test.com", "password123")
-
- // User creates BYOK provider via API
- // Use unreachable endpoint so auto-fetch fails — simulateFetch controls the catalog.
- w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "My Venice", "provider": "venice",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-alice-key",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create BYOK: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- cfgID := created["id"].(string)
-
- // [SIMULATED FETCH] — what auto-fetch SHOULD do after provider creation
- // Inserts models with visibility='enabled' (BYOK models should be auto-enabled)
- simulateFetch(t, cfgID, []string{"llama-3.3-70b", "deepseek-r1"}, "enabled")
-
- // User calls models/enabled — should see their 2 personal models
- userModels := h.getModels(userToken)
- personalModels := 0
- for _, raw := range userModels {
- m := raw.(map[string]interface{})
- if m["scope"] == "personal" {
- personalModels++
- }
- }
-
- // Before provider.go NULL scan fix: personalModels = 0 (scan crashes, models silently lost)
- // After fix: personalModels = 2
- if personalModels != 2 {
- t.Fatalf("user should see 2 personal BYOK models, got %d\n"+
- " If 0: provider.go scanProviders crashes on NULL model_default\n"+
- " Check: warn: failed to load personal providers: sql: Scan error",
- personalModels)
- }
-
- // Verify the models have correct scope and fields
- if !hasModelWithScope(userModels, "llama-3.3-70b", "personal") {
- t.Error("llama-3.3-70b should appear with scope=personal")
- }
- if !hasModelWithScope(userModels, "deepseek-r1", "personal") {
- t.Error("deepseek-r1 should appear with scope=personal")
- }
-}
-
-// ── Journey 4: Team provider → member sees, non-member doesn't ──
-
-func TestUserJourney_TeamProvider_MemberVsNonMember(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create team members
- aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
- aliceToken := makeToken(aliceID, "alice@test.com", "user")
-
- bobID := database.SeedTestUser(t, "bob", "bob@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
- bobToken := makeToken(bobID, "bob@test.com", "user")
-
- // Step 1: Admin creates team via API
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "Engineering", "description": "Eng team",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team: %d: %s", w.Code, w.Body.String())
- }
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // Step 2: Admin adds alice as team admin, bob is NOT added
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": aliceID, "role": "admin"})
-
- // Step 3: Team admin (alice) creates team provider via self-service API
- w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), aliceToken,
- map[string]interface{}{
- "name": "Team Venice", "provider": "venice",
- "endpoint": "https://api.venice.ai/api/v1", "api_key": "sk-team-key",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team provider: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var prov map[string]interface{}
- decode(w, &prov)
- provID := prov["id"].(string)
-
- // Step 4: [SIMULATED FETCH] — what fetching from Venice API would return
- simulateFetch(t, provID, []string{"llama-3.3-70b", "deepseek-r1"}, "enabled")
-
- // Step 5: Also add a global model so we can verify additive behavior
- gw := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "GlobalOpenAI", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
- })
- var gcfg map[string]interface{}
- decode(gw, &gcfg)
- globalCfgID := gcfg["id"].(string)
- simulateFetch(t, globalCfgID, []string{"gpt-4o"}, "enabled")
-
- // Step 6: Alice (team member) sees global + team models
- aliceModels := h.getModels(aliceToken)
- if !hasModel(aliceModels, "gpt-4o") {
- t.Error("alice should see global model gpt-4o")
- }
- if !hasModel(aliceModels, "llama-3.3-70b") {
- t.Errorf("alice (team member) should see team model llama-3.3-70b, got: %v", getModelIDs(aliceModels))
- }
-
- // Step 7: Bob (NOT in team) sees ONLY global models
- bobModels := h.getModels(bobToken)
- if !hasModel(bobModels, "gpt-4o") {
- t.Error("bob should see global model gpt-4o")
- }
- if hasModel(bobModels, "llama-3.3-70b") {
- t.Error("bob (non-member) should NOT see team model llama-3.3-70b")
- }
- if hasModel(bobModels, "deepseek-r1") {
- t.Error("bob (non-member) should NOT see team model deepseek-r1")
- }
-}
-
-// ── Journey 5: Full 4-actor matrix ──
-//
-// All providers created via API. All assertions via API.
-// Only raw SQL is [SIMULATED FETCH].
-//
-// Actors:
-// platformAdmin — system admin, NOT in any team
-// teamAdmin — team_members.role='admin' in Engineering
-// teamMember — team_members.role='member' in Engineering
-// outsider — regular user, no team
-//
-// Expected:
-// | Actor | Global(en) | Team(en) | BYOK(own) | Total |
-// |--------------|------------|----------|-----------|-------|
-// | platformAdmin| 2 | 0 | 0 | 2 |
-// | teamAdmin | 2 | 1 | 0 | 3 |
-// | teamMember | 2 | 1 | 1 * | 4 |
-// | outsider | 2 | 0 | 1 * | 3 |
-//
-// * BYOK models require provider.go NULL scan fix
-
-func TestUserJourney_FullMatrix(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("platformadmin", "platformadmin@test.com")
- _ = adminID
-
- // Create all actors
- teamAdminID := database.SeedTestUser(t, "teamadmin", "teamadmin@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamadmin@test.com", "user")
-
- teamMemberID := database.SeedTestUser(t, "teammember", "teammember@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamMemberID)
- teamMemberToken := makeToken(teamMemberID, "teammember@test.com", "user")
-
- outsiderID := database.SeedTestUser(t, "outsider", "outsider@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), outsiderID)
- outsiderToken := makeToken(outsiderID, "outsider@test.com", "user")
-
- // ── Setup: Enable BYOK policy ──
- h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "true"})
-
- // ── Setup: Create team via admin API ──
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "Engineering", "description": "Eng team",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team: %d", w.Code)
- }
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // Add teamAdmin and teamMember to team (platformAdmin and outsider are NOT added)
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamMemberID, "role": "member"})
-
- // ── Setup: Global provider via admin API ──
- w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "GlobalOpenAI", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create global config: %d", w.Code)
- }
- var gcfg map[string]interface{}
- decode(w, &gcfg)
- globalCfgID := gcfg["id"].(string)
-
- // [SIMULATED FETCH] for global provider: 2 enabled + 1 disabled
- simulateFetch(t, globalCfgID, []string{"gpt-4o", "gpt-4o-mini"}, "enabled")
- simulateFetch(t, globalCfgID, []string{"gpt-3.5-turbo"}, "disabled")
-
- // ── Setup: Team provider via team admin self-service API ──
- w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken,
- map[string]interface{}{
- "name": "TeamVenice", "provider": "venice",
- "endpoint": "https://api.venice.ai/api/v1", "api_key": "sk-team",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String())
- }
- var tprov map[string]interface{}
- decode(w, &tprov)
- teamProvID := tprov["id"].(string)
-
- // [SIMULATED FETCH] for team provider: 1 enabled + 1 disabled
- simulateFetch(t, teamProvID, []string{"llama-3.3-70b"}, "enabled")
- simulateFetch(t, teamProvID, []string{"deepseek-r1"}, "disabled")
-
- // ── Setup: BYOK providers via user API ──
- // Use unreachable endpoints so auto-fetch fails — simulateFetch controls catalog.
- w = h.request("POST", "/api/v1/api-configs", teamMemberToken, map[string]interface{}{
- "name": "MemberKey", "provider": "openai",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-member",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create member BYOK: %d: %s", w.Code, w.Body.String())
- }
- var memberCfg map[string]interface{}
- decode(w, &memberCfg)
- memberBYOKID := memberCfg["id"].(string)
-
- // [SIMULATED FETCH] for member BYOK
- simulateFetch(t, memberBYOKID, []string{"gpt-4o-member-byok"}, "enabled")
-
- w = h.request("POST", "/api/v1/api-configs", outsiderToken, map[string]interface{}{
- "name": "OutsiderKey", "provider": "venice",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-outsider",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create outsider BYOK: %d: %s", w.Code, w.Body.String())
- }
- var outsiderCfg map[string]interface{}
- decode(w, &outsiderCfg)
- outsiderBYOKID := outsiderCfg["id"].(string)
-
- // [SIMULATED FETCH] for outsider BYOK
- simulateFetch(t, outsiderBYOKID, []string{"llama-outsider-byok"}, "enabled")
-
- // ════════════════════════════════════════════
- // ASSERTIONS — what each actor actually sees
- // ════════════════════════════════════════════
-
- t.Run("platformAdmin_sees_global_only", func(t *testing.T) {
- models := h.getModels(adminToken)
- ids := getModelIDs(models)
- if len(models) != 2 {
- t.Fatalf("platformAdmin: want 2 (global enabled), got %d: %v", len(models), ids)
- }
- if !hasModel(models, "gpt-4o") || !hasModel(models, "gpt-4o-mini") {
- t.Errorf("platformAdmin should see gpt-4o and gpt-4o-mini, got: %v", ids)
- }
- if hasModel(models, "gpt-3.5-turbo") {
- t.Error("platformAdmin should NOT see disabled gpt-3.5-turbo")
- }
- if hasModel(models, "llama-3.3-70b") {
- t.Error("platformAdmin should NOT see team model (not in team)")
- }
- })
-
- t.Run("teamAdmin_sees_global_plus_team", func(t *testing.T) {
- models := h.getModels(teamAdminToken)
- ids := getModelIDs(models)
- if len(models) != 3 {
- t.Fatalf("teamAdmin: want 3 (2 global + 1 team), got %d: %v", len(models), ids)
- }
- if !hasModel(models, "llama-3.3-70b") {
- t.Errorf("teamAdmin should see team model llama-3.3-70b, got: %v", ids)
- }
- if hasModel(models, "deepseek-r1") {
- t.Error("teamAdmin should NOT see disabled team model deepseek-r1")
- }
- })
-
- t.Run("teamMember_sees_global_plus_team_plus_byok", func(t *testing.T) {
- models := h.getModels(teamMemberToken)
- ids := getModelIDs(models)
- if len(models) != 4 {
- t.Fatalf("teamMember: want 4 (2 global + 1 team + 1 BYOK), got %d: %v\n"+
- " If 3: provider.go NULL scan bug is hiding BYOK models\n"+
- " If 2: team provider scan also failing",
- len(models), ids)
- }
- if !hasModelWithScope(models, "gpt-4o-member-byok", "personal") {
- t.Error("teamMember should see own BYOK model with scope=personal")
- }
- if hasModel(models, "llama-outsider-byok") {
- t.Error("teamMember should NOT see outsider's BYOK model")
- }
- })
-
- t.Run("outsider_sees_global_plus_own_byok", func(t *testing.T) {
- models := h.getModels(outsiderToken)
- ids := getModelIDs(models)
- if len(models) != 3 {
- t.Fatalf("outsider: want 3 (2 global + 1 BYOK), got %d: %v\n"+
- " If 2: provider.go NULL scan bug is hiding BYOK models",
- len(models), ids)
- }
- if !hasModelWithScope(models, "llama-outsider-byok", "personal") {
- t.Error("outsider should see own BYOK model with scope=personal")
- }
- if hasModel(models, "llama-3.3-70b") {
- t.Error("outsider should NOT see team model (not in team)")
- }
- if hasModel(models, "gpt-4o-member-byok") {
- t.Error("outsider should NOT see teamMember's BYOK model")
- }
- })
-
- // ── Dynamic state changes ──
-
- t.Run("byok_policy_off_hides_personal_models", func(t *testing.T) {
- h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "false"})
- defer h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
- map[string]interface{}{"value": "true"})
-
- models := h.getModels(teamMemberToken)
- if hasModelWithScope(models, "gpt-4o-member-byok", "personal") {
- t.Error("BYOK off: teamMember should NOT see personal models")
- }
- if len(models) != 3 {
- t.Fatalf("BYOK off: teamMember want 3 (2 global + 1 team), got %d: %v",
- len(models), getModelIDs(models))
- }
-
- models = h.getModels(outsiderToken)
- if len(models) != 2 {
- t.Fatalf("BYOK off: outsider want 2 (global only), got %d: %v",
- len(models), getModelIDs(models))
- }
- })
-
- t.Run("admin_disables_global_model_users_lose_it", func(t *testing.T) {
- var catalogID string
- database.TestDB.QueryRow(
- dialectSQL("SELECT id FROM model_catalog WHERE model_id = 'gpt-4o' AND provider_config_id = $1"), globalCfgID,
- ).Scan(&catalogID)
-
- // Admin disables gpt-4o
- w := h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
- map[string]interface{}{"visibility": "disabled"})
- if w.Code != http.StatusOK {
- t.Fatalf("disable model: %d", w.Code)
- }
- defer func() {
- h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
- map[string]interface{}{"visibility": "enabled"})
- }()
-
- // Everyone loses gpt-4o
- for _, tc := range []struct {
- name string
- token string
- }{
- {"platformAdmin", adminToken},
- {"teamAdmin", teamAdminToken},
- {"teamMember", teamMemberToken},
- {"outsider", outsiderToken},
- } {
- models := h.getModels(tc.token)
- if hasModel(models, "gpt-4o") {
- t.Errorf("%s should NOT see disabled gpt-4o", tc.name)
- }
- }
- })
-
- t.Run("admin_models_shows_global_only", func(t *testing.T) {
- w := h.request("GET", "/api/v1/admin/models", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin/models: %d", w.Code)
- }
- var resp map[string]interface{}
- decode(w, &resp)
- allModels := resp["data"].([]interface{})
- // Admin should see only global-scope models (3), not team(2) or BYOK(2)
- if len(allModels) != 3 {
- t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels))
- }
- })
-}
-
-// ═══════════════════════════════════════════
-// ROLES TESTS
-// ═══════════════════════════════════════════
-
-func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- w := h.request("GET", "/api/v1/admin/roles", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list roles: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
-
- // Migration 004 seeds utility, embedding, generation
- // (generation removed from ValidRoles in v0.10.2 but still in JSONB seed data)
- for _, role := range []string{"utility", "embedding", "generation"} {
- if _, ok := resp[role]; !ok {
- t.Errorf("expected role %q in response, got: %v", role, resp)
- }
- }
-}
-
-func TestIntegration_Roles_UpdateAndGet(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create a global provider to reference
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "TestProvider", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
- }
- var cfg map[string]interface{}
- decode(w, &cfg)
- cfgID := cfg["id"].(string)
-
- // Update utility role
- w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
- "primary": map[string]string{
- "provider_config_id": cfgID,
- "model_id": "gpt-4o-mini",
- },
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update role: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify via list
- w = h.request("GET", "/api/v1/admin/roles", adminToken, nil)
- var allRoles map[string]interface{}
- decode(w, &allRoles)
- utilRaw, ok := allRoles["utility"]
- if !ok {
- t.Fatal("utility role missing after update")
- }
- utilMap, ok := utilRaw.(map[string]interface{})
- if !ok {
- t.Fatalf("utility role unexpected type: %T", utilRaw)
- }
- primary, _ := utilMap["primary"].(map[string]interface{})
- if primary == nil {
- t.Fatal("utility primary should be set")
- }
- if primary["model_id"] != "gpt-4o-mini" {
- t.Errorf("utility primary model: want gpt-4o-mini, got %v", primary["model_id"])
- }
-}
-
-func TestIntegration_Roles_InvalidRole400(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- w := h.request("GET", "/api/v1/admin/roles/nonexistent", adminToken, nil)
- if w.Code != http.StatusBadRequest {
- t.Fatalf("invalid role: want 400, got %d", w.Code)
- }
-}
-
-func TestIntegration_Roles_NonAdmin403(t *testing.T) {
- h := setupHarness(t)
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("user1", "user1@test.com", "password123")
-
- w := h.request("GET", "/api/v1/admin/roles", userToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("non-admin roles: want 403, got %d", w.Code)
- }
-}
-
-// ── Team Roles ────────────────────────────
-
-func TestIntegration_TeamRoles_CRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create team admin user
- teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
-
- // Create team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "Eng", "description": "Engineering",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // Add team admin
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
-
- // Create a provider to reference
- w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "Provider1", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- var pcfg map[string]interface{}
- decode(w, &pcfg)
- cfgID := pcfg["id"].(string)
-
- // List team roles — initially empty
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
- }
- var roleResp map[string]interface{}
- decode(w, &roleResp)
- if len(roleResp) != 0 {
- t.Fatalf("team roles should be empty initially, got %d", len(roleResp))
- }
-
- // Set team role override
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken,
- map[string]interface{}{
- "primary": map[string]string{
- "provider_config_id": cfgID,
- "model_id": "gpt-4o",
- },
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set team role: %d: %s", w.Code, w.Body.String())
- }
-
- // List should now have override
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
- roleResp = map[string]interface{}{}
- decode(w, &roleResp)
- if len(roleResp) == 0 {
- t.Fatal("team roles should have utility after update")
- }
- if _, ok := roleResp["utility"]; !ok {
- t.Fatal("team roles should have utility after update")
- }
-
- // Delete override
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete team role: %d: %s", w.Code, w.Body.String())
- }
-
- // List should be empty again
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
- roleResp = map[string]interface{}{}
- decode(w, &roleResp)
- if _, ok := roleResp["utility"]; ok {
- t.Fatal("team roles should not have utility after delete")
- }
-}
-
-// ═══════════════════════════════════════════
-// USAGE TESTS
-// ═══════════════════════════════════════════
-
-// seedUsage inserts a usage_log row directly for testing.
-func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) {
- t.Helper()
- seedExec(t, `
- INSERT INTO usage_log (user_id, provider_config_id, provider_scope,
- model_id, prompt_tokens, completion_tokens,
- cost_input, cost_output)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
- `, userID, provCfgID, scope, model, prompt, completion, costIn, costOut)
-}
-
-func TestIntegration_Usage_AdminView(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- userID := database.SeedTestUser(t, "alice", "alice@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
-
- // Create global provider
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "TestOAI", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
- })
- var cfg map[string]interface{}
- decode(w, &cfg)
- cfgID := cfg["id"].(string)
-
- // Seed usage data
- seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.005, 0.006)
- seedUsage(t, userID, cfgID, "gpt-4o", "global", 500, 100, 0.0025, 0.003)
- seedUsage(t, userID, cfgID, "gpt-4o-mini", "global", 2000, 400, 0.001, 0.002)
-
- // Admin usage — grouped by model (default)
- w = h.request("GET", "/api/v1/admin/usage?group_by=model", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
-
- totals := resp["totals"].(map[string]interface{})
- if int(totals["requests"].(float64)) != 3 {
- t.Errorf("totals.requests: want 3, got %v", totals["requests"])
- }
- if int(totals["input_tokens"].(float64)) != 3500 {
- t.Errorf("totals.input_tokens: want 3500, got %v", totals["input_tokens"])
- }
-
- results := resp["results"].([]interface{})
- if len(results) != 2 {
- t.Fatalf("want 2 model groups, got %d", len(results))
- }
-}
-
-func TestIntegration_Usage_AdminExcludesBYOK(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
- userID := database.SeedTestUser(t, "bob", "bob@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
-
- // Create global provider
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "Global1", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-g",
- })
- var cfg map[string]interface{}
- decode(w, &cfg)
- globalCfgID := cfg["id"].(string)
-
- // Seed: 1 global usage + 1 personal BYOK usage
- seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
- seedUsage(t, userID, globalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
-
- // Admin view should exclude personal
- w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
- var resp map[string]interface{}
- decode(w, &resp)
- totals := resp["totals"].(map[string]interface{})
-
- if int(totals["requests"].(float64)) != 1 {
- t.Errorf("admin should see 1 request (excludes BYOK), got %v", totals["requests"])
- }
-}
-
-func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
-
- // Create global provider (admin-managed)
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "Global2", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
- })
- var globalCfg map[string]interface{}
- decode(w, &globalCfg)
- globalCfgID := globalCfg["id"].(string)
-
- // Create personal BYOK provider (user-managed)
- w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "MyKey", "provider": "openai",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var personalCfg map[string]interface{}
- decode(w, &personalCfg)
- personalCfgID := personalCfg["id"].(string)
-
- // Seed usage against both providers
- seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
- seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
-
- // Personal view should only show BYOK usage (not global)
- w = h.request("GET", "/api/v1/usage", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- totals := resp["totals"].(map[string]interface{})
-
- if int(totals["requests"].(float64)) != 1 {
- t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"])
- }
- if int(totals["input_tokens"].(float64)) != 500 {
- t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"])
- }
-
- // Admin view should show global usage but exclude BYOK
- w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
- }
- decode(w, &resp)
- totals = resp["totals"].(map[string]interface{})
-
- if int(totals["requests"].(float64)) != 1 {
- t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"])
- }
- if int(totals["input_tokens"].(float64)) != 1000 {
- t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"])
- }
-}
-
-func TestIntegration_Usage_NonAdmin403(t *testing.T) {
- h := setupHarness(t)
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("dave", "dave@test.com", "password123")
-
- w := h.request("GET", "/api/v1/admin/usage", userToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("non-admin usage: want 403, got %d", w.Code)
- }
-}
-
-// ── Team Usage ────────────────────────────
-
-func TestIntegration_Usage_TeamAdmin(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create team admin + member
- teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user")
-
- memberID := database.SeedTestUser(t, "member2", "member2@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
-
- // Create team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "EngTeam", "description": "Test",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": memberID, "role": "member"})
-
- // Create team provider via team admin self-service
- w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken,
- map[string]interface{}{
- "name": "TeamOpenAI", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-team",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String())
- }
- var tprov map[string]interface{}
- decode(w, &tprov)
- teamProvID := tprov["id"].(string)
-
- // Also create a global provider (usage against it should NOT appear in team view)
- w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "GlobalProv", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
- })
- var gcfg map[string]interface{}
- decode(w, &gcfg)
- globalProvID := gcfg["id"].(string)
-
- // Seed: 2 rows against team provider, 1 against global
- seedUsage(t, memberID, teamProvID, "gpt-4o", "team", 1000, 200, 0.01, 0.01)
- seedUsage(t, teamAdminID, teamProvID, "gpt-4o", "team", 500, 100, 0.005, 0.005)
- seedUsage(t, memberID, globalProvID, "gpt-4o", "global", 2000, 400, 0.02, 0.02)
-
- // Team usage should show only team provider usage (2 rows)
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("team usage: %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- totals := resp["totals"].(map[string]interface{})
-
- if int(totals["requests"].(float64)) != 2 {
- t.Errorf("team usage should show 2 requests (team provider only), got %v", totals["requests"])
- }
- if int(totals["input_tokens"].(float64)) != 1500 {
- t.Errorf("team usage input_tokens: want 1500, got %v", totals["input_tokens"])
- }
-}
-
-func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- memberID := database.SeedTestUser(t, "member3", "member3@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), memberID)
- memberToken := makeToken(memberID, "member3@test.com", "user")
-
- // Create team, add member (NOT admin)
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "EngTeam2", "description": "Test2",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": memberID, "role": "member"})
-
- // Non-admin member should be forbidden
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), memberToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("team usage non-admin: want 403, got %d", w.Code)
- }
-}
-
-// ═══════════════════════════════════════════
-// PRICING TESTS
-// ═══════════════════════════════════════════
-
-func TestIntegration_Pricing_CRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create provider to reference
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "PricingProv", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-p",
- })
- var cfg map[string]interface{}
- decode(w, &cfg)
- cfgID := cfg["id"].(string)
-
- // Upsert pricing
- w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
- "provider_config_id": cfgID,
- "model_id": "gpt-4o",
- "input_per_m": 2.5,
- "output_per_m": 10.0,
- })
- if w.Code != http.StatusOK {
- t.Fatalf("upsert pricing: %d: %s", w.Code, w.Body.String())
- }
-
- // List pricing
- w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list pricing: %d: %s", w.Code, w.Body.String())
- }
- var entries []interface{}
- decode(w, &entries)
- if len(entries) != 1 {
- t.Fatalf("want 1 pricing entry, got %d", len(entries))
- }
- entry := entries[0].(map[string]interface{})
- if entry["model_id"] != "gpt-4o" {
- t.Errorf("pricing model: want gpt-4o, got %v", entry["model_id"])
- }
- if entry["source"] != "manual" {
- t.Errorf("pricing source: want manual, got %v", entry["source"])
- }
-
- // Delete pricing
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/pricing/%s/gpt-4o", cfgID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete pricing: %d: %s", w.Code, w.Body.String())
- }
-
- // List should be empty
- w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
- decode(w, &entries)
- if len(entries) != 0 {
- t.Fatalf("want 0 pricing entries after delete, got %d", len(entries))
- }
-}
-
-func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("byokuser", "byokuser@test.com", "password123")
-
- // Global provider with pricing
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "GlobalP", "provider": "openai",
- "endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
- })
- var gcfg map[string]interface{}
- decode(w, &gcfg)
- globalID := gcfg["id"].(string)
-
- // Set global pricing
- h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
- "provider_config_id": globalID,
- "model_id": "gpt-4o",
- "input_per_m": 2.5,
- "output_per_m": 10.0,
- })
-
- // BYOK provider (personal scope)
- w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "MyKey", "provider": "openai",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-byok",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create BYOK: %d: %s", w.Code, w.Body.String())
- }
- var bcfg map[string]interface{}
- decode(w, &bcfg)
- byokID := bcfg["id"].(string)
-
- // Simulate catalog pricing for BYOK provider (as model sync would)
- seedExec(t, `
- INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source)
- VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog')
- `, byokID)
-
- // Admin list should only show the 1 global entry, not the BYOK one
- w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
- var entries []interface{}
- decode(w, &entries)
- if len(entries) != 1 {
- t.Fatalf("admin pricing should show 1 (global only), got %d", len(entries))
- }
- entry := entries[0].(map[string]interface{})
- if entry["provider_config_id"] != globalID {
- t.Errorf("pricing entry should be global provider, got %v", entry["provider_config_id"])
- }
-}
-
-func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("byokuser2", "byokuser2@test.com", "password123")
-
- // Create BYOK provider
- w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
- "name": "MyKey2", "provider": "openai",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-byok2",
- })
- var bcfg map[string]interface{}
- decode(w, &bcfg)
- byokID := bcfg["id"].(string)
-
- // Admin tries to set pricing on personal provider — should be rejected
- w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
- "provider_config_id": byokID,
- "model_id": "gpt-4o",
- "input_per_m": 2.5,
- "output_per_m": 10.0,
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── GetRole Endpoint (exercises GetConfig signature) ──
-// The existing Roles_UpdateAndGet test validates via ListRoles.
-// This test hits GET /admin/roles/:role which routes through
-// resolver.GetConfig — the exact call site that broke in v0.10.2
-// when the signature changed from 3-arg to 4-arg.
-
-func TestIntegration_Roles_GetSingleRole(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin_getrole", "admin_getrole@test.com")
-
- // Create provider + configure utility role
- w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
- "name": "getrole-provider", "provider": "openai",
- "endpoint": "http://localhost:1/v1", "api_key": "sk-getrole",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
- }
- var cfg map[string]interface{}
- decode(w, &cfg)
- cfgID := cfg["id"].(string)
-
- w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
- "primary": map[string]string{
- "provider_config_id": cfgID,
- "model_id": "gpt-4o-mini",
- },
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update role: %d: %s", w.Code, w.Body.String())
- }
-
- // GET /admin/roles/utility — the endpoint that broke
- w = h.request("GET", "/api/v1/admin/roles/utility", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get single role: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Seeded role with null config via GetRole → 200 (migration seeds all roles)
- w = h.request("GET", "/api/v1/admin/roles/embedding", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get seeded-but-empty role: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── KB CRUD ─────────────────────────────────────
-
-func TestIntegration_KnowledgeBaseCRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create personal KB
- w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Test KB", "description": "A test knowledge base",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var kb map[string]interface{}
- decode(w, &kb)
- kbID := kb["id"].(string)
-
- if kb["name"] != "Test KB" {
- t.Fatalf("create KB: name mismatch: got %s", kb["name"])
- }
- if kb["scope"] != "personal" {
- t.Fatalf("create KB: scope should default to personal: got %s", kb["scope"])
- }
-
- // List KBs
- w = h.request("GET", "/api/v1/knowledge-bases", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list KBs: want 200, got %d", w.Code)
- }
- var kbList map[string]interface{}
- decode(w, &kbList)
- items := kbList["data"].([]interface{})
- if len(items) != 1 {
- t.Fatalf("list KBs: expected 1, got %d", len(items))
- }
-
- // Get single KB
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get KB: want 200, got %d", w.Code)
- }
-
- // Update KB
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken,
- map[string]interface{}{"name": "Updated KB"})
- if w.Code != http.StatusOK {
- t.Fatalf("update KB: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify update
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
- var updated map[string]interface{}
- decode(w, &updated)
- if updated["name"] != "Updated KB" {
- t.Fatalf("update KB: name not updated, got %s", updated["name"])
- }
-
- // Upload document — should 503 (no object store in test harness)
- w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil)
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("upload doc without objstore: want 503, got %d", w.Code)
- }
-
- // List documents (empty)
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents", kbID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list docs: want 200, got %d", w.Code)
- }
-
- // Rebuild — should 503 (no ingester in test harness)
- w = h.request("POST", fmt.Sprintf("/api/v1/knowledge-bases/%s/rebuild", kbID), adminToken, nil)
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("rebuild without ingester: want 503, got %d", w.Code)
- }
-
- // Delete KB
- w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete KB: want 200, got %d", w.Code)
- }
-
- // Verify deleted
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("get deleted KB: want 404, got %d", w.Code)
- }
-}
-
-// ── KB Cross-User Isolation ─────────────────────
-
-func TestIntegration_KBCrossUserIsolation(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- _, userToken := h.registerUser("alice", "alice@test.com", "password123")
-
- // Admin creates personal KB
- w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Admin Private KB",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("admin create KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var adminKB map[string]interface{}
- decode(w, &adminKB)
- adminKBID := adminKB["id"].(string)
-
- // Alice should NOT see admin's personal KB
- w = h.request("GET", "/api/v1/knowledge-bases", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("alice list KBs: want 200, got %d", w.Code)
- }
- var aliceList map[string]interface{}
- decode(w, &aliceList)
- items := aliceList["data"].([]interface{})
- if len(items) != 0 {
- t.Fatalf("alice should see 0 KBs, got %d", len(items))
- }
-
- // Alice should NOT be able to access admin's KB directly
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("alice access admin KB: want 404 (hidden), got %d", w.Code)
- }
-
- // Alice should NOT be able to delete admin's KB
- // Returns 403 (no kb.write permission) or 404 (ownership isolation) — both are correct denials
- w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s", adminKBID), userToken, nil)
- if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
- t.Fatalf("alice delete admin KB: want 403 or 404, got %d", w.Code)
- }
-}
-
-// ── Channel KB Linking ──────────────────────────
-
-func TestIntegration_ChannelKBLinking(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create a channel
- w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
- "title": "Test Channel",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var ch map[string]interface{}
- decode(w, &ch)
- channelID := ch["id"].(string)
-
- // Create a KB
- w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Channel KB",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var kb map[string]interface{}
- decode(w, &kb)
- kbID := kb["id"].(string)
-
- // Link KB to channel
- w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken,
- map[string]interface{}{"kb_ids": []string{kbID}})
- if w.Code != http.StatusOK {
- t.Fatalf("link KB to channel: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Get channel KBs
- w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get channel KBs: want 200, got %d", w.Code)
- }
- var linked map[string]interface{}
- decode(w, &linked)
- data := linked["data"].([]interface{})
- if len(data) != 1 {
- t.Fatalf("channel should have 1 linked KB, got %d", len(data))
- }
-
- // Unlink (set empty)
- w = h.request("PUT", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken,
- map[string]interface{}{"kb_ids": []string{}})
- if w.Code != http.StatusOK {
- t.Fatalf("unlink KBs: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify empty
- w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/knowledge-bases", channelID), adminToken, nil)
- var empty map[string]interface{}
- decode(w, &empty)
- emptyData := empty["data"].([]interface{})
- if len(emptyData) != 0 {
- t.Fatalf("channel should have 0 linked KBs after unlink, got %d", len(emptyData))
- }
-}
-
-// ── KB Document Status + Delete ─────────────────
-
-func TestIntegration_KB_DocumentStatusAndDelete(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create a KB
- w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Doc Test KB",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var kb map[string]interface{}
- decode(w, &kb)
- kbID := kb["id"].(string)
-
- // Seed a document directly (upload handler requires object store)
- docID := uuid.NewString()
- _, err := database.TestDB.Exec(dialectSQL(
- `INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`),
- docID, kbID, "test.md", "text/markdown", 1234, "kb/"+kbID+"/"+docID+"_test.md",
- "chunking", adminID, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
- if err != nil {
- t.Fatalf("seed doc: %v", err)
- }
-
- // GET document status
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, docID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get doc status: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var status map[string]interface{}
- decode(w, &status)
- if status["id"] != docID {
- t.Fatalf("doc status: id mismatch: got %s, want %s", status["id"], docID)
- }
- if status["status"] != "chunking" {
- t.Fatalf("doc status: want 'chunking', got %s", status["status"])
- }
- if status["filename"] != "test.md" {
- t.Fatalf("doc status: want filename 'test.md', got %s", status["filename"])
- }
-
- // GET status for wrong KB — should 404
- fakeKB := uuid.NewString()
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", fakeKB, docID), adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("doc status wrong KB: want 404, got %d", w.Code)
- }
-
- // GET status for wrong doc — should 404
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, uuid.NewString()), adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("doc status wrong doc: want 404, got %d", w.Code)
- }
-
- // DELETE document
- w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s", kbID, docID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete doc: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var delResp map[string]interface{}
- decode(w, &delResp)
- if delResp["deleted"] != true {
- t.Fatalf("delete doc: want deleted=true, got %v", delResp["deleted"])
- }
-
- // Verify gone
- w = h.request("GET", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s/status", kbID, docID), adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("deleted doc status: want 404, got %d", w.Code)
- }
-
- // DELETE on wrong KB — should 404
- docID2 := uuid.NewString()
- database.TestDB.Exec(dialectSQL(
- `INSERT INTO kb_documents (id, kb_id, filename, content_type, size_bytes, storage_key, status, uploaded_by, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`),
- docID2, kbID, "other.txt", "text/plain", 100, "placeholder",
- "pending", adminID, time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
-
- w = h.request("DELETE", fmt.Sprintf("/api/v1/knowledge-bases/%s/documents/%s", fakeKB, docID2), adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("delete doc wrong KB: want 404, got %d", w.Code)
- }
-}
-
-// ── KB Update Empty Body ────────────────────────
-
-func TestIntegration_KB_UpdateEmptyBody(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create KB
- w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Empty Update KB",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var kb map[string]interface{}
- decode(w, &kb)
- kbID := kb["id"].(string)
-
- // Update with empty fields — should 400
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s", kbID), adminToken,
- map[string]interface{}{})
- if w.Code != http.StatusBadRequest {
- t.Fatalf("empty update: want 400, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── KB Permission Enforcement ───────────────────
-
-func TestIntegration_KB_PermissionEnforcement(t *testing.T) {
- h := setupHarness(t)
- _, _ = h.createAdminUser("admin", "admin@test.com")
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- _, userToken := h.registerUser("normperm", "normperm@test.com", "password123")
-
- // Regular user without kb.create should get 403
- // (TruncateAll wipes the Everyone group, so no default permissions)
- w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "Should Fail",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("kb create without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// GROUPS + RESOURCE GRANTS (v0.16.0)
-// ══════════════════════════════════════════════
-
-func TestGroupCRUD(t *testing.T) {
- h := setupHarness(t)
-
- _, adminToken := h.createAdminUser("gadmin", "gadmin@test.com")
-
- // ── Create global group ──
- w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Engineering",
- "description": "All engineers",
- "scope": "global",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created models.Group
- decode(w, &created)
- if created.ID == "" || created.Name != "Engineering" || created.Scope != "global" {
- t.Fatalf("unexpected group: %+v", created)
- }
-
- // ── List groups ──
- w = h.request("GET", "/api/v1/admin/groups", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list groups: want 200, got %d", w.Code)
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- data := listResp["data"].([]interface{})
- if len(data) != 1 {
- t.Fatalf("expected 1 group, got %d", len(data))
- }
-
- // ── Get group ──
- w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get group: want 200, got %d", w.Code)
- }
-
- // ── Update group ──
- w = h.request("PUT", "/api/v1/admin/groups/"+created.ID, adminToken, map[string]interface{}{
- "name": "Platform Engineering",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify update
- w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
- var updated models.Group
- decode(w, &updated)
- if updated.Name != "Platform Engineering" {
- t.Fatalf("name should be updated, got %q", updated.Name)
- }
-
- // ── Duplicate name conflict ──
- w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Platform Engineering",
- "scope": "global",
- })
- if w.Code != http.StatusConflict {
- t.Fatalf("duplicate name: want 409, got %d", w.Code)
- }
-
- // ── Delete group ──
- w = h.request("DELETE", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete group: want 200, got %d", w.Code)
- }
-
- // Verify deleted
- w = h.request("GET", "/api/v1/admin/groups/"+created.ID, adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("deleted group: want 404, got %d", w.Code)
- }
-}
-
-func TestGroupMembers(t *testing.T) {
- h := setupHarness(t)
-
- _, adminToken := h.createAdminUser("gmadmin", "gmadmin@test.com")
- userID := database.SeedTestUser(h.t, "gmuser", "gmuser@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
- userToken := makeToken(userID, "gmuser@test.com", "user")
-
- // Create group
- w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Testers",
- "scope": "global",
- })
- var group models.Group
- decode(w, &group)
-
- // ── Add member ──
- w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
- "user_id": userID,
- })
- if w.Code != http.StatusOK {
- t.Fatalf("add member: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── List members ──
- w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list members: want 200, got %d", w.Code)
- }
- var memberResp map[string]interface{}
- decode(w, &memberResp)
- members := memberResp["data"].([]interface{})
- if len(members) != 1 {
- t.Fatalf("expected 1 member, got %d", len(members))
- }
- m := members[0].(map[string]interface{})
- if m["username"] != "gmuser" {
- t.Fatalf("expected username gmuser, got %v", m["username"])
- }
-
- // ── My groups (user endpoint) ──
- w = h.request("GET", "/api/v1/groups/mine", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("my groups: want 200, got %d", w.Code)
- }
- var myResp map[string]interface{}
- decode(w, &myResp)
- myGroups := myResp["data"].([]interface{})
- if len(myGroups) != 1 {
- t.Fatalf("user should be in 1 group, got %d", len(myGroups))
- }
-
- // ── Idempotent add (no error) ──
- w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
- "user_id": userID,
- })
- if w.Code != http.StatusOK {
- t.Fatalf("idempotent add: want 200, got %d", w.Code)
- }
-
- // ── Remove member ──
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify removed
- w = h.request("GET", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, nil)
- decode(w, &memberResp)
- members = memberResp["data"].([]interface{})
- if len(members) != 0 {
- t.Fatalf("expected 0 members after removal, got %d", len(members))
- }
-}
-
-func TestGroupScopeValidation(t *testing.T) {
- h := setupHarness(t)
-
- _, adminToken := h.createAdminUser("gsadmin", "gsadmin@test.com")
-
- // Team-scoped without team_id → 400
- w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Bad Group",
- "scope": "team",
- })
- if w.Code != http.StatusBadRequest {
- t.Fatalf("team scope without team_id: want 400, got %d", w.Code)
- }
-
- // Global with team_id → 400
- w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Bad Group",
- "scope": "global",
- "team_id": "00000000-0000-0000-0000-000000000001",
- })
- if w.Code != http.StatusBadRequest {
- t.Fatalf("global scope with team_id: want 400, got %d", w.Code)
- }
-}
-
-func TestResourceGrants(t *testing.T) {
- h := setupHarness(t)
-
- _, adminToken := h.createAdminUser("rgadmin", "rgadmin@test.com")
-
- // Create a group
- w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Grantees",
- "scope": "global",
- })
- var group models.Group
- decode(w, &group)
-
- // Create a persona (admin)
- w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "Test Bot",
- "base_model_id": "test-model",
- "scope": "global",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var persona map[string]interface{}
- decode(w, &persona)
- personaID := persona["id"].(string)
-
- // ── Get grant (no grant yet → default team_only) ──
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get grant: want 200, got %d", w.Code)
- }
- var grantResp map[string]interface{}
- decode(w, &grantResp)
- if grantResp["grant_scope"] != "team_only" {
- t.Fatalf("default grant_scope should be team_only, got %v", grantResp["grant_scope"])
- }
-
- // ── Set grant: groups scope ──
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
- "grant_scope": "groups",
- "granted_groups": []string{group.ID},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── Get grant (now groups) ──
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
- decode(w, &grantResp)
- if grantResp["grant_scope"] != "groups" {
- t.Fatalf("grant_scope should be groups, got %v", grantResp["grant_scope"])
- }
-
- // ── Set grant: global scope ──
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
- "grant_scope": "global",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set global grant: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── Revoke: set back to team_only (deletes grant row) ──
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
- "grant_scope": "team_only",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("revoke grant: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── Validation: groups scope without groups → 400 ──
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
- "grant_scope": "groups",
- })
- if w.Code != http.StatusBadRequest {
- t.Fatalf("groups without list: want 400, got %d", w.Code)
- }
-}
-
-func TestGroupBasedPersonaAccess(t *testing.T) {
- h := setupHarness(t)
-
- // Setup: admin + regular user (not on any team)
- adminID, adminToken := h.createAdminUser("gpadmin", "gpadmin@test.com")
- userID := database.SeedTestUser(h.t, "gpuser", "gpuser@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
- userToken := makeToken(userID, "gpuser@test.com", "user")
-
- // Create a team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "Secret Team",
- })
- var teamResp map[string]interface{}
- decode(w, &teamResp)
- teamID := teamResp["id"].(string)
-
- // Create persona scoped to that team (user shouldn't see it without group access)
- personaID := seedInsertReturningID(t, `
- INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
- VALUES ('Secret Bot', 'test-model', 'team', $1, $2, true)
- RETURNING id
- `, teamID, adminID)
- if personaID == "" {
- t.Fatal("personaID is empty after insert")
- }
-
- // ── User should NOT see team persona ──
- w = h.request("GET", "/api/v1/personas", userToken, nil)
- var personasResp map[string]interface{}
- decode(w, &personasResp)
- personas, _ := personasResp["data"].([]interface{})
- for _, p := range personas {
- pm := p.(map[string]interface{})
- if pm["id"] == personaID {
- t.Fatalf("user should NOT see team persona without group access")
- }
- }
-
- // ── Create group + add user + grant persona ──
- w = h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "Secret Readers",
- "scope": "global",
- })
- var group models.Group
- decode(w, &group)
-
- w = h.request("POST", "/api/v1/admin/groups/"+group.ID+"/members", adminToken, map[string]interface{}{
- "user_id": userID,
- })
-
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, map[string]interface{}{
- "grant_scope": "groups",
- "granted_groups": []string{group.ID},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── User should NOW see team persona via group grant ──
- w = h.request("GET", "/api/v1/personas", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
- }
- decode(w, &personasResp)
- personas, _ = personasResp["data"].([]interface{})
- found := false
- for _, p := range personas {
- pm := p.(map[string]interface{})
- if pm["id"] == personaID {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("user should see team persona after group grant, but didn't find it in %d personas (response: %s)", len(personas), w.Body.String())
- }
-
- // ── Remove user from group → should lose access ──
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
-
- w = h.request("GET", "/api/v1/personas", userToken, nil)
- decode(w, &personasResp)
- personas, _ = personasResp["data"].([]interface{})
- for _, p := range personas {
- pm := p.(map[string]interface{})
- if pm["id"] == personaID {
- t.Fatalf("user should NOT see persona after group removal")
- }
- }
-}
-
-// ═══════════════════════════════════════════════════════════════
-// v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes
-// ═══════════════════════════════════════════════════════════════
-
-// ── Persona-KB Binding CRUD ──
-
-func TestIntegration_PersonaKB_Binding(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // 1. Create a global persona via admin API
- w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "KB Persona",
- "base_model_id": "test-model",
- "system_prompt": "You are a test persona.",
- })
- if w.Code != http.StatusCreated && w.Code != http.StatusOK {
- t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String())
- }
- var personaResp map[string]interface{}
- decode(w, &personaResp)
- personaID := personaResp["id"].(string)
-
- // 2. Create a global KB
- w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Test KB",
- "scope": "global",
- })
- if w.Code != http.StatusOK && w.Code != http.StatusCreated {
- t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
- }
- var kbResp map[string]interface{}
- decode(w, &kbResp)
- kbID := kbResp["id"].(string)
-
- // 3. Bind KB to persona
- w = h.request("PUT",
- fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
- adminToken, map[string]interface{}{
- "kb_ids": []string{kbID},
- "auto_search": map[string]bool{kbID: true},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // 4. Read back bindings
- w = h.request("GET",
- fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
- adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- data := listResp["data"].([]interface{})
- if len(data) != 1 {
- t.Fatalf("expected 1 binding, got %d", len(data))
- }
- binding := data[0].(map[string]interface{})
- if binding["kb_id"] != kbID {
- t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID)
- }
- if binding["auto_search"] != true {
- t.Fatal("auto_search should be true")
- }
-
- // 5. Unbind — send empty list
- w = h.request("PUT",
- fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
- adminToken, map[string]interface{}{
- "kb_ids": []string{},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // 6. Verify empty
- w = h.request("GET",
- fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
- adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var emptyResp map[string]interface{}
- decode(w, &emptyResp)
- emptyData := emptyResp["data"].([]interface{})
- if len(emptyData) != 0 {
- t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData))
- }
-}
-
-// ── KB Create Auth (was TODO — now enforced) ──
-
-func TestIntegration_KB_CreateAuth(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
- _ = adminToken
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Seed Everyone group with kb.create so the permission middleware passes
- // and we can test the handler-level scope checks below.
- _, err := database.TestDB.Exec(dialectSQL(`
- INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
- VALUES ($1, $2, $3, $4, NULL, $5, $6)`),
- "00000000-0000-0000-0000-000000000001", "Everyone",
- "Implicit group — all authenticated users receive these permissions.",
- "global", "system", `["model.use","kb.read","kb.create","channel.create"]`)
- if err != nil {
- t.Fatalf("seed Everyone group: %v", err)
- }
-
- _, userToken := h.registerUser("kbuser", "kb@test.com", "password123")
-
- // Regular user should NOT be able to create global KBs
- w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "Sneaky Global KB",
- "scope": "global",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Regular user should NOT be able to create team KBs without team admin role
- w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "Sneaky Team KB",
- "scope": "team",
- "team_id": "00000000-0000-0000-0000-000000000001",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Regular user CAN create personal KBs
- w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "My Personal KB",
- "scope": "personal",
- })
- if w.Code != http.StatusOK && w.Code != http.StatusCreated {
- t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── KB Team Scope Creation ──────────────────────
-
-func TestIntegration_KB_TeamScopeCreate(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Seed Everyone group with kb.create
- database.TestDB.Exec(dialectSQL(`
- INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
- VALUES ($1, $2, $3, $4, NULL, $5, $6)`),
- "00000000-0000-0000-0000-000000000001", "Everyone",
- "Implicit group", "global", "system",
- `["model.use","kb.read","kb.create","channel.create"]`)
-
- userID, userToken := h.registerUser("teamkb", "teamkb@test.com", "password123")
-
- // Create a team via admin
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "KB Team", "description": "team for KB test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // Add user as regular member — should NOT be able to create team KB
- w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": userID, "role": "member"})
- if w.Code != http.StatusOK && w.Code != http.StatusCreated {
- t.Fatalf("add member: want 200/201, got %d: %s", w.Code, w.Body.String())
- }
-
- w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "Team KB by Member", "scope": "team", "team_id": teamID,
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("member create team KB: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Promote to team admin — should succeed
- database.TestDB.Exec(dialectSQL(
- `UPDATE team_members SET role = 'admin' WHERE team_id = $1 AND user_id = $2`),
- teamID, userID)
-
- w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "Team KB by Admin", "scope": "team", "team_id": teamID,
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("team admin create team KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var kbResp map[string]interface{}
- decode(w, &kbResp)
- if kbResp["scope"] != "team" {
- t.Fatalf("team KB scope: want 'team', got %s", kbResp["scope"])
- }
- if kbResp["team_id"] != teamID {
- t.Fatalf("team KB team_id: want %s, got %s", teamID, kbResp["team_id"])
- }
-}
-
-// ── KB Discoverable Flag ──
-
-func TestIntegration_KB_Discoverable(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- _, userToken := h.registerUser("discuser", "disc@test.com", "password123")
-
- // 1. Admin creates a global KB (discoverable=true by default)
- w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Visible KB",
- "scope": "global",
- })
- if w.Code != http.StatusOK && w.Code != http.StatusCreated {
- t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
- }
- var kbResp map[string]interface{}
- decode(w, &kbResp)
- kbID := kbResp["id"].(string)
-
- // 2. User can see it in discoverable list
- w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var discovResp map[string]interface{}
- decode(w, &discovResp)
- kbs, _ := discovResp["data"].([]interface{})
- if kbs == nil {
- t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String())
- }
- found := false
- for _, kb := range kbs {
- kbMap := kb.(map[string]interface{})
- if kbMap["id"] == kbID {
- found = true
- // Verify kbResponse shape (not raw model)
- if _, ok := kbMap["status"]; !ok {
- t.Fatal("discoverable response missing 'status' field (not using kbResponse?)")
- }
- if _, ok := kbMap["chunk_count"]; !ok {
- t.Fatal("discoverable response missing 'chunk_count' field (not using kbResponse?)")
- }
- // created_at should be ISO format (kbResponse), not Go default with nanoseconds
- ts, _ := kbMap["created_at"].(string)
- if len(ts) > 0 && strings.Contains(ts, ".") {
- t.Fatalf("discoverable created_at has nanoseconds (raw model leak): %s", ts)
- }
- break
- }
- }
- if !found {
- t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String())
- }
-
- // 3. Admin hides the KB
- w = h.request("PUT",
- fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID),
- adminToken, map[string]interface{}{"discoverable": false})
- if w.Code != http.StatusOK {
- t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // 4. User can no longer see it
- w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var discovResp2 map[string]interface{}
- decode(w, &discovResp2)
- kbs2, _ := discovResp2["data"].([]interface{})
- for _, kb := range kbs2 {
- if kb.(map[string]interface{})["id"] == kbID {
- t.Fatal("hidden KB should not appear in discoverable list")
- }
- }
-}
-
-// ── Platform Policy: kb_direct_access seeded ──
-
-func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Verify the migration seeded the policy by reading it through admin settings
- w := h.request("GET", "/api/v1/settings/public", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get public settings: want 200, got %d", w.Code)
- }
- // Policy existence is verified by the migration running without error
- // (if kb_direct_access INSERT fails, the migration fails and no tests run)
- _ = w
-}
-
-// ── KB SetDiscoverable Auth Enforcement ─────────
-
-func TestIntegration_KB_SetDiscoverableAuth(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
- _ = adminID
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- _, aliceToken := h.registerUser("alice", "alice@test.com", "password123")
- _, bobToken := h.registerUser("bob", "bob@test.com", "password123")
-
- // Seed Everyone group with kb.create so users can create KBs
- database.TestDB.Exec(dialectSQL(`
- INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
- VALUES ($1, $2, $3, $4, NULL, $5, $6)`),
- "00000000-0000-0000-0000-000000000001", "Everyone",
- "Implicit group", "global", "system",
- `["model.use","kb.read","kb.create","channel.create"]`)
-
- // Alice creates a personal KB
- w := h.request("POST", "/api/v1/knowledge-bases", aliceToken, map[string]interface{}{
- "name": "Alice KB",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("alice create KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var aliceKB map[string]interface{}
- decode(w, &aliceKB)
- aliceKBID := aliceKB["id"].(string)
-
- // Alice CAN toggle her own KB
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID),
- aliceToken, map[string]interface{}{"discoverable": false})
- if w.Code != http.StatusOK {
- t.Fatalf("owner toggle discoverable: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Bob CANNOT toggle Alice's KB (not owner, not admin)
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID),
- bobToken, map[string]interface{}{"discoverable": true})
- if w.Code != http.StatusNotFound {
- // loadAndAuthorize returns 404 for personal KBs the user doesn't own
- t.Fatalf("non-owner toggle discoverable: want 404, got %d: %s", w.Code, w.Body.String())
- }
-
- // Admin creates a global KB
- w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
- "name": "Global KB", "scope": "global",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("admin create global KB: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var globalKB map[string]interface{}
- decode(w, &globalKB)
- globalKBID := globalKB["id"].(string)
-
- // Alice CANNOT toggle a global KB she doesn't own
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", globalKBID),
- aliceToken, map[string]interface{}{"discoverable": false})
- if w.Code != http.StatusForbidden {
- t.Fatalf("non-admin toggle global discoverable: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Admin CAN toggle the global KB
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", globalKBID),
- adminToken, map[string]interface{}{"discoverable": false})
- if w.Code != http.StatusOK {
- t.Fatalf("admin toggle global discoverable: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Admin CANNOT access Alice's personal KB (loadAndAuthorize returns 404
- // for personal KBs not owned by the caller — consistent with Get/Update/Delete)
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", aliceKBID),
- adminToken, map[string]interface{}{"discoverable": true})
- if w.Code != http.StatusNotFound {
- t.Fatalf("admin toggle alice personal KB: want 404 (not owner), got %d: %s", w.Code, w.Body.String())
- }
-
- // Non-existent KB returns 404
- w = h.request("PUT", fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", uuid.NewString()),
- adminToken, map[string]interface{}{"discoverable": false})
- if w.Code != http.StatusNotFound {
- t.Fatalf("toggle nonexistent KB: want 404, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ═══════════════════════════════════════════════
-// Messages + Treepath tests (SQLite compat)
-// ═══════════════════════════════════════════════
-
-func TestIntegration_Messages_CRUD(t *testing.T) {
- h := setupHarness(t)
- _, token := h.createAdminUser("msguser", "msg@test.com")
-
- // Create channel
- w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
- "title": "Message Test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var ch map[string]interface{}
- decode(w, &ch)
- channelID := ch["id"].(string)
-
- // Create first message
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
- "role": "user",
- "content": "Hello, world!",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create message: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var msg1 map[string]interface{}
- decode(w, &msg1)
- if msg1["id"] == nil || msg1["id"].(string) == "" {
- t.Fatal("message should have an id")
- }
- if msg1["content"].(string) != "Hello, world!" {
- t.Fatalf("content mismatch: got %q", msg1["content"])
- }
- msg1ID := msg1["id"].(string)
-
- // Create second message (child of first)
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
- "role": "assistant",
- "content": "Hi there!",
- "parent_id": msg1ID,
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create message 2: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // List messages — should have 2
- w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list messages: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- total := int(listResp["total"].(float64))
- if total != 2 {
- t.Fatalf("expected 2 messages, got %d", total)
- }
-}
-
-func TestIntegration_Messages_EditFork(t *testing.T) {
- h := setupHarness(t)
- _, token := h.createAdminUser("forkuser", "fork@test.com")
-
- // Create channel
- w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
- "title": "Fork Test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
- }
- var ch map[string]interface{}
- decode(w, &ch)
- channelID := ch["id"].(string)
-
- // Create user message (root)
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
- "role": "user",
- "content": "First draft",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create message: %d: %s", w.Code, w.Body.String())
- }
- var msg map[string]interface{}
- decode(w, &msg)
- msgID := msg["id"].(string)
-
- // Edit (fork) the message — creates a sibling
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages/%s/edit", channelID, msgID), token, map[string]interface{}{
- "content": "Second draft",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("edit message: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var edited map[string]interface{}
- decode(w, &edited)
- if edited["content"].(string) != "Second draft" {
- t.Fatalf("edited content mismatch: got %q", edited["content"])
- }
-
- // Sibling count should be 2 (original + edit)
- sibCount := int(edited["sibling_count"].(float64))
- if sibCount != 2 {
- t.Fatalf("expected sibling_count=2, got %d", sibCount)
- }
-
- // Verify via siblings endpoint
- w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/messages/%s/siblings", channelID, msgID), token, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list siblings: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var sibResp map[string]interface{}
- decode(w, &sibResp)
- siblings := sibResp["siblings"].([]interface{})
- if len(siblings) != 2 {
- t.Fatalf("expected 2 siblings, got %d", len(siblings))
- }
-}
-
-func TestIntegration_Messages_TreePath(t *testing.T) {
- h := setupHarness(t)
- _, token := h.createAdminUser("pathuser", "path@test.com")
-
- // Create channel
- w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
- "title": "Path Test",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
- }
- var ch map[string]interface{}
- decode(w, &ch)
- channelID := ch["id"].(string)
-
- // Build a 3-message chain: root → child → grandchild
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
- "role": "user", "content": "root message",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("msg1: %d: %s", w.Code, w.Body.String())
- }
- var m1 map[string]interface{}
- decode(w, &m1)
-
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
- "role": "assistant", "content": "response", "parent_id": m1["id"].(string),
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("msg2: %d: %s", w.Code, w.Body.String())
- }
- var m2 map[string]interface{}
- decode(w, &m2)
-
- w = h.request("POST", fmt.Sprintf("/api/v1/channels/%s/messages", channelID), token, map[string]interface{}{
- "role": "user", "content": "follow-up", "parent_id": m2["id"].(string),
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("msg3: %d: %s", w.Code, w.Body.String())
- }
-
- // Get active path — should return 3 messages in root-first order
- w = h.request("GET", fmt.Sprintf("/api/v1/channels/%s/path", channelID), token, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get path: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var pathEnv map[string]interface{}
- decode(w, &pathEnv)
- pathResp := pathEnv["data"].([]interface{})
- if len(pathResp) != 3 {
- t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
- }
-
- // Verify order: root first, grandchild last
- first := pathResp[0].(map[string]interface{})
- last := pathResp[2].(map[string]interface{})
- if first["content"].(string) != "root message" {
- t.Fatalf("first in path should be root, got %q", first["content"])
- }
- if last["content"].(string) != "follow-up" {
- t.Fatalf("last in path should be follow-up, got %q", last["content"])
- }
-}
-
-// ── Channel List with Type Filter (regression: $1 reuse in SQLite) ──
-
-func TestIntegration_ChannelListWithTypeFilter(t *testing.T) {
- h := setupHarness(t)
- _, token := h.createAdminUser("admin", "admin@test.com")
-
- // Create a direct chat
- w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{
- "title": "Test Direct Chat",
- "type": "direct",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // List without type filter — should succeed
- w = h.request("GET", "/api/v1/channels?page=1&per_page=100", token, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list channels (no filter): want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // List with single type filter
- w = h.request("GET", "/api/v1/channels?page=1&per_page=100&type=direct", token, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list channels (type=direct): want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // List with multi-value types filter — this triggers the $1 reuse in the
- // count subquery. Before the QArgs fix, this returned 500 on SQLite.
- w = h.request("GET", "/api/v1/channels?page=1&per_page=100&types=direct,group", token, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list channels (types=direct,group): want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify the response is valid
- var resp map[string]interface{}
- decode(w, &resp)
- // The response may use "channels" or "data" depending on the handler.
- // Verify the request succeeded (200) — that's the regression test.
- // Channel content verification is covered by other tests.
-}
-
-// ═══════════════════════════════════════════
-// TEAMS ICD AUDIT — Phase 2 Tests
-// ═══════════════════════════════════════════
-
-// ── Admin Team CRUD Lifecycle ──────────────
-
-func TestAudit_AdminTeamCRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // ── Create ──
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "Audit Team", "description": "For audit testing",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- teamID := created["id"].(string)
- if created["name"] != "Audit Team" {
- t.Fatalf("create response should have name, got %v", created["name"])
- }
-
- // ── List (data envelope) ──
- w = h.request("GET", "/api/v1/admin/teams", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list teams: want 200, got %d", w.Code)
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- if _, ok := listResp["data"]; !ok {
- t.Fatal("GET /admin/teams must return 'data' key (envelope convention)")
- }
- if _, ok := listResp["total"]; !ok {
- t.Fatal("GET /admin/teams must return 'total' key (paginated)")
- }
-
- // ── Get ──
- w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get team: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var got map[string]interface{}
- decode(w, &got)
- if got["name"] != "Audit Team" {
- t.Fatalf("get team name mismatch: %v", got["name"])
- }
-
- // ── Get nonexistent ──
- w = h.request("GET", "/api/v1/admin/teams/00000000-0000-0000-0000-000000000099", adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("get nonexistent team: want 404, got %d", w.Code)
- }
-
- // ── Update ──
- w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
- "name": "Renamed Team",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update team: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify update
- w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
- decode(w, &got)
- if got["name"] != "Renamed Team" {
- t.Fatalf("after update, name should be 'Renamed Team', got %v", got["name"])
- }
-
- // ── Update with no fields → 400 ──
- w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{})
- if w.Code != http.StatusBadRequest {
- t.Fatalf("update with no fields: want 400, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── Duplicate name → 409 ──
- w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "Second Team",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create second team: %d: %s", w.Code, w.Body.String())
- }
- w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
- "name": "Second Team",
- })
- if w.Code != http.StatusConflict {
- t.Fatalf("duplicate name update: want 409, got %d: %s", w.Code, w.Body.String())
- }
-
- // ── Delete ──
- w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete team: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify deleted
- w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("deleted team: want 404, got %d", w.Code)
- }
-
- // ── Delete nonexistent → 404 ──
- w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("delete nonexistent: want 404, got %d", w.Code)
- }
-}
-
-// ── Admin Member Update + Remove ───────────
-
-func TestAudit_AdminMemberUpdateRemove(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- userID := database.SeedTestUser(t, "bob", "bob@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
-
- // Create team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "MemberTest",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // Add member
- w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": userID, "role": "member"})
- if w.Code != http.StatusCreated {
- t.Fatalf("add member: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // Get member ID from listing
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
- var membersResp map[string]interface{}
- decode(w, &membersResp)
- members := membersResp["data"].([]interface{})
- if len(members) != 1 {
- t.Fatalf("expected 1 member, got %d", len(members))
- }
- memberID := members[0].(map[string]interface{})["id"].(string)
-
- // Update role → admin
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken,
- map[string]string{"role": "admin"})
- if w.Code != http.StatusOK {
- t.Fatalf("update member: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Update nonexistent member → 404
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, "00000000-0000-0000-0000-000000000099"), adminToken,
- map[string]string{"role": "member"})
- if w.Code != http.StatusNotFound {
- t.Fatalf("update nonexistent member: want 404, got %d", w.Code)
- }
-
- // Remove member
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Remove nonexistent → 404
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("remove nonexistent: want 404, got %d", w.Code)
- }
-}
-
-// ── C1: Cross-Team Member Mutation ─────────
-// BUG: UpdateMember/RemoveMember WHERE clause has no team_id constraint.
-// A team admin for Team A can mutate membership in Team B by knowing
-// the team_members.id primary key.
-
-func TestAudit_CrossTeamMemberMutation(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create two users
- aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
- bobID := database.SeedTestUser(t, "bob", "bob@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
-
- // Create Team A and Team B
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team A"})
- var tA map[string]interface{}
- decode(w, &tA)
- teamAID := tA["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team B"})
- var tB map[string]interface{}
- decode(w, &tB)
- teamBID := tB["id"].(string)
-
- // Alice is admin of Team A
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamAID), adminToken,
- map[string]string{"user_id": aliceID, "role": "admin"})
-
- // Bob is member of Team B
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken,
- map[string]string{"user_id": bobID, "role": "member"})
-
- // Get Bob's team_members.id from Team B listing
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, nil)
- var bMembers map[string]interface{}
- decode(w, &bMembers)
- bobMemberID := bMembers["data"].([]interface{})[0].(map[string]interface{})["id"].(string)
-
- // Alice (Team A admin) tries to update Bob's role in Team B
- // via the team-scoped route for Team A — should fail with 404 because
- // bobMemberID does not belong to Team A.
- aliceToken := makeToken(aliceID, "alice@test.com", "user")
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken,
- map[string]string{"role": "admin"})
- if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
- t.Fatalf("C1 BUG: cross-team member update should be 404/403, got %d: %s\n"+
- "UpdateMember WHERE clause has no team_id constraint — authorization bypass",
- w.Code, w.Body.String())
- }
-
- // Alice (Team A admin) tries to remove Bob from Team B
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, nil)
- if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
- t.Fatalf("C1 BUG: cross-team member delete should be 404/403, got %d: %s\n"+
- "RemoveMember WHERE clause has no team_id constraint — authorization bypass",
- w.Code, w.Body.String())
- }
-}
-
-// ── Team Audit Log ─────────────────────────
-
-func TestAudit_TeamAuditLog(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create team + add admin as team member so audit actions are scoped
- teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
-
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "AuditTeam",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
-
- // ── Query audit log (team admin) ──
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("team audit log: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var auditResp map[string]interface{}
- decode(w, &auditResp)
- // Must use "data" envelope
- if _, ok := auditResp["data"]; !ok {
- t.Fatal("GET /teams/:teamId/audit must return 'data' key (envelope convention)")
- }
- if _, ok := auditResp["total"]; !ok {
- t.Fatal("GET /teams/:teamId/audit must return 'total' key (paginated)")
- }
-
- // ── Query audit actions ──
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit/actions", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("team audit actions: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var actionsResp map[string]interface{}
- decode(w, &actionsResp)
- if _, ok := actionsResp["actions"]; !ok {
- t.Fatal("GET /teams/:teamId/audit/actions must return 'actions' key")
- }
-}
-
-// ── Admin Permissions Listing ──────────────
-
-func TestAudit_AdminPermissions(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // ── List all permissions ──
- w := h.request("GET", "/api/v1/admin/permissions", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list permissions: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var permResp map[string]interface{}
- decode(w, &permResp)
- perms, ok := permResp["permissions"]
- if !ok {
- t.Fatal("GET /admin/permissions must return 'permissions' key")
- }
- permList := perms.([]interface{})
- if len(permList) == 0 {
- t.Fatal("permissions list should not be empty")
- }
-
- // Verify known permissions are present
- found := false
- for _, p := range permList {
- if p.(string) == "model.use" {
- found = true
- break
- }
- }
- if !found {
- t.Fatal("permissions list should contain 'model.use'")
- }
-}
-
-// ── User Effective Permissions ─────────────
-
-func TestAudit_UserEffectivePermissions(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- userID := database.SeedTestUser(t, "charlie", "charlie@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
-
- // ── Get permissions for user ──
- w := h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", userID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get user permissions: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- if _, ok := resp["permissions"]; !ok {
- t.Fatal("GET /admin/users/:id/permissions must return 'permissions' key")
- }
- if _, ok := resp["user_id"]; !ok {
- t.Fatal("GET /admin/users/:id/permissions must return 'user_id' key")
- }
- // H10: must include contributing groups
- groups, ok := resp["groups"]
- if !ok {
- t.Fatal("GET /admin/users/:id/permissions must return 'groups' key (contributing groups)")
- }
- groupList := groups.([]interface{})
- // Should always contain the Everyone group
- found := false
- for _, g := range groupList {
- if g.(string) == "00000000-0000-0000-0000-000000000001" {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("groups should always include the Everyone group, got %v", groupList)
- }
-
- // ── Get permissions for admin (should include their own set) ──
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", adminID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get admin permissions: want 200, got %d", w.Code)
- }
-}
-
-// ── System Group Deletion Rejection ────────
-
-func TestAudit_SystemGroupCannotBeDeleted(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- everyoneID := "00000000-0000-0000-0000-000000000001"
-
- // Seed the Everyone group (TruncateAll wipes migration-seeded data)
- _, err := database.TestDB.Exec(dialectSQL(`
- INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
- VALUES ($1, $2, $3, $4, NULL, $5, $6)
- `), everyoneID, "Everyone",
- "Implicit group — all authenticated users receive these permissions.",
- "global", "system", `["model.use","kb.read","channel.create"]`)
- if err != nil {
- t.Fatalf("seed Everyone group: %v", err)
- }
-
- // Attempt to delete the Everyone system group → should fail with 400
- w := h.request("DELETE", "/api/v1/admin/groups/"+everyoneID, adminToken, nil)
- if w.Code != http.StatusBadRequest {
- t.Fatalf("delete system group: want 400, got %d: %s\n"+
- "System groups (source=system) must be protected from deletion",
- w.Code, w.Body.String())
- }
-}
-
-// ── Resource Grant DELETE Round-Trip ────────
-
-func TestAudit_ResourceGrantDelete(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- // Create a persona to grant
- w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "Grant Test Persona",
- "base_model_id": "test-model",
- "scope": "global",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var persona map[string]interface{}
- decode(w, &persona)
- personaID := persona["id"].(string)
-
- // Set a grant
- w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken,
- map[string]interface{}{
- "grant_scope": "global",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify the grant exists
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get grant: want 200, got %d", w.Code)
- }
- var grantResp map[string]interface{}
- decode(w, &grantResp)
- if grantResp["grant_scope"] != "global" {
- t.Fatalf("grant_scope should be global, got %v", grantResp["grant_scope"])
- }
-
- // DELETE the grant
- w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete grant: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // After delete, GET should return default (team_only)
- w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get after delete: want 200, got %d", w.Code)
- }
- decode(w, &grantResp)
- if grantResp["grant_scope"] != "team_only" {
- t.Fatalf("after delete, grant_scope should fall back to team_only, got %v", grantResp["grant_scope"])
- }
-}
-
-// ── H8: Team Providers Envelope ────────────
-// BUG: ListTeamProviders returns {"providers": [...]} instead of {"data": [...]}.
-
-func TestAudit_TeamProvidersEnvelope(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
-
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "ProvEnvTeam",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
-
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list team providers: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
-
- // Must use "data" not "providers"
- if _, ok := resp["data"]; !ok {
- t.Fatalf("H8 BUG: GET /teams/:teamId/providers should return 'data' key (envelope convention), "+
- "got keys: %v", mapKeys(resp))
- }
- if _, ok := resp["allow_team_providers"]; !ok {
- t.Fatal("GET /teams/:teamId/providers must include 'allow_team_providers'")
- }
-}
-
-// ── H9: Team Roles Envelope ────────────────
-// Roles endpoints return composite named-key maps (not lists).
-// Convention: composite endpoints return object directly, no {"data": ...} wrapper.
-func TestAudit_TeamRolesEnvelope(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
-
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "RolesEnvTeam",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
-
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list team roles: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
-
- // Composite endpoint — must NOT have a "data" wrapper
- if _, ok := resp["data"]; ok {
- t.Fatalf("GET /teams/:teamId/roles should return named keys directly (composite convention), "+
- "not wrapped in {\"data\": ...}")
- }
-}
-
-// ── Team Groups Listing ────────────────────
-
-func TestAudit_TeamGroupsListing(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("admin", "admin@test.com")
-
- teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
- teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
-
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
- "name": "GroupsTeam",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
- map[string]string{"user_id": teamAdminID, "role": "admin"})
-
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/groups", teamID), teamAdminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list team groups: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
-
- // Must use "data" envelope
- if _, ok := resp["data"]; !ok {
- t.Fatalf("GET /teams/:teamId/groups must return 'data' key, got keys: %v", mapKeys(resp))
- }
-}
-
-// ── Helper: map keys for diagnostic output ──
-
-func mapKeys(m map[string]interface{}) []string {
- keys := make([]string, 0, len(m))
- for k := range m {
- keys = append(keys, k)
- }
- return keys
-}
-
-// ═══════════════════════════════════════════════════════════════
-// PERSONA AUDIT TESTS (v0.28.0-audit CS2)
-// ═══════════════════════════════════════════════════════════════
-
-// ── Persona CRUD ────────────────────────────
-
-func TestPersona_CRUD_Personal(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("padmin", "padmin@test.com")
- _ = adminID
-
- // Enable persona creation policy
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Create
- w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
- "name": "My Helper", "base_model_id": "test-model",
- "system_prompt": "You are helpful.", "description": "Test persona",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
- if created["handle"] == nil || created["handle"] == "" {
- t.Error("handle should be auto-generated from name")
- }
-
- // Update
- w = h.request("PUT", "/api/v1/personas/"+personaID, adminToken, map[string]interface{}{
- "name": "My Updated Helper", "description": "Updated desc",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update persona: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // List — verify updated name appears
- w = h.request("GET", "/api/v1/personas", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- data := listResp["data"].([]interface{})
- found := false
- for _, p := range data {
- pm := p.(map[string]interface{})
- if pm["id"] == personaID && pm["name"] == "My Updated Helper" {
- found = true
- }
- }
- if !found {
- t.Error("updated persona not found in list with new name")
- }
-
- // Delete
- w = h.request("DELETE", "/api/v1/personas/"+personaID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete persona: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-func TestPersona_Update_OwnershipCheck(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("own1", "own1@test.com")
- otherID := database.SeedTestUser(t, "own2", "own2@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
- otherToken := makeToken(otherID, "own2@test.com", "user")
-
- // Enable persona creation
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Admin creates a personal persona
- w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
- "name": "Admin's Bot", "base_model_id": "test-model",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
-
- // Other user tries to update — should be forbidden
- w = h.request("PUT", "/api/v1/personas/"+personaID, otherToken, map[string]interface{}{
- "name": "Stolen",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Other user tries to delete — should be forbidden
- w = h.request("DELETE", "/api/v1/personas/"+personaID, otherToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("delete by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-func TestPersona_AdminCRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("apadmin", "apadmin@test.com")
-
- // Create global persona via admin
- w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "Global Bot", "base_model_id": "test-model",
- "system_prompt": "You are global.",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("admin create: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
- if created["scope"] != "global" {
- t.Fatalf("admin persona scope: want 'global', got %v", created["scope"])
- }
-
- // Update
- w = h.request("PUT", "/api/v1/admin/personas/"+personaID, adminToken, map[string]interface{}{
- "name": "Global Bot v2",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("admin update: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // List
- w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin list: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- if listResp["data"] == nil {
- t.Fatal("admin list should return 'data' key")
- }
-
- // Delete
- w = h.request("DELETE", "/api/v1/admin/personas/"+personaID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── Envelope + Nil Slice ────────────────────
-
-func TestPersona_EnvelopeKey(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("envadm", "envadm@test.com")
-
- // User list
- w := h.request("GET", "/api/v1/personas", adminToken, nil)
- var resp map[string]interface{}
- decode(w, &resp)
- if _, ok := resp["personas"]; ok {
- t.Error("response should use 'data' key, not 'personas'")
- }
- if resp["data"] == nil {
- t.Error("response should have 'data' key")
- }
-
- // Admin list
- w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
- decode(w, &resp)
- if _, ok := resp["personas"]; ok {
- t.Error("admin response should use 'data' key, not 'personas'")
- }
- if resp["data"] == nil {
- t.Error("admin response should have 'data' key")
- }
-}
-
-func TestPersona_NilSlice_ReturnsEmptyArray(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("niladm", "niladm@test.com")
-
- // Fresh user, no personas — should get [] not null
- w := h.request("GET", "/api/v1/personas", adminToken, nil)
- // Check raw JSON contains [] not null
- body := w.Body.String()
- if strings.Contains(body, `"data":null`) {
- t.Fatal("data should be [] not null for empty persona list")
- }
- if !strings.Contains(body, `"data":[]`) {
- t.Fatalf("data should be empty array, got: %s", body)
- }
-
- // Admin list
- w = h.request("GET", "/api/v1/admin/personas", adminToken, nil)
- body = w.Body.String()
- if strings.Contains(body, `"data":null`) {
- t.Fatal("admin data should be [] not null for empty persona list")
- }
-}
-
-// ── Tool Grants ─────────────────────────────
-
-func TestPersona_ToolGrants_RoundTrip(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("tgadm", "tgadm@test.com")
-
- // Create persona
- w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "Grant Bot", "base_model_id": "test-model",
- })
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
-
- // Initially empty
- w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var grantResp map[string]interface{}
- decode(w, &grantResp)
- grants := grantResp["data"].([]interface{})
- if len(grants) != 0 {
- t.Fatalf("initial grants: want 0, got %d", len(grants))
- }
-
- // Set grants
- w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
- "tool_names": []string{"web_search", "calculator", "kb_search"},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Read back
- w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
- decode(w, &grantResp)
- grants = grantResp["data"].([]interface{})
- if len(grants) != 3 {
- t.Fatalf("grants after set: want 3, got %d", len(grants))
- }
-
- // Clear grants
- w = h.request("PUT", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
- "tool_names": []string{},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("clear tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Read back — should be empty
- w = h.request("GET", "/api/v1/admin/personas/"+personaID+"/tool-grants", adminToken, nil)
- decode(w, &grantResp)
- grants = grantResp["data"].([]interface{})
- if len(grants) != 0 {
- t.Fatalf("grants after clear: want 0, got %d", len(grants))
- }
-
- // Nil-slice check: verify "data":[] not "data":null
- body := w.Body.String()
- if strings.Contains(body, `"data":null`) {
- t.Fatal("tool grants should return [] not null when empty")
- }
-}
-
-func TestPersona_ToolGrants_UserScope(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("tguadm", "tguadm@test.com")
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Create personal persona
- w := h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
- "name": "User Grant Bot", "base_model_id": "test-model",
- })
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
-
- // Set via user endpoint
- w = h.request("PUT", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, map[string]interface{}{
- "tool_names": []string{"web_search"},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("user set tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Read back via user endpoint
- w = h.request("GET", "/api/v1/personas/"+personaID+"/tool-grants", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("user get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var grantResp map[string]interface{}
- decode(w, &grantResp)
- grants := grantResp["data"].([]interface{})
- if len(grants) != 1 {
- t.Fatalf("user grants: want 1, got %d", len(grants))
- }
-}
-
-// ── Team Persona CRUD + Scope Checks ────────
-
-func TestTeamPersona_CRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("tpadm", "tpadm@test.com")
-
- // Create a team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "Persona Team",
- })
- var teamResp map[string]interface{}
- decode(w, &teamResp)
- teamID := teamResp["id"].(string)
-
- // Create team persona
- w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
- "name": "Team Bot", "base_model_id": "test-model",
- "system_prompt": "You are a team bot.",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create team persona: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
- if created["scope"] != "team" {
- t.Fatalf("team persona scope: want 'team', got %v", created["scope"])
- }
-
- // List team personas
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list team personas: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- if listResp["data"] == nil {
- t.Fatal("team persona list should use 'data' key")
- }
- items := listResp["data"].([]interface{})
- if len(items) != 1 {
- t.Fatalf("team personas: want 1, got %d", len(items))
- }
-
- // Update team persona
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, map[string]interface{}{
- "name": "Team Bot v2",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update team persona: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Delete team persona
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamID, personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete team persona: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify deleted
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, nil)
- decode(w, &listResp)
- items = listResp["data"].([]interface{})
- if len(items) != 0 {
- t.Fatalf("team personas after delete: want 0, got %d", len(items))
- }
-}
-
-func TestTeamPersona_Delete_ScopeCheck(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("tpscadm", "tpscadm@test.com")
-
- // Create two teams
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "Team Alpha",
- })
- var teamA map[string]interface{}
- decode(w, &teamA)
- teamAID := teamA["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "Team Beta",
- })
- var teamB map[string]interface{}
- decode(w, &teamB)
- teamBID := teamB["id"].(string)
-
- // Create persona on Team Alpha
- personaID := seedInsertReturningID(t, `
- INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
- VALUES ('Alpha Bot', 'test-model', 'team', $1, $2, true)
- RETURNING id
- `, teamAID, adminID)
-
- // Try to delete it via Team Beta route — should fail
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team delete: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Try to update it via Team Beta route — should fail
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamBID, personaID), adminToken, map[string]interface{}{
- "name": "Stolen",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team update: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Delete via correct team — should succeed
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s", teamAID, personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("same-team delete: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-func TestTeamPersona_KB_ScopeCheck(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("tpkbadm", "tpkbadm@test.com")
-
- // Create two teams
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "KB Team A",
- })
- var teamA map[string]interface{}
- decode(w, &teamA)
- teamAID := teamA["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "KB Team B",
- })
- var teamB map[string]interface{}
- decode(w, &teamB)
- teamBID := teamB["id"].(string)
-
- // Create persona on Team A
- personaID := seedInsertReturningID(t, `
- INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
- VALUES ('KB Bot', 'test-model', 'team', $1, $2, true)
- RETURNING id
- `, teamAID, adminID)
-
- // Try to read KBs via Team B — should fail
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team get KBs: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Try to set KBs via Team B — should fail
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamBID, personaID), adminToken, map[string]interface{}{
- "kb_ids": []string{},
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team set KBs: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Read via correct team — should succeed
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/knowledge-bases", teamAID, personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("same-team get KBs: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── Persona Groups ──────────────────────────
-
-func TestPersonaGroup_CRUD(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("pgadm", "pgadm@test.com")
-
- // Create group
- w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
- "name": "Support Team", "description": "Front-line support personas",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create group: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var created map[string]interface{}
- decode(w, &created)
- groupID := created["id"].(string)
- if created["scope"] != "personal" {
- t.Fatalf("group scope: want 'personal', got %v", created["scope"])
- }
-
- // Get
- w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get group: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var fetched map[string]interface{}
- decode(w, &fetched)
- if fetched["name"] != "Support Team" {
- t.Fatalf("group name: want 'Support Team', got %v", fetched["name"])
- }
-
- // Update
- w = h.request("PUT", "/api/v1/persona-groups/"+groupID, adminToken, map[string]interface{}{
- "name": "Support Team v2",
- })
- if w.Code != http.StatusOK {
- t.Fatalf("update group: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // List
- w = h.request("GET", "/api/v1/persona-groups", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("list groups: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var listResp map[string]interface{}
- decode(w, &listResp)
- if listResp["data"] == nil {
- t.Fatal("group list should use 'data' key")
- }
- if _, ok := listResp["groups"]; ok {
- t.Error("group list should NOT use 'groups' key")
- }
-
- // Delete
- w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("delete group: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Verify deleted
- w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("get deleted group: want 404, got %d", w.Code)
- }
-}
-
-func TestPersonaGroup_Members(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("pgmadm", "pgmadm@test.com")
-
- // Create a persona for the group
- w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "Group Persona A", "base_model_id": "test-model",
- })
- var pA map[string]interface{}
- decode(w, &pA)
- personaAID := pA["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
- "name": "Group Persona B", "base_model_id": "test-model",
- })
- var pB map[string]interface{}
- decode(w, &pB)
- personaBID := pB["id"].(string)
-
- // Create group
- w = h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
- "name": "Two-Bot Team",
- })
- var group map[string]interface{}
- decode(w, &group)
- groupID := group["id"].(string)
-
- // Add member A as leader
- w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
- "persona_id": personaAID, "is_leader": true,
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("add member A: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // Add member B
- w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
- "persona_id": personaBID,
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("add member B: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // Get group — should have 2 members
- w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get group for members: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var fetched map[string]interface{}
- decode(w, &fetched)
- members := fetched["members"].([]interface{})
- if len(members) != 2 {
- t.Fatalf("members: want 2, got %d", len(members))
- }
-
- // Verify leader is first (sorted by is_leader DESC)
- first := members[0].(map[string]interface{})
- if first["is_leader"] != true {
- t.Error("first member should be the leader")
- }
-
- // Swap leader to B — should clear A's leader flag
- w = h.request("POST", fmt.Sprintf("/api/v1/persona-groups/%s/members", groupID), adminToken, map[string]interface{}{
- "persona_id": personaBID, "is_leader": true,
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("swap leader: want 201, got %d: %s", w.Code, w.Body.String())
- }
-
- // Get group — verify only one leader
- w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
- decode(w, &fetched)
- members = fetched["members"].([]interface{})
- leaderCount := 0
- for _, m := range members {
- mm := m.(map[string]interface{})
- if mm["is_leader"] == true {
- leaderCount++
- }
- }
- if leaderCount != 1 {
- t.Fatalf("leaders after swap: want 1, got %d", leaderCount)
- }
-
- // Remove member A
- memberAID := ""
- for _, m := range members {
- mm := m.(map[string]interface{})
- if mm["persona_id"] == personaAID {
- memberAID = mm["id"].(string)
- }
- }
- if memberAID == "" {
- t.Fatal("could not find member A in group")
- }
- w = h.request("DELETE", fmt.Sprintf("/api/v1/persona-groups/%s/members/%s", groupID, memberAID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Get group — should have 1 member
- w = h.request("GET", "/api/v1/persona-groups/"+groupID, adminToken, nil)
- decode(w, &fetched)
- members = fetched["members"].([]interface{})
- if len(members) != 1 {
- t.Fatalf("members after remove: want 1, got %d", len(members))
- }
-}
-
-func TestPersonaGroup_OwnershipCheck(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("pgown1", "pgown1@test.com")
- otherID := database.SeedTestUser(t, "pgown2", "pgown2@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), otherID)
- otherToken := makeToken(otherID, "pgown2@test.com", "user")
-
- // Admin creates a group
- w := h.request("POST", "/api/v1/persona-groups", adminToken, map[string]interface{}{
- "name": "Admin's Group",
- })
- var group map[string]interface{}
- decode(w, &group)
- groupID := group["id"].(string)
-
- // Other user tries to update — should fail
- w = h.request("PUT", "/api/v1/persona-groups/"+groupID, otherToken, map[string]interface{}{
- "name": "Stolen Group",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("update by non-owner: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Other user tries to delete — should fail
- w = h.request("DELETE", "/api/v1/persona-groups/"+groupID, otherToken, nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("delete by non-owner: want 404, got %d: %s", w.Code, w.Body.String())
- }
-
- // Other user list — should not see admin's group
- w = h.request("GET", "/api/v1/persona-groups", otherToken, nil)
- var listResp map[string]interface{}
- decode(w, &listResp)
- items := listResp["data"].([]interface{})
- if len(items) != 0 {
- t.Fatalf("non-owner should see 0 groups, got %d", len(items))
- }
-}
-
-func TestPersonaGroup_EnvelopeKey(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("pgeadm", "pgeadm@test.com")
-
- w := h.request("GET", "/api/v1/persona-groups", adminToken, nil)
- var resp map[string]interface{}
- decode(w, &resp)
-
- if _, ok := resp["groups"]; ok {
- t.Error("persona-groups list should use 'data' key, not 'groups'")
- }
- if resp["data"] == nil {
- t.Error("persona-groups list should have 'data' key")
- }
-}
-
-// ── Team Persona Tool Grants ────────────────
-
-func TestTeamPersona_ToolGrants_RoundTrip(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := h.createAdminUser("ttgadm", "ttgadm@test.com")
-
- // Create team
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "ToolGrant Team",
- })
- var team map[string]interface{}
- decode(w, &team)
- teamID := team["id"].(string)
-
- // Create team persona
- w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/personas", teamID), adminToken, map[string]interface{}{
- "name": "TG Bot", "base_model_id": "test-model",
- })
- var created map[string]interface{}
- decode(w, &created)
- personaID := created["id"].(string)
-
- // Initially empty
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("get team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
- var resp map[string]interface{}
- decode(w, &resp)
- grants := resp["data"].([]interface{})
- if len(grants) != 0 {
- t.Fatalf("initial team grants: want 0, got %d", len(grants))
- }
-
- // Set
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, map[string]interface{}{
- "tool_names": []string{"web_search", "kb_search"},
- })
- if w.Code != http.StatusOK {
- t.Fatalf("set team tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- // Read back
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamID, personaID), adminToken, nil)
- decode(w, &resp)
- grants = resp["data"].([]interface{})
- if len(grants) != 2 {
- t.Fatalf("team grants after set: want 2, got %d", len(grants))
- }
-}
-
-func TestTeamPersona_ToolGrants_ScopeCheck(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("ttgsadm", "ttgsadm@test.com")
-
- // Create two teams
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "TG Scope A",
- })
- var teamA map[string]interface{}
- decode(w, &teamA)
- teamAID := teamA["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "TG Scope B",
- })
- var teamB map[string]interface{}
- decode(w, &teamB)
- teamBID := teamB["id"].(string)
-
- // Create persona on Team A
- personaID := seedInsertReturningID(t, `
- INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
- VALUES ('TG Scope Bot', 'test-model', 'team', $1, $2, true)
- RETURNING id
- `, teamAID, adminID)
-
- // Try to read via Team B — 403
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team get tool grants: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Try to set via Team B — 403
- w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamBID, personaID), adminToken, map[string]interface{}{
- "tool_names": []string{"web_search"},
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team set tool grants: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Via correct team — 200
- w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/personas/%s/tool-grants", teamAID, personaID), adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("same-team get tool grants: want 200, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ── Team Persona Avatar ─────────────────────
-
-func TestTeamPersona_Avatar_ScopeCheck(t *testing.T) {
- h := setupHarness(t)
- adminID, adminToken := h.createAdminUser("taadm", "taadm@test.com")
-
- // Create two teams
- w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "Avatar Team A",
- })
- var teamA map[string]interface{}
- decode(w, &teamA)
- teamAID := teamA["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]interface{}{
- "name": "Avatar Team B",
- })
- var teamB map[string]interface{}
- decode(w, &teamB)
- teamBID := teamB["id"].(string)
-
- // Create persona on Team A
- personaID := seedInsertReturningID(t, `
- INSERT INTO personas (name, base_model_id, scope, owner_id, created_by, is_active)
- VALUES ('Avatar Bot', 'test-model', 'team', $1, $2, true)
- RETURNING id
- `, teamAID, adminID)
-
- // Try to delete avatar via Team B — 403
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamBID, personaID), adminToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("cross-team delete avatar: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Try to delete via correct team — should succeed (persona has no avatar, so 404 from UPDATE rows=0 is fine too)
- w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/personas/%s/avatar", teamAID, personaID), adminToken, nil)
- if w.Code != http.StatusOK && w.Code != http.StatusNotFound {
- t.Fatalf("same-team delete avatar: want 200 or 404, got %d: %s", w.Code, w.Body.String())
- }
-}
diff --git a/server/handlers/perm_enforcement_test.go b/server/handlers/perm_enforcement_test.go
deleted file mode 100644
index 5a1130c..0000000
--- a/server/handlers/perm_enforcement_test.go
+++ /dev/null
@@ -1,551 +0,0 @@
-package handlers
-
-// ══════════════════════════════════════════════════════════════════════════════
-// Permission Enforcement Tests (v0.37.1)
-//
-// Verifies that RequirePermission middleware actually blocks non-admin users
-// who lack the required permission, and allows them after the permission is
-// granted via group membership.
-//
-// Every test:
-// 1. Creates a non-admin user (no groups, TruncateAll wipes Everyone)
-// 2. Attempts the gated operation → expects 403
-// 3. Creates a group with the required permission
-// 4. Adds the user to that group
-// 5. Retries the operation → expects success (200 or 201)
-//
-// No admin users. No shortcuts.
-// ══════════════════════════════════════════════════════════════════════════════
-
-import (
- "net/http"
- "testing"
-
- "switchboard-core/database"
-)
-
-// ── Helpers ─────────────────────────────────
-
-// seedNonAdminUser creates a non-admin active user with a valid token.
-// TruncateAll wipes the Everyone group so this user has ZERO permissions.
-func seedNonAdminUser(t *testing.T) (userID, token string) {
- t.Helper()
- userID = database.SeedTestUser(t, "permuser", "permuser@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
- token = makeToken(userID, "permuser@test.com", "user")
- return
-}
-
-// seedAdminUser creates an admin user for setup operations (group creation).
-func seedAdminUser(t *testing.T) (userID, token string) {
- t.Helper()
- userID = database.SeedTestUser(t, "permadmin", "permadmin@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), userID)
- token = makeToken(userID, "permadmin@test.com", "admin")
- return
-}
-
-// grantPermission creates a group with the given permission and adds the user to it.
-// Uses admin token for group creation, returns the group ID.
-func grantPermission(t *testing.T, h *testHarness, adminToken, userID string, perm string) string {
- t.Helper()
- w := h.request("POST", "/api/v1/admin/groups", adminToken, map[string]interface{}{
- "name": "grant-" + perm,
- "scope": "global",
- "permissions": []string{perm},
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create group for %s: want 201, got %d: %s", perm, w.Code, w.Body.String())
- }
- var group map[string]interface{}
- decode(w, &group)
- groupID := group["id"].(string)
-
- w = h.request("POST", "/api/v1/admin/groups/"+groupID+"/members", adminToken, map[string]interface{}{
- "user_id": userID,
- })
- if w.Code != http.StatusCreated && w.Code != http.StatusOK {
- t.Fatalf("add member to group %s: want 200/201, got %d: %s", perm, w.Code, w.Body.String())
- }
- return groupID
-}
-
-// seedChannel creates a channel using the admin token and returns its ID.
-func seedChannel(t *testing.T, h *testHarness, adminToken string) string {
- t.Helper()
- w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
- "title": "test-channel",
- "type": "direct",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("seed channel: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var ch map[string]interface{}
- decode(w, &ch)
- return ch["id"].(string)
-}
-
-// ══════════════════════════════════════════════
-// §1 model.use — completions, summarize, title
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_ModelUse_Completions(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- adminID, adminToken := seedAdminUser(t)
- _ = adminID
-
- // User needs channel.create to make a channel for completions,
- // so grant that first (otherwise 403 on channel create, not completions).
- grantPermission(t, h, adminToken, userID, "channel.create")
-
- // Create channel as user (now allowed)
- w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
- "title": "perm-test", "type": "direct",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String())
- }
- var ch map[string]interface{}
- decode(w, &ch)
- channelID := ch["id"].(string)
-
- // Completions without model.use → 403
- w = h.request("POST", "/api/v1/chat/completions", userToken, map[string]interface{}{
- "channel_id": channelID,
- "message": "hello",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("completions without model.use: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Grant model.use
- grantPermission(t, h, adminToken, userID, "model.use")
-
- // Completions with model.use → should pass middleware (may fail downstream
- // due to no provider configured, but NOT 403)
- w = h.request("POST", "/api/v1/chat/completions", userToken, map[string]interface{}{
- "channel_id": channelID,
- "message": "hello",
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("completions WITH model.use: still got 403: %s", w.Body.String())
- }
- // Accept 400/500/502 — anything except 403 means the permission gate passed
-}
-
-// NOTE: Summarize and GenerateTitle share the model.use permission with
-// completions. Their route-level RequirePermission wiring is in main.go
-// but the test harness (setupHarness) doesn't register those routes.
-// The completions test above proves model.use enforcement works.
-// Summarize/title coverage will come when the test harness is extended.
-
-// ══════════════════════════════════════════════
-// §2 channel.create
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_ChannelCreate(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Without channel.create → 403
- w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
- "title": "blocked", "type": "direct",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("channel create without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "channel.create")
-
- w = h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
- "title": "allowed", "type": "direct",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("channel create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// §3 channel.invite
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_ChannelInvite(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Need channel.create first to have a channel to invite into
- grantPermission(t, h, adminToken, userID, "channel.create")
- w := h.request("POST", "/api/v1/channels", userToken, map[string]interface{}{
- "title": "invite-test", "type": "group",
- })
- var ch map[string]interface{}
- decode(w, &ch)
- channelID := ch["id"].(string)
-
- // Create another user to invite
- inviteeID := database.SeedTestUser(t, "invitee", "invitee@test.com")
- database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), inviteeID)
-
- // Without channel.invite → 403
- w = h.request("POST", "/api/v1/channels/"+channelID+"/participants", userToken, map[string]interface{}{
- "user_id": inviteeID,
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("invite without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "channel.invite")
-
- w = h.request("POST", "/api/v1/channels/"+channelID+"/participants", userToken, map[string]interface{}{
- "user_id": inviteeID,
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("invite WITH permission: still got 403: %s", w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// §4 kb.create, kb.read, kb.write
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_KBCreate(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Without kb.create → 403
- w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "blocked-kb",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("kb create without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "kb.create")
-
- w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "allowed-kb",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("kb create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-func TestPermEnforce_KBSearch(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Admin creates a KB for the user to search
- grantPermission(t, h, adminToken, userID, "kb.create")
- w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "search-test-kb",
- })
- var kb map[string]interface{}
- decode(w, &kb)
- kbID := kb["id"].(string)
-
- // Without kb.read → 403
- w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/search", userToken, map[string]interface{}{
- "query": "test",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("kb search without kb.read: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "kb.read")
-
- // With kb.read the middleware should pass. The handler may panic or 500
- // because the test harness has no embedder configured, but any non-403
- // response proves the permission gate opened. Use a recovery wrapper
- // to catch the nil-embedder panic gracefully.
- func() {
- defer func() {
- if r := recover(); r != nil {
- // Panic in handler (nil embedder) means middleware passed — success
- }
- }()
- w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/search", userToken, map[string]interface{}{
- "query": "test",
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("kb search WITH kb.read: still got 403: %s", w.Body.String())
- }
- }()
-}
-
-func TestPermEnforce_KBWrite(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Grant kb.create so user can create the KB
- grantPermission(t, h, adminToken, userID, "kb.create")
- w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
- "name": "write-test-kb",
- })
- var kb map[string]interface{}
- decode(w, &kb)
- kbID := kb["id"].(string)
-
- // Without kb.write → 403 on update
- w = h.request("PUT", "/api/v1/knowledge-bases/"+kbID, userToken, map[string]interface{}{
- "name": "renamed",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("kb update without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Without kb.write → 403 on delete
- w = h.request("DELETE", "/api/v1/knowledge-bases/"+kbID, userToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("kb delete without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- // Without kb.write → 403 on rebuild
- w = h.request("POST", "/api/v1/knowledge-bases/"+kbID+"/rebuild", userToken, nil)
- if w.Code != http.StatusForbidden {
- t.Fatalf("kb rebuild without kb.write: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "kb.write")
-
- // With kb.write → should pass middleware
- w = h.request("PUT", "/api/v1/knowledge-bases/"+kbID, userToken, map[string]interface{}{
- "name": "renamed-ok",
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("kb update WITH kb.write: still got 403: %s", w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// §5 persona.create, persona.manage
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_PersonaCreate(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Enable user personas policy
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Without persona.create → 403
- w := h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
- "name": "blocked-persona",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("persona create without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "persona.create")
-
- w = h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
- "name": "allowed-persona",
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("persona create WITH permission: still got 403: %s", w.Body.String())
- }
-}
-
-func TestPermEnforce_PersonaManage(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
-
- // Grant create so user can make a persona
- grantPermission(t, h, adminToken, userID, "persona.create")
- w := h.request("POST", "/api/v1/personas", userToken, map[string]interface{}{
- "name": "manage-test",
- })
- var persona map[string]interface{}
- decode(w, &persona)
- personaID := persona["id"].(string)
-
- // Without persona.manage → 403 on update
- w = h.request("PUT", "/api/v1/personas/"+personaID, userToken, map[string]interface{}{
- "name": "updated",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("persona update without persona.manage: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "persona.manage")
-
- w = h.request("PUT", "/api/v1/personas/"+personaID, userToken, map[string]interface{}{
- "name": "updated-ok",
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("persona update WITH persona.manage: still got 403: %s", w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// §6 workflow.create
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_WorkflowCreate(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Without workflow.create → 403
- w := h.request("POST", "/api/v1/workflows", userToken, map[string]interface{}{
- "name": "blocked-wf",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("workflow create without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "workflow.create")
-
- w = h.request("POST", "/api/v1/workflows", userToken, map[string]interface{}{
- "name": "allowed-wf",
- })
- if w.Code == http.StatusForbidden {
- t.Fatalf("workflow create WITH permission: still got 403: %s", w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// §7 task.create
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_TaskCreate(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- // Without task.create → 403
- w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
- "name": "blocked-task",
- "task_type": "prompt",
- "schedule": "@daily",
- "user_prompt": "test",
- "model_id": "test-model",
- })
- if w.Code != http.StatusForbidden {
- t.Fatalf("task create without permission: want 403, got %d: %s", w.Code, w.Body.String())
- }
-
- grantPermission(t, h, adminToken, userID, "task.create")
-
- w = h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
- "name": "allowed-task",
- "task_type": "prompt",
- "schedule": "@daily",
- "user_prompt": "test",
- "model_id": "test-model",
- "timezone": "UTC",
- })
- if w.Code != http.StatusCreated {
- t.Fatalf("task create WITH permission: want 201, got %d: %s", w.Code, w.Body.String())
- }
-}
-
-// ══════════════════════════════════════════════
-// §8 GET /profile/permissions (new endpoint)
-// ══════════════════════════════════════════════
-
-func TestPermEnforce_ProfilePermissions_NoGroups(t *testing.T) {
- h := setupHarness(t)
- _, userToken := seedNonAdminUser(t)
-
- // User with no groups, no Everyone group → empty permissions
- w := h.request("GET", "/api/v1/profile/permissions", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("profile/permissions: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- var resp map[string]interface{}
- decode(w, &resp)
-
- perms, ok := resp["permissions"].([]interface{})
- if !ok {
- t.Fatal("response must have 'permissions' array")
- }
- if len(perms) != 0 {
- t.Fatalf("expected 0 permissions (no groups), got %d: %v", len(perms), perms)
- }
-
- groups, ok := resp["groups"].([]interface{})
- if !ok {
- t.Fatal("response must have 'groups' array")
- }
- // Should still include Everyone group ID (even though it doesn't exist after truncate)
- if len(groups) == 0 {
- t.Fatal("groups should include Everyone group ID")
- }
-}
-
-func TestPermEnforce_ProfilePermissions_WithGrant(t *testing.T) {
- h := setupHarness(t)
- userID, userToken := seedNonAdminUser(t)
- _, adminToken := seedAdminUser(t)
-
- grantPermission(t, h, adminToken, userID, "channel.create")
- grantPermission(t, h, adminToken, userID, "model.use")
-
- w := h.request("GET", "/api/v1/profile/permissions", userToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("profile/permissions: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- var resp map[string]interface{}
- decode(w, &resp)
-
- perms, _ := resp["permissions"].([]interface{})
- permSet := make(map[string]bool)
- for _, p := range perms {
- permSet[p.(string)] = true
- }
- if !permSet["channel.create"] {
- t.Fatal("expected channel.create in permissions")
- }
- if !permSet["model.use"] {
- t.Fatal("expected model.use in permissions")
- }
- if permSet["task.create"] {
- t.Fatal("task.create should NOT be in permissions (not granted)")
- }
-}
-
-func TestPermEnforce_ProfilePermissions_Admin(t *testing.T) {
- h := setupHarness(t)
- _, adminToken := seedAdminUser(t)
-
- w := h.request("GET", "/api/v1/profile/permissions", adminToken, nil)
- if w.Code != http.StatusOK {
- t.Fatalf("profile/permissions admin: want 200, got %d: %s", w.Code, w.Body.String())
- }
-
- var resp map[string]interface{}
- decode(w, &resp)
-
- perms, _ := resp["permissions"].([]interface{})
- // Admin should get ALL permissions
- if len(perms) != len(allPermissionConstants()) {
- t.Fatalf("admin should have all %d permissions, got %d", len(allPermissionConstants()), len(perms))
- }
-}
-
-// allPermissionConstants returns the count of auth.AllPermissions.
-// Duplicated here to avoid import cycle issues in test assertions.
-func allPermissionConstants() []string {
- return []string{
- "model.use", "model.select_any",
- "kb.read", "kb.write", "kb.create",
- "channel.create", "channel.invite",
- "persona.create", "persona.manage",
- "workflow.create",
- "admin.view",
- "token.unlimited",
- "task.create", "task.admin", "task.action", "task.starlark",
- }
-}
diff --git a/server/handlers/testmain_test.go b/server/handlers/testmain_test.go
index 48f6c13..a58e692 100644
--- a/server/handlers/testmain_test.go
+++ b/server/handlers/testmain_test.go
@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"switchboard-core/database"
- "switchboard-core/providers"
_ "modernc.org/sqlite" // register sqlite driver for DB_DRIVER=sqlite
)
@@ -19,7 +18,6 @@ import (
// Set DB_DRIVER=sqlite to run against SQLite instead of Postgres.
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
- providers.Init()
teardown := database.SetupTestDB()
code := m.Run()
diff --git a/server/main.go b/server/main.go
index ce74c99..795184e 100644
--- a/server/main.go
+++ b/server/main.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
_ "embed"
- "encoding/json"
"fmt"
"log"
"net/http"
@@ -107,12 +106,6 @@ func main() {
stores = postgres.NewStores(database.DB)
}
-
- // Provider health accumulator (v0.22.0)
- if database.IsSQLite() {
- } else {
- }
-
// v0.33.0: Start Prometheus DB pool collector
metrics.StartDBCollector(database.DB, 15*time.Second)
@@ -131,25 +124,15 @@ func main() {
}
}()
- retScanner.Start()
- defer retScanner.Stop()
-
- // v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
-
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores, uekCache)
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores, uekCache)
- // Seed providers from env (dev/test only, skipped in production)
- handlers.SeedProviders(cfg, stores, keyResolver)
-
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinPackages(stores, "extensions/builtin")
- // Load search provider config from DB (defaults to DuckDuckGo if not set)
- loadSearchConfig(stores)
}
defer database.Close()
@@ -178,15 +161,6 @@ func main() {
}
handlers.SetStorageConfigured(objStore != nil)
- // Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
- // Nil if storage is disabled.
- if objStore != nil && cfg.StoragePath != "" {
- if err != nil {
- log.Printf("⚠ Extraction queue init failed: %v", err)
- } else {
- }
- }
-
// ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus()
@@ -194,15 +168,6 @@ func main() {
// No-op when running SQLite — in-process Bus is sufficient for single-pod.
events.StartPGBroadcast(bus)
- if cfg.StoragePath != "" {
- if err := wfs.Init(); err != nil {
- log.Printf("⚠ Workspace FS init failed: %v", err)
- } else {
- log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
- }
- }
-
-
// Sandboxed interpreter for extension scripts. Runner assembles
// modules based on granted permissions. Notifier attached below
// after notification service init.
@@ -253,7 +218,7 @@ func main() {
if stores.GlobalConfig != nil {
if smtpCfg, err := notifications.LoadSMTPConfig(stores.GlobalConfig, keyResolver); err == nil && smtpCfg != nil {
transport := notifications.NewEmailTransport(*smtpCfg)
- instanceName := "Chat Switchboard"
+ instanceName := "Switchboard Core"
if brandCfg, err := stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
if name, ok := brandCfg["instance_name"].(string); ok && name != "" {
instanceName = name
@@ -272,8 +237,6 @@ func main() {
defer notifSvc.StopCleanup()
}
- // v0.27.2: Task scheduler with executor — needs hub + notification service
-
// Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
@@ -419,48 +382,6 @@ func main() {
c.JSON(http.StatusOK, gin.H{"ticket": ticket})
})
- // Channels
-
- // Typing indicator broadcast (v0.23.2)
- protected.POST("/channels/:id/typing", func(c *gin.Context) {
- userID := c.GetString("user_id")
- channelID := c.Param("id")
- // Resolve display name
- var displayName string
- user, err := stores.Users.GetByID(c.Request.Context(), userID)
- if err == nil && user != nil {
- displayName = user.DisplayName
- if displayName == "" {
- displayName = user.Username
- }
- }
- if displayName == "" {
- displayName = userID[:8]
- }
- if err == nil {
- payload, _ := json.Marshal(map[string]any{
- "channel_id": channelID,
- "user_id": userID,
- "display_name": displayName,
- })
- evt := events.Event{
- Label: "typing.user",
- Payload: payload,
- Ts: time.Now().UnixMilli(),
- }
- for _, pid := range pids {
- hub.PublishToUser(pid, evt)
- }
- }
- c.JSON(200, gin.H{"ok": true})
- })
-
- // Chat Folders (v0.23.1)
- protected.GET("/folders", folders.List)
- protected.POST("/folders", folders.Create)
- protected.PUT("/folders/:id", folders.Update)
- protected.DELETE("/folders/:id", folders.Delete)
-
// Presence (v0.23.1)
presence := handlers.NewPresenceHandler(stores)
protected.POST("/presence/heartbeat", presence.Heartbeat)
@@ -469,15 +390,6 @@ func main() {
// User search (v0.23.2 — DM user picker)
protected.GET("/users/search", presence.SearchUsers)
- // Persona groups (v0.23.2 — roster templates for group chats)
- protected.GET("/persona-groups", pgH.List)
- protected.POST("/persona-groups", pgH.Create)
- protected.GET("/persona-groups/:id", pgH.Get)
- protected.PUT("/persona-groups/:id", pgH.Update)
- protected.DELETE("/persona-groups/:id", pgH.Delete)
- protected.POST("/persona-groups/:id/members", pgH.AddMember)
- protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
-
// Workflows (v0.26.1 — team-owned staged processes)
wfH := handlers.NewWorkflowHandler(stores)
protected.GET("/workflows", wfH.List)
@@ -494,30 +406,6 @@ func main() {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
- // Workflow assignments (v0.26.4 — team assignment queue)
-
- // Tasks (v0.27.1, permissions v0.27.2)
-
- // Channel models (v0.20.0 — multi-model @mention routing)
- protected.GET("/channels/:id/models", chModelH.List)
- protected.POST("/channels/:id/models", chModelH.Add)
- protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
- protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
-
- // Channel participants (v0.23.0 — ICD §3.7)
-
- // Messages
- protected.GET("/channels/:id/messages", msgs.ListMessages)
- protected.POST("/channels/:id/messages", msgs.CreateMessage)
-
- // Message tree (forking)
- protected.GET("/channels/:id/path", msgs.GetActivePath)
- protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
- protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
- protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
- protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
- protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage)
-
// Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
@@ -532,10 +420,6 @@ func main() {
protected.POST("/packages/install", userPkgH.InstallPersonalPackage)
protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage)
-
-
- // Provider Configs (user-facing — replaces /api-configs)
-
// Connection Type Discovery (v0.38.4)
connTypeH := handlers.NewConnectionTypeHandler(stores)
protected.GET("/connection-types", connTypeH.ListConnectionTypes)
@@ -549,13 +433,6 @@ func main() {
protected.PUT("/connections/:id", connH.UpdateConnection)
protected.DELETE("/connections/:id", connH.DeleteConnection)
- // Models (unified resolver — replaces scattered endpoints)
-
- // Model Preferences
- protected.GET("/models/preferences", modelPrefs.GetPreferences)
- protected.PUT("/models/preferences", modelPrefs.SetPreference)
- protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
-
// User Settings & Profile
settings := handlers.NewSettingsHandler(stores, uekCache)
protected.GET("/profile", settings.GetProfile)
@@ -572,33 +449,6 @@ func main() {
bootH := handlers.NewProfileBootstrapHandler(stores)
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
- // Usage (personal)
- protected.GET("/usage", usage.PersonalUsage)
-
- // Personas
-
- // Notes
- protected.GET("/notes", notes.List)
- protected.POST("/notes", notes.Create)
- protected.GET("/notes/search", notes.Search)
- protected.GET("/notes/search-titles", notes.SearchTitles)
- protected.GET("/notes/folders", notes.ListFolders)
- protected.GET("/notes/graph", notes.Graph)
- protected.POST("/notes/bulk-delete", notes.BulkDelete)
- protected.GET("/notes/:id", notes.Get)
- protected.PUT("/notes/:id", notes.Update)
- protected.DELETE("/notes/:id", notes.Delete)
- protected.GET("/notes/:id/backlinks", notes.Backlinks)
-
- // Projects (v0.19.0)
- protected.POST("/projects/:id/files", projFileH.UploadToProject)
- protected.GET("/projects/:id/files", projFileH.ListByProject)
- protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
- protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile)
- protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject)
- protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive)
- protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive)
-
// Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub)
protected.GET("/notifications", notifH.List)
@@ -612,28 +462,6 @@ func main() {
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
- // Git credentials (v0.21.4) — user-scoped, independent of workspace
-
-
- // Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
-
- // Data portability (v0.34.0) — user data export/import
- protected.GET("/export/me", dataExport.ExportMyData)
- protected.POST("/import/me", dataImport.ImportMyData)
- protected.POST("/import/chatgpt", dataImport.ImportChatGPT)
-
- // Hook: clean up storage files when channels are deleted
-
-
- // Memory management (v0.18.0)
- protected.GET("/memories", memH.ListMyMemories)
- protected.PUT("/memories/:id", memH.UpdateMemory)
- protected.DELETE("/memories/:id", memH.DeleteMemory)
- protected.POST("/memories/:id/approve", memH.ApproveMemory)
- protected.POST("/memories/:id/reject", memH.RejectMemory)
- protected.GET("/memories/count", memH.MemoryCount)
- protected.POST("/memories/compact", memH.CompactMemories)
-
// Teams (user: my teams)
teams := handlers.NewTeamHandler(stores, keyResolver)
protected.GET("/teams/mine", teams.MyTeams)
@@ -654,8 +482,6 @@ func main() {
// Team groups (team admins manage team-scoped groups)
teamScoped.GET("/groups", groupH.ListTeamGroups)
- // Team providers
-
// Team connections (v0.38.1)
teamScoped.GET("/connections", teams.ListTeamConnections)
teamScoped.POST("/connections", teams.CreateTeamConnection)
@@ -671,25 +497,6 @@ func main() {
teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
- // Team usage (team admins only — usage against team-owned providers)
- teamScoped.GET("/usage", teamUsage.TeamUsage)
-
- // Team personas
- teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
- teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
- teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
- teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
- teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) // v0.17.0
- teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) // v0.17.0
- teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) // v0.28.0
- teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) // v0.28.0
-
- teamScoped.GET("/roles", teamRoles.ListTeamRoles)
- teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
- teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
-
- // Team workflow assignments (v0.26.4)
-
// Team workflows — self-service (v0.31.2)
teamWfH := handlers.NewWorkflowHandler(stores)
teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows)
@@ -705,18 +512,6 @@ func main() {
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
- // Team workflow monitoring (v0.35.0)
-
-
- // Team tasks — admin CRUD (v0.27.5)
- }
-
- // Team task viewing for all members (v0.27.5)
- teamMemberRoutes := protected.Group("/teams/:teamId")
- teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
- {
- teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
- teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
}
// Public global settings (non-admin users can read safe subset)
@@ -756,30 +551,12 @@ func main() {
// Stats
admin.GET("/stats", adm.GetStats)
- // Global Provider Configs
-
// Global Connections (v0.38.1)
admin.GET("/connections", adm.ListGlobalConnections)
admin.POST("/connections", adm.CreateGlobalConnection)
admin.PUT("/connections/:id", adm.UpdateGlobalConnection)
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
- // Model Catalog
-
- // Personas (admin global)
- admin.GET("/personas", personaAdm.ListAdminPersonas)
- admin.POST("/personas", personaAdm.CreateAdminPersona)
- admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
- admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
- admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
- admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
- admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
- admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0
-
- // Admin memory review (v0.18.0)
- admin.GET("/memories/pending", adminMemH.ListPendingReview)
- admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
-
// Teams (admin)
teamAdm := handlers.NewTeamHandler(stores, keyResolver)
admin.GET("/teams", teamAdm.ListTeams)
@@ -792,10 +569,6 @@ func main() {
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
- // Team data export/import (v0.34.0)
- admin.GET("/teams/:id/export", teamExport.ExportTeam)
- admin.POST("/teams/:id/import", teamImport.ImportTeam)
-
// Admin broadcast (v0.28.6)
adminNotifH := handlers.NewNotificationHandler(stores, hub)
admin.POST("/notifications/broadcast", adminNotifH.Broadcast)
@@ -823,18 +596,8 @@ func main() {
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
- // Projects (admin — v0.19.0)
- admin.GET("/projects", adminProjH.AdminList)
- admin.DELETE("/projects/:id", adminProjH.Delete)
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
- admin.GET("/roles", rolesH.ListRoles)
- admin.GET("/roles/:role", rolesH.GetRole)
- admin.PUT("/roles/:role", rolesH.UpdateRole)
- admin.POST("/roles/:role/test", rolesH.TestRole)
-
- // Usage & Pricing
-
// Storage status
storageH := handlers.NewStorageHandler(objStore)
admin.GET("/storage/status", storageH.Status)
@@ -846,12 +609,6 @@ func main() {
emailAdm := handlers.NewAdminEmailHandler(stores)
admin.POST("/notifications/test-email", emailAdm.TestEmail)
- admin.GET("/storage/orphans", fileAdm.OrphanCount)
- admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
- admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
-
- // Archived channels (admin — v0.23.2)
-
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
@@ -873,27 +630,6 @@ func main() {
admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)
- admin.GET("/dashboard", dashAdm.GetDashboard)
-
- admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
- admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
-
- // Capability Overrides (admin — v0.22.0)
- admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
- admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
- admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
- admin.GET("/capability-overrides", capAdm.ListAllOverrides)
-
- // Provider Types (admin — v0.22.1)
- admin.GET("/provider-types", handlers.GetProviderTypes)
-
- admin.GET("/routing/policies", routingAdm.ListPolicies)
- admin.GET("/routing/policies/:id", routingAdm.GetPolicy)
- admin.POST("/routing/policies", routingAdm.CreatePolicy)
- admin.PUT("/routing/policies/:id", routingAdm.UpdatePolicy)
- admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
- admin.POST("/routing/test", routingAdm.TestRouting)
-
// Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin)
packagesDir := ""
if cfg.StoragePath != "" {
@@ -920,15 +656,10 @@ func main() {
admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
- // Package export (v0.30.0)
- admin.GET("/packages/:id/export", pkgExport.ExportPackage)
-
// Workflow package export (v0.30.2)
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
- // Workflow monitoring (v0.35.0)
-
// Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
@@ -937,20 +668,10 @@ func main() {
admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
- // Task management — admin (v0.27.1, extended v0.27.2)
- admin.GET("/tasks", taskAdm.ListAll)
- admin.POST("/tasks/:id/run", taskAdm.RunNow)
- admin.POST("/tasks/:id/kill", taskAdm.KillRun)
- admin.DELETE("/tasks/:id", taskAdm.Delete)
- admin.GET("/system-functions", taskAdm.ListSystemFunctions)
}
}
- // ── Page Routes (v0.22.5) ────────────────
- // Server-rendered pages via Go templates. Runs alongside the SPA.
- // The chat surface is a bridge: Go renders the shell, existing JS
- // builds the DOM inside it — identical behavior to index.html.
-
+ // ── Page Routes ──────────────────────────
// v0.27.0: Extension surface static assets (JS, CSS, images).
// Served without auth — same rationale as extension assets (script tags can't send headers).
// v0.28.7: on-disk path moved to /data/packages/, URL stays /surfaces/:id/ for stability.
@@ -996,32 +717,11 @@ func main() {
extAPI.Any("/*path", extAPIH.Handle)
}
- // Workflow API — session participants can send messages + trigger completions
- wfAPI := base.Group("/api/v1/w")
- wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
- {
- wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
- wfAPI.GET("/:id/messages", wfMsgs.ListMessages)
-
- wfAPI.POST("/:id/completions", wfComp.Complete)
-
- wfAPI.GET("/:id/form", wfForms.GetFormTemplate)
- wfAPI.POST("/:id/form-submit", wfForms.SubmitForm)
- }
-
- // Workflow visitor entry (v0.26.3) — public start endpoint
- // NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
- // conflicts with /api/v1/w/:id/messages (different wildcard names at
- // same path position). Separate namespace avoids the collision.
- base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
-
- // v0.28.0: Webhook trigger endpoint (token-based auth, no JWT)
-
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
- log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
+ log.Printf("🔀 Switchboard Core v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
if objStore != nil {
@@ -1038,9 +738,6 @@ func main() {
// ── Vault CLI Commands ──────────────────────
-// loadSearchConfig reads search provider config from global_config and applies it.
-// Falls back to DuckDuckGo (the default set in search package init) if not configured.
-
func runVaultCommand(subcmd string) {
cfg := config.Load()
diff --git a/server/pages/pages.go b/server/pages/pages.go
index 24f1e98..bdbf7dc 100644
--- a/server/pages/pages.go
+++ b/server/pages/pages.go
@@ -211,25 +211,10 @@ func New(cfg *config.Config, stores store.Stores) *Engine {
return e
}
-// registerCoreSurfaces populates the surface manifest list with the five
-// core surfaces. Extension surfaces will be appended at startup from
-// extension manifests (future).
+// registerCoreSurfaces populates the surface manifest list with the kernel
+// surfaces. Extension surfaces are appended from DB at startup.
func (e *Engine) registerCoreSurfaces() {
e.surfaces = []SurfaceManifest{
- {
- ID: "chat", Route: "/", AltRoutes: []string{"/chat/:chatID"},
- Title: "Chat", Template: "surface-chat", Auth: "authenticated",
- Components: []string{"chat-pane", "model-selector", "user-menu"},
- DataRequires: []string{"chat"},
- Layout: "single", Source: "core",
- },
- {
- ID: "notes", Route: "/notes", AltRoutes: []string{"/notes/:noteId"},
- Title: "Notes", Template: "surface-notes", Auth: "authenticated",
- Components: []string{"note-editor", "chat-pane"},
- DataRequires: []string{"notes"},
- Layout: "single", Source: "core",
- },
{
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
Title: "Admin", Template: "surface-admin", Auth: "admin",
@@ -248,16 +233,9 @@ func (e *Engine) registerCoreSurfaces() {
DataRequires: []string{"team-admin"},
Layout: "single", Source: "core",
},
- {
- ID: "projects", Route: "/projects/:id", AltRoutes: []string{"/projects"},
- Title: "Projects", Template: "surface-projects", Auth: "authenticated",
- DataRequires: []string{"projects"},
- Layout: "single", Source: "core",
- },
{
ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session",
- Components: []string{"chat-pane"},
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
diff --git a/server/pages/pages_surfaces.go b/server/pages/pages_surfaces.go
index 14912a0..29cadb3 100644
--- a/server/pages/pages_surfaces.go
+++ b/server/pages/pages_surfaces.go
@@ -90,7 +90,7 @@ func (e *Engine) EnabledSurfaceIDs() []string {
return ids
}
-// disabledRedirect returns a handler that redirects to the chat surface.
+// disabledRedirect returns a handler that redirects to the root.
func (e *Engine) disabledRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html
deleted file mode 100644
index d8148b7..0000000
--- a/server/pages/templates/surfaces/chat.html
+++ /dev/null
@@ -1,68 +0,0 @@
-{{/*
- Chat surface (v0.37.10 — Preact).
- Replaced the ~387-line SPA scaffold with a single mount point.
- Preact ChatSurface component handles all rendering.
-*/}}
-
-{{define "surface-chat"}}
-
-{{/* ── Crash Catcher (visible without devtools) ── */}}
-
-
-
-{{/* ── Mount Point ────────────────────────── */}}
-
-
-{{end}}
-
-{{define "css-chat"}}
-
-
-{{end}}
-
-{{define "scripts-chat"}}
-
-
-
-
-{{end}}
diff --git a/server/pages/templates/surfaces/notes.html b/server/pages/templates/surfaces/notes.html
deleted file mode 100644
index 4ef48b0..0000000
--- a/server/pages/templates/surfaces/notes.html
+++ /dev/null
@@ -1,66 +0,0 @@
-{{/*
- Notes surface (v0.37.11 — Preact).
- Replaced the v0.37.9 template mount with a single mount point.
- Preact NotesSurface component handles all rendering.
-*/}}
-
-{{define "surface-notes"}}
-
-{{/* ── Crash Catcher (visible without devtools) ── */}}
-
-
-
-{{/* ── Mount Point ────────────────────────── */}}
-
-
-{{end}}
-
-{{define "css-notes"}}
-
-
-{{end}}
-
-{{define "scripts-notes"}}
-
-
-
-
-{{end}}
diff --git a/server/pages/templates/surfaces/projects.html b/server/pages/templates/surfaces/projects.html
deleted file mode 100644
index 200eca7..0000000
--- a/server/pages/templates/surfaces/projects.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{{/*
- Projects surface — v0.37.16: Card grid + detail with inline chat.
-*/}}
-
-{{define "surface-projects"}}
-
-{{end}}
-
-{{define "css-projects"}}
-
-{{end}}
-
-{{define "scripts-projects"}}
-
-
-
-{{end}}
diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml
index aa3c0cc..a596c76 100644
--- a/server/static/openapi.yaml
+++ b/server/static/openapi.yaml
@@ -1,12492 +1,105 @@
openapi: 3.0.3
info:
- title: Chat Switchboard API
- description: 'Multi-provider AI chat platform with team workspaces, workflow automation,
-
- and extensibility via Starlark packages.
-
+ title: Switchboard Core API
+ description: |
+ Self-hosted extension platform. Identity, teams, permissions, workflows,
+ and a package system. Everything else ships as installable extensions.
## Authentication
-
Three authentication modes, selected by the `AUTH_MODE` environment variable.
-
- All three produce the same JWT-based session — downstream middleware is
-
- auth-mode-agnostic.
-
+ All three produce the same JWT-based session.
**Builtin (default):** Username/password via `POST /api/v1/auth/login`.
- Include the returned token as `Authorization: Bearer `.
+ **mTLS:** Client certificate presented via reverse proxy headers.
+ **OIDC:** Authorization code flow against an external IdP (e.g. Keycloak).
- **OIDC:** Authorization code flow via `GET /api/v1/auth/oidc/login`.
+ Access tokens expire in 15 minutes. Refresh tokens expire in 7 days.
+ Use `POST /api/v1/auth/refresh` to rotate.
- Compatible with any OpenID Connect provider (tested with Keycloak).
+ version: '${VERSION}'
-
- **mTLS:** Mutual TLS via reverse proxy headers. No explicit login — user
-
- identity is extracted from the client certificate on every request.
-
-
- ## WebSocket
-
-
- Real-time events are delivered over WebSocket at `/ws`. Obtain a single-use
-
- ticket (30s TTL) via `POST /api/v1/ws/ticket` and connect with
-
- `?ticket=`. Cross-pod safe (v0.32.0).
-
-
- ## Response Conventions
-
-
- - **List endpoints** return `{ "data": [...] }` with an array under `data`.
-
- - **Single-object endpoints** return the object directly.
-
- - **Composite endpoints** use named keys (e.g. `{ "settings": {...} }`).
-
- - **Errors** return `{ "error": "description" }`.
-
- '
- version: ${VERSION}
- contact:
- name: Chat Switchboard
servers:
-- url: /
- description: Current instance
-tags:
-- name: Auth
- description: Authentication and session management
-- name: Profile
- description: User profile, preferences, and credentials
-- name: Channels
- description: Chat channel CRUD and messaging
-- name: Messages
- description: Message CRUD, editing, regeneration, and tree navigation
-- name: Completions
- description: AI completion requests (streaming and non-streaming)
-- name: Personas
- description: AI persona CRUD, system prompts, tool config, KB bindings
-- name: Models
- description: Model catalog, preferences, and capability management
-- name: Providers
- description: Provider config CRUD (BYOK) and model fetching
-- name: Notes
- description: Obsidian-style notes with wikilinks, backlinks, and graph
-- name: Knowledge Bases
- description: Document ingestion, embedding, and semantic search
-- name: Memory
- description: Conversation memory extraction, review, and compaction
-- name: Projects
- description: Project containers for channels, KBs, notes, and files
-- name: Workspaces
- description: File workspaces with git integration
-- name: Tasks
- description: Scheduled and on-demand task execution (Go + Starlark)
-- name: Workflows
- description: Multi-stage workflow definitions, instances, and assignments
-- name: Teams
- description: Team CRUD, membership, roles, and scoped resources
-- name: Extensions
- description: Package install, permissions, secrets, and lifecycle
-- name: Surfaces
- description: Extension surface mounting and management
-- name: Notifications
- description: User notification list, preferences, and read state
-- name: Files
- description: File upload, download, and metadata
-- name: Health
- description: System health, readiness, and metrics
-- name: Admin
- description: Platform administration (requires admin role)
-- name: Usage
- description: Token usage and cost tracking
-- name: WebSocket
- description: Real-time event delivery
-- name: Data Portability
- description: Export, import, GDPR compliance
+ - url: '{scheme}://{host}{basePath}'
+ variables:
+ scheme:
+ default: https
+ host:
+ default: localhost:8080
+ basePath:
+ default: ''
+
paths:
- /api/v1/auth/register:
- post:
- tags:
- - Auth
- summary: Register a new user
- description: 'Builtin auth only. Gated by `allow_registration` policy.
-
- A unique `handle` is auto-generated from `username`.
-
- '
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - username
- - password
- - email
- properties:
- username:
- type: string
- minLength: 3
- password:
- type: string
- minLength: 8
- maxLength: 128
- email:
- type: string
- format: email
- display_name:
- type: string
- example:
- username: jdoe
- password: s3cure-p@ss
- email: jdoe@example.com
- display_name: Jane Doe
+ /health:
+ get:
+ summary: Health check
+ operationId: healthCheck
+ tags: [System]
responses:
- '201':
- description: User created and logged in
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/AuthResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '403':
- description: Registration disabled by policy
- '409':
- description: Username or email already taken
'200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
+ description: OK
+
/api/v1/auth/login:
post:
- tags:
- - Auth
- summary: Authenticate and obtain JWT tokens
- description: 'Builtin auth. Accepts `login` (username or email) and `password`.
-
- Returns an access token (15m TTL) and a refresh token (7d TTL).
-
- '
+ summary: Login (builtin auth)
+ operationId: login
+ tags: [Auth]
requestBody:
required: true
content:
application/json:
schema:
type: object
- required:
- - login
- - password
properties:
login:
type: string
- description: Username or email address
password:
type: string
- example:
- login: jdoe
- password: s3cure-p@ss
+ required: [login, password]
responses:
'200':
- description: Login successful
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/AuthResponse'
- '401':
- description: Invalid credentials
- '403':
- description: Account inactive (awaiting admin approval)
- '429':
- $ref: '#/components/responses/RateLimited'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/auth/refresh:
- post:
- tags:
- - Auth
- summary: Refresh an expired access token
- description: 'Exchanges a valid refresh token for a new token pair. The old
+ description: JWT tokens returned
- refresh token is invalidated (rotation).
-
- '
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - refresh_token
- properties:
- refresh_token:
- type: string
- responses:
- '200':
- description: New token pair
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/AuthResponse'
- '401':
- description: Invalid or expired refresh token
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/auth/logout:
+ /api/v1/auth/register:
post:
- tags:
- - Auth
- summary: Invalidate the current session
- description: Revokes the provided refresh token.
- security:
- - bearerAuth: []
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- refresh_token:
- type: string
- responses:
- '200':
- description: Logged out
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/auth/oidc/login:
- get:
- tags:
- - Auth
- summary: Initiate OIDC authorization code flow
- description: 'Redirects to the configured OIDC identity provider. Stores `state`
-
- and `nonce` for CSRF protection. Only available when `AUTH_MODE=oidc`.
-
- '
- responses:
- '302':
- description: Redirect to IdP authorization endpoint
- '500':
- description: OIDC not configured or discovery failed
- '200':
- description: Success
- content:
- application/json:
- schema:
- type: object
- /api/v1/auth/oidc/callback:
- get:
- tags:
- - Auth
- summary: OIDC authorization code callback
- description: 'Receives the authorization code from the IdP, exchanges it for tokens,
-
- validates the ID token via JWKS, and auto-provisions the user on first
-
- login. Returns an HTML fragment that passes tokens to the frontend via
-
- URL fragment.
-
- '
- parameters:
- - name: code
- in: query
- required: true
- schema:
- type: string
- - name: state
- in: query
- required: true
- schema:
- type: string
- responses:
- '200':
- description: HTML with embedded tokens
- content:
- text/html:
- schema:
- type: string
- '400':
- description: Invalid state or code
- '500':
- description: Token exchange or validation failed
- /api/v1/profile:
- get:
- tags:
- - Profile
- summary: Get current user's profile
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User profile
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserProfile'
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Profile
- summary: Update profile fields
- description: 'Partial update — only provided fields are changed.
-
- Supports `display_name` and `email`.
-
- '
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- properties:
- display_name:
- type: string
- email:
- type: string
- format: email
- example:
- display_name: Jane D.
- email: jane@newdomain.com
- responses:
- '200':
- description: Updated profile
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserProfile'
- '409':
- $ref: '#/components/responses/Conflict'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/profile/password:
- post:
- tags:
- - Profile
- summary: Change password
- description: 'Builtin auth only. Validates current password, then updates.
-
- Re-wraps the User Encryption Key (UEK) so BYOK-encrypted
-
- provider keys remain accessible.
-
- '
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - current_password
- - new_password
- properties:
- current_password:
- type: string
- new_password:
- type: string
- minLength: 8
- maxLength: 128
- responses:
- '200':
- description: Password updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- description: Current password incorrect
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/profile/avatar:
- post:
- tags:
- - Profile
- summary: Upload avatar
- description: 'Accepts a base64 data URI or raw base64 string. The server decodes,
-
- resizes to 128x128 PNG, and stores as a data URI. Max input 2 MB.
-
- '
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - image
- properties:
- image:
- type: string
- description: Base64 data URI or raw base64 string
- responses:
- '200':
- description: Avatar stored
- content:
- application/json:
- schema:
- type: object
- properties:
- avatar:
- type: string
- description: Data URI of the stored avatar
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- delete:
- tags:
- - Profile
- summary: Remove avatar
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Avatar removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/settings:
- get:
- tags:
- - Profile
- summary: Get user preferences
- description: 'Returns user-level preferences wrapped in a `settings` key.
-
- Value is `{}` if the user has never saved preferences.
-
- '
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User settings
- content:
- application/json:
- schema:
- type: object
- required:
- - settings
- properties:
- settings:
- type: object
- additionalProperties: true
- example:
- settings:
- theme: dark
- editor_keybindings: vim
- default_model: claude-sonnet-4-5
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Profile
- summary: Update user preferences
- description: 'Shallow merge — incoming keys overwrite existing, unmentioned keys
-
- are preserved. Returns the full merged settings object.
-
- '
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- additionalProperties: true
- example:
- theme: light
- font_size: 14
- responses:
- '200':
- description: Updated settings
- content:
- application/json:
- schema:
- type: object
- required:
- - settings
- properties:
- settings:
- type: object
- additionalProperties: true
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/settings/public:
- get:
- tags:
- - Profile
- summary: Get public platform settings
- description: 'Returns platform-level settings visible to authenticated users
-
- (e.g. allowed registration, default model, branding).
-
- '
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Public settings
- content:
- application/json:
- schema:
- type: object
- additionalProperties: true
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/channels:
- get:
- tags:
- - Channels
- summary: List channels accessible to the current user
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: query
- schema:
- type: string
- enum:
- - direct
- - group
- - workflow
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/PerPage'
- responses:
- '200':
- description: Channel list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- page:
- type: integer
- per_page:
- type: integer
- total:
- type: integer
- total_pages:
- type: integer
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Channels
- summary: Create a new channel
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- properties:
- title:
- type: string
- type:
- type: string
- enum:
- - direct
- - group
- default: direct
- system_prompt:
- type: string
- responses:
- '201':
- description: Channel created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}:
- get:
- tags:
- - Channels
- summary: Get channel details
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Channel details
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '404':
- $ref: '#/components/responses/NotFound'
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Channels
- summary: Update channel settings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- properties:
- title:
- type: string
- system_prompt:
- type: string
- ai_mode:
- type: string
- enum:
- - auto
- - false
- - mention_only
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Channels
- summary: Delete or leave a channel
- description: |
- Owner: deletes (or archives for retention when TTL > 0).
- Non-owner participant: leaves the channel.
- When retention_ttl_days > 0, all channels are archived and purged after TTL.
- Only Personal (BYOK) provider channels are exempt and always immediately deleted.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted, archived for retention, or left channel
- content:
- application/json:
- schema:
- type: object
- properties:
- message:
- type: string
- enum:
- - channel deleted
- - channel archived for retention
- - left channel
- purge_after:
- type: string
- format: date-time
- description: Only present when archived for retention
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/chat/completions:
- post:
- tags:
- - Completions
- summary: Send a message and get an AI completion
- description: 'Streams SSE events by default. Set `stream: false` for a single JSON
-
- response. Supports tool calling, @mentions, multi-model routing, and
-
- workflow integration.
-
- '
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/CompletionRequest'
- responses:
- '200':
- description: '**Streaming:** SSE events (`data: {"content":"..."}`, `data: [DONE]`).
-
- **Non-streaming:** OpenAI-compatible JSON response.
-
- '
- content:
- text/event-stream:
- schema:
- type: string
- application/json:
- schema:
- $ref: '#/components/schemas/CompletionResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '429':
- $ref: '#/components/responses/RateLimited'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /health:
- get:
- tags:
- - Health
- summary: Basic health check
- description: Public, no auth required. Also mirrored at `/api/v1/health`.
- responses:
- '200':
- description: System status
- content:
- application/json:
- schema:
- type: object
- properties:
- status:
- type: string
- example: ok
- version:
- type: string
- database:
- type: boolean
- schema_version:
- type: integer
- /healthz/live:
- get:
- tags:
- - Health
- summary: Kubernetes liveness probe
- responses:
- '200':
- description: Process alive
- content:
- application/json:
- schema:
- type: object
- /healthz/ready:
- get:
- tags:
- - Health
- summary: Kubernetes readiness probe
- description: Pings the database with a 2s timeout. Returns 503 if unreachable.
- responses:
- '200':
- description: Ready to serve traffic
- content:
- application/json:
- schema:
- type: object
- '503':
- description: Database unavailable
- /metrics:
- get:
- tags:
- - Health
- summary: Prometheus metrics endpoint
- description: 'Returns all `switchboard_*` metrics in Prometheus text exposition format.
-
- Includes HTTP request latency, WebSocket connections, completion tokens,
-
- DB pool stats, and provider health gauges.
-
- '
- responses:
- '200':
- description: Prometheus text format
- content:
- text/plain:
- schema:
- type: string
- /api/v1/ws/ticket:
- post:
- tags:
- - WebSocket
- summary: Obtain a WebSocket connection ticket
- description: 'Returns a single-use ticket (30s TTL) for authenticating the WebSocket
-
- upgrade at `/ws?ticket=`. Stored in PG for cross-pod validation
-
- (v0.32.0).
-
- '
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Ticket issued
- content:
- application/json:
- schema:
- type: object
- properties:
- ticket:
- type: string
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/export:
- post:
- tags:
- - Data Portability
- summary: Convert markdown to PDF or DOCX
- description: 'Uses pandoc to convert markdown content to the requested format.
-
- Returns the binary file with appropriate Content-Type.
-
- '
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - content
- - format
- properties:
- content:
- type: string
- description: Markdown content to convert
- format:
- type: string
- enum:
- - pdf
- - docx
- filename:
- type: string
- description: Output filename (without extension)
- responses:
- '200':
- description: Converted document
- content:
- application/pdf:
- schema:
- type: string
- format: binary
- application/vnd.openxmlformats-officedocument.wordprocessingml.document:
- schema:
- type: string
- format: binary
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/mark-read:
- post:
- tags:
- - Channels
- summary: Mark channel as read
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Read cursor updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/typing:
- post:
- tags:
- - Channels
- summary: Broadcast typing indicator
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Typing event sent
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/summarize:
- post:
- tags:
- - Channels
- summary: Trigger conversation summarization
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Summary created
- content:
- application/json:
- schema:
- type: object
- properties:
- message:
- type: string
- summary_id:
- type: string
- format: uuid
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/generate-title:
- post:
- tags:
- - Channels
- summary: Auto-generate channel title from first exchange
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Title generated
- content:
- application/json:
- schema:
- type: object
- properties:
- title:
- type: string
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/path:
- get:
- tags:
- - Messages
- summary: Get active message path (tree walk)
- description: Returns messages along the active branch from root to leaf.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Active message path
- content:
- application/json:
- schema:
- type: object
- properties:
- messages:
- type: array
- items:
- $ref: '#/components/schemas/Message'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/channels/{id}/messages:
- get:
- tags:
- - Messages
- summary: List all messages (flat, all branches)
- description: Debug/legacy endpoint. Returns every message regardless of branch.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/PerPage'
- responses:
- '200':
- description: All messages in channel
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Message'
- page:
- type: integer
- per_page:
- type: integer
- total:
- type: integer
- total_pages:
- type: integer
- '404':
- $ref: '#/components/responses/NotFound'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Messages
- summary: Create a message
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - role
- - content
- properties:
- role:
- type: string
- enum:
- - user
- - system
- content:
- type: string
- parent_id:
- type: string
- format: uuid
- nullable: true
- attachments:
- type: array
- items:
- type: string
- format: uuid
- responses:
- '201':
- description: Message created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Message'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/messages/{msgId}/edit:
- post:
- tags:
- - Messages
- summary: Edit a message (creates sibling branch)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: msgId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - content
- properties:
- content:
- type: string
- responses:
- '201':
- description: Sibling message created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Message'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/messages/{msgId}/regenerate:
- post:
- tags:
- - Messages
- summary: Regenerate an assistant response (creates sibling)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: msgId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- model:
- type: string
- provider_config_id:
- type: string
- format: uuid
- responses:
- '200':
- description: Streaming regeneration started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Message'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/messages/{msgId}/siblings:
- get:
- tags:
- - Messages
- summary: List siblings at a branch point
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: msgId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Sibling messages
- content:
- application/json:
- schema:
- type: object
- properties:
- siblings:
- type: array
- items:
- $ref: '#/components/schemas/Message'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/channels/{id}/cursor:
- put:
- tags:
- - Messages
- summary: Switch active branch
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - active_leaf_id
- properties:
- active_leaf_id:
- type: string
- format: uuid
- responses:
- '200':
- description: Cursor updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Message'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/tools:
- get:
- tags:
- - Completions
- summary: List available tools
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Tool definitions
- content:
- application/json:
- schema:
- type: object
- properties:
- tools:
- type: array
- items:
- $ref: '#/components/schemas/ToolDef'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/channels/{id}/models:
- get:
- tags:
- - Channels
- summary: List channel model roster
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Channel model roster
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Channels
- summary: Add model to channel roster
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - model
- - provider_config_id
- properties:
- model:
- type: string
- provider_config_id:
- type: string
- format: uuid
- display_name:
- type: string
- responses:
- '201':
- description: Model added to roster
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/models/{modelId}:
- patch:
- tags:
- - Channels
- summary: Update channel model entry
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: modelId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Channels
- summary: Remove model from channel roster
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: modelId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/channels/{id}/participants:
- get:
- tags:
- - Channels
- summary: List channel participants
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Participant list
- content:
- application/json:
- schema:
- type: object
- properties:
- participants:
- type: array
- items:
- $ref: '#/components/schemas/Participant'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Channels
- summary: Add participant to channel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - participant_type
- - participant_id
- properties:
- participant_type:
- type: string
- enum:
- - user
- - persona
- participant_id:
- type: string
- format: uuid
- role:
- type: string
- enum:
- - member
- - observer
- default: member
- responses:
- '201':
- description: Participant added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/participants/{participantId}:
- patch:
- tags:
- - Channels
- summary: Update participant role
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: participantId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- role:
- type: string
- enum:
- - member
- - observer
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Channels
- summary: Remove participant
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: participantId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/channels/{id}/knowledge-bases:
- get:
- tags:
- - Channels
- - Knowledge Bases
- summary: List KB bindings for channel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Channel KB bindings
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Channels
- - Knowledge Bases
- summary: Set channel KB bindings (replace all)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - kb_ids
- properties:
- kb_ids:
- type: array
- items:
- type: string
- format: uuid
- responses:
- '200':
- description: Bindings updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/channels/{id}/files:
- post:
- tags:
- - Files
- summary: Upload file to channel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- multipart/form-data:
- schema:
- type: object
- properties:
- file:
- type: string
- format: binary
- responses:
- '201':
- description: File uploaded
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/FileObject'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- get:
- tags:
- - Files
- summary: List files in channel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: origin
- in: query
- schema:
- type: string
- enum:
- - user_upload
- - tool_output
- - system
- responses:
- '200':
- description: Channel files
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/FileObject'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/files:
- get:
- tags:
- - Files
- summary: List all files owned by user
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/PerPage'
- responses:
- '200':
- description: User file list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/FileObject'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/files/{id}:
- get:
- tags:
- - Files
- summary: Get file metadata
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: File metadata
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/FileObject'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Files
- summary: Delete file
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/files/{id}/download:
- get:
- tags:
- - Files
- summary: Download file
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: File content
- content:
- application/octet-stream:
- schema:
- type: string
- format: binary
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/messages/{id}/files:
- get:
- tags:
- - Files
- summary: List files attached to a message
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Message files
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/FileObject'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/folders:
- get:
- tags:
- - Channels
- summary: List chat folders
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User folders
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Channels
- summary: Create folder
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - name
- properties:
- name:
- type: string
- sort_order:
- type: integer
- default: 0
- responses:
- '201':
- description: Folder created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/folders/{id}:
- put:
- tags:
- - Channels
- summary: Update folder
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Channels
- summary: Delete folder (chats become unfiled)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/presence/heartbeat:
- post:
- tags:
- - Channels
- summary: Send presence heartbeat
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Heartbeat recorded
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Channel'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/presence:
- get:
- tags:
- - Channels
- summary: Query user presence status
- security:
- - bearerAuth: []
- parameters:
- - name: users
- in: query
- required: true
- schema:
- type: string
- description: Comma-separated user UUIDs (max 100)
- responses:
- '200':
- description: Presence map
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/users/search:
- get:
- tags:
- - Channels
- summary: Search users (for DM creation and participant picker)
- security:
- - bearerAuth: []
- parameters:
- - name: q
- in: query
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Matching users (max 20)
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/personas:
- get:
- tags:
- - Personas
- summary: List user's personal personas
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Personal personas
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Personas
- summary: Create personal persona
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/PersonaInput'
- responses:
- '201':
- description: Persona created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/personas/{id}:
- put:
- tags:
- - Personas
- summary: Update personal persona
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Personas
- summary: Delete personal persona
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/personas/{id}/avatar:
- post:
- tags:
- - Personas
- summary: Upload persona avatar
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- image:
- type: string
- responses:
- '200':
- description: Avatar uploaded
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- delete:
- tags:
- - Personas
- summary: Delete persona avatar
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Avatar removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/personas/{id}/knowledge-bases:
- get:
- tags:
- - Personas
- - Knowledge Bases
- summary: Get persona KB bindings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Persona KB bindings
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Personas
- - Knowledge Bases
- summary: Set persona KB bindings (replace all)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- kb_ids:
- type: array
- items:
- type: string
- format: uuid
- auto_search:
- type: object
- additionalProperties:
- type: boolean
- responses:
- '200':
- description: Bindings updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/personas/{id}/tool-grants:
- get:
- tags:
- - Personas
- summary: Get persona tool grants
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Tool grant list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Personas
- summary: Set persona tool grants
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- tool_names:
- type: array
- items:
- type: string
- responses:
- '200':
- description: Grants updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/persona-groups:
- get:
- tags:
- - Personas
- summary: List persona groups
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Persona group list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Personas
- summary: Create persona group
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - name
- properties:
- name:
- type: string
- description:
- type: string
- responses:
- '201':
- description: Group created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/persona-groups/{id}:
- get:
- tags:
- - Personas
- summary: Get persona group with members
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Group with members
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Personas
- summary: Update persona group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Personas
- summary: Delete persona group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/persona-groups/{id}/members:
- post:
- tags:
- - Personas
- summary: Add persona to group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- required:
- - persona_id
- properties:
- persona_id:
- type: string
- format: uuid
- is_leader:
- type: boolean
- default: false
- responses:
- '201':
- description: Member added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/persona-groups/{id}/members/{memberId}:
- delete:
- tags:
- - Personas
- summary: Remove persona from group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: memberId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/models/enabled:
- get:
- tags:
- - Models
- summary: List models available to current user
- description: Primary endpoint for model selectors. Returns models, personas, and default.
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Available models with default
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- default_model:
- type: string
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/models/preferences:
- get:
- tags:
- - Models
- summary: Get user model preferences
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User model preferences
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Models
- summary: Set model preference (upsert)
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - model_id
- - provider_config_id
- properties:
- model_id:
- type: string
- provider_config_id:
- type: string
- format: uuid
- hidden:
- type: boolean
- sort_order:
- type: integer
- responses:
- '200':
- description: Preference updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/models/preferences/bulk:
- post:
- tags:
- - Models
- summary: Bulk set model visibility
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - entries
- - hidden
- properties:
- entries:
- type: array
- items:
- type: object
- properties:
- model_id:
- type: string
- provider_config_id:
- type: string
- format: uuid
- hidden:
- type: boolean
- responses:
- '200':
- description: Preferences updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/api-configs:
- get:
- tags:
- - Providers
- summary: List user's BYOK provider configs
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Provider configs (keys redacted)
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Providers
- summary: Create BYOK provider config
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfigInput'
- responses:
- '201':
- description: Config created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/api-configs/{id}:
- get:
- tags:
- - Providers
- summary: Get provider config (key redacted)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Provider config
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Providers
- summary: Update provider config
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Providers
- summary: Delete provider config
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/api-configs/{id}/models:
- get:
- tags:
- - Providers
- summary: List models for a provider config
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Model catalog entries
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/api-configs/{id}/models/fetch:
- post:
- tags:
- - Providers
- summary: Sync models from provider API
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Models synced
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/notes:
- get:
- tags:
- - Notes
- summary: List notes (paginated)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/Page'
- - $ref: '#/components/parameters/PerPage'
- - name: folder
- in: query
- schema:
- type: string
- responses:
- '200':
- description: Note list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Note'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Notes
- summary: Create note
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - title
- properties:
- title:
- type: string
- content:
- type: string
- folder:
- type: string
- responses:
- '201':
- description: Note created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Note'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/notes/{id}:
- get:
- tags:
- - Notes
- summary: Get note
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Full note object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Note'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Notes
- summary: Update note
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Note'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Notes
- summary: Delete note
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/notes/{id}/backlinks:
- get:
- tags:
- - Notes
- summary: Get notes that link to this note
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Backlink list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Note'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/notes/search:
- get:
- tags:
- - Notes
- summary: Full-text search notes
- security:
- - bearerAuth: []
- parameters:
- - name: q
- in: query
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Search results with highlights
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Note'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notes/search-titles:
- get:
- tags:
- - Notes
- summary: Title search (for wikilink autocomplete)
- security:
- - bearerAuth: []
- parameters:
- - name: q
- in: query
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Matching titles
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Note'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notes/graph:
- get:
- tags:
- - Notes
- summary: Get note graph topology
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Graph with nodes, edges, and unresolved links
- content:
- application/json:
- schema:
- type: object
- properties:
- nodes:
- type: array
- items:
- type: object
- properties:
- id:
- type: string
- format: uuid
- title:
- type: string
- edges:
- type: array
- items:
- type: object
- properties:
- source:
- type: string
- format: uuid
- target:
- type: string
- format: uuid
- unresolved:
- type: array
- items:
- type: object
- properties:
- source:
- type: string
- format: uuid
- target_title:
- type: string
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notes/folders:
- get:
- tags:
- - Notes
- summary: List distinct note folder names
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Folder list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Note'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notes/bulk-delete:
- post:
- tags:
- - Notes
- summary: Bulk delete notes
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - ids
- properties:
- ids:
- type: array
- items:
- type: string
- format: uuid
- responses:
- '200':
- description: Notes deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Note'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/knowledge-bases:
- get:
- tags:
- - Knowledge Bases
- summary: List knowledge bases
- security:
- - bearerAuth: []
- responses:
- '200':
- description: KB list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/KnowledgeBase'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Knowledge Bases
- summary: Create knowledge base
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - name
- properties:
- name:
- type: string
- description:
- type: string
- scope:
- type: string
- enum:
- - personal
- - team
- - global
- default: personal
- team_id:
- type: string
- format: uuid
- responses:
- '201':
- description: KB created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/KnowledgeBase'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/knowledge-bases/{id}:
- get:
- tags:
- - Knowledge Bases
- summary: Get knowledge base
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: KB object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/KnowledgeBase'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Knowledge Bases
- summary: Update knowledge base
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/KnowledgeBase'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Knowledge Bases
- summary: Delete knowledge base
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/knowledge-bases/{id}/documents:
- get:
- tags:
- - Knowledge Bases
- summary: List KB documents
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Document list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/KnowledgeBase'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Knowledge Bases
- summary: Upload document to KB
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- multipart/form-data:
- schema:
- type: object
- properties:
- file:
- type: string
- format: binary
- responses:
- '202':
- description: Document accepted for processing
- content:
- application/json:
- schema:
- type: object
- '412':
- description: No embedding model configured
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/knowledge-bases/{id}/documents/{docId}/status:
- get:
- tags:
- - Knowledge Bases
- summary: Get document processing status
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: docId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Document status
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/KnowledgeBase'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/knowledge-bases/{id}/documents/{docId}:
- delete:
- tags:
- - Knowledge Bases
- summary: Delete KB document
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: docId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/knowledge-bases/{id}/search:
- post:
- tags:
- - Knowledge Bases
- summary: Semantic search within KB
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - query
- properties:
- query:
- type: string
- limit:
- type: integer
- default: 5
- maximum: 20
- threshold:
- type: number
- default: 0.3
- responses:
- '200':
- description: Search results with similarity scores
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/KnowledgeBase'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/knowledge-bases/{id}/rebuild:
- post:
- tags:
- - Knowledge Bases
- summary: Re-chunk and re-embed all documents
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '202':
- description: Rebuild started
- content:
- application/json:
- schema:
- type: object
- properties:
- message:
- type: string
- total_docs:
- type: integer
- rebuilding:
- type: integer
- skipped:
- type: integer
- '503':
- description: Ingestion pipeline not configured
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/knowledge-bases-discoverable:
- get:
- tags:
- - Knowledge Bases
- summary: List discoverable KBs (for picker UI)
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Discoverable KB list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/KnowledgeBase'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/knowledge-bases/{id}/discoverable:
- put:
- tags:
- - Knowledge Bases
- summary: Toggle KB discoverability
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- discoverable:
- type: boolean
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/KnowledgeBase'
- '403':
- description: Only owner or admin can change
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/memories:
- get:
- tags:
- - Memory
- summary: List user's memories
- security:
- - bearerAuth: []
- parameters:
- - name: status
- in: query
- schema:
- type: string
- enum:
- - active
- - pending_review
- - archived
- default: active
- - name: query
- in: query
- schema:
- type: string
- responses:
- '200':
- description: Memory list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Memory'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/memories/{id}:
- put:
- tags:
- - Memory
- summary: Update memory
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- key:
- type: string
- value:
- type: string
- confidence:
- type: number
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Memory'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Memory
- summary: Delete memory
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/memories/{id}/approve:
- post:
- tags:
- - Memory
- summary: Approve a pending memory
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Approved
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Memory'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/memories/{id}/reject:
- post:
- tags:
- - Memory
- summary: Reject a pending memory
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Rejected
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Memory'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/memories/count:
- get:
- tags:
- - Memory
- summary: Get memory counts by status
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Memory counts
- content:
- application/json:
- schema:
- type: object
- properties:
- active:
- type: integer
- pending:
- type: integer
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/memories/compact:
- post:
- tags:
- - Memory
- summary: Compact memories
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Compaction triggered
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Memory'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/projects:
- get:
- tags:
- - Projects
- summary: List projects
- security:
- - bearerAuth: []
- parameters:
- - name: include_archived
- in: query
- schema:
- type: boolean
- default: false
- responses:
- '200':
- description: Project list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Project'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Projects
- summary: Create project
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - name
- properties:
- name:
- type: string
- maxLength: 200
- description:
- type: string
- color:
- type: string
- icon:
- type: string
- responses:
- '201':
- description: Project created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Project'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/projects/{id}:
- get:
- tags:
- - Projects
- summary: Get project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Project object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Project'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Projects
- summary: Update project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Project'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Projects
- summary: Delete project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/projects/{id}/channels:
- get:
- tags:
- - Projects
- summary: List project channels
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Project channels
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Projects
- summary: Add channel to project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- required:
- - channel_id
- properties:
- channel_id:
- type: string
- format: uuid
- position:
- type: integer
- responses:
- '200':
- description: Channel added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '403':
- $ref: '#/components/responses/Forbidden'
- /api/v1/projects/{id}/channels/{channelId}:
- delete:
- tags:
- - Projects
- summary: Remove channel from project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: channelId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/projects/{id}/channels/reorder:
- put:
- tags:
- - Projects
- summary: Reorder project channels
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- channel_ids:
- type: array
- items:
- type: string
- format: uuid
- responses:
- '200':
- description: Reordered
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Project'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/projects/{id}/knowledge-bases:
- get:
- tags:
- - Projects
- - Knowledge Bases
- summary: List project KB associations
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Project KBs
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Project'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Projects
- - Knowledge Bases
- summary: Add KB to project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- required:
- - kb_id
- properties:
- kb_id:
- type: string
- format: uuid
- auto_search:
- type: boolean
- default: false
- responses:
- '201':
- description: KB added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Project'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/projects/{id}/knowledge-bases/{kbId}:
- delete:
- tags:
- - Projects
- - Knowledge Bases
- summary: Remove KB from project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: kbId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/projects/{id}/notes:
- get:
- tags:
- - Projects
- - Notes
- summary: List project note associations
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Project notes
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Project'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Projects
- - Notes
- summary: Add note to project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- required:
- - note_id
- properties:
- note_id:
- type: string
- format: uuid
- responses:
- '201':
- description: Note added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Project'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/projects/{id}/notes/{noteId}:
- delete:
- tags:
- - Projects
- - Notes
- summary: Remove note from project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: noteId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/projects/{id}/files:
- get:
- tags:
- - Projects
- - Files
- summary: List project files (workspace-backed)
- description: Returns workspace files for the project. Auto-creates workspace on first upload.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: path
- in: query
- schema:
- type: string
- description: Directory path to list (empty = root)
- - name: recursive
- in: query
- schema:
- type: string
- default: 'true'
- description: Include subdirectories
- responses:
- '200':
- description: Project files
- content:
- application/json:
- schema:
- type: object
- properties:
- files:
- type: array
- items:
- type: object
- properties:
- id:
- type: string
- path:
- type: string
- is_directory:
- type: boolean
- content_type:
- type: string
- size_bytes:
- type: integer
- count:
- type: integer
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Projects
- - Files
- summary: Upload file to project workspace
- description: Uploads a file to the project's workspace. Auto-creates workspace on first upload.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: path
- in: query
- schema:
- type: string
- description: Subdirectory path to upload into
- requestBody:
- content:
- multipart/form-data:
- schema:
- type: object
- properties:
- file:
- type: string
- format: binary
- responses:
- '201':
- description: File uploaded
- content:
- application/json:
- schema:
- type: object
- properties:
- path:
- type: string
- filename:
- type: string
- content_type:
- type: string
- size_bytes:
- type: integer
- '413':
- description: File too large or workspace quota exceeded
- '401':
- $ref: '#/components/responses/Unauthorized'
- delete:
- tags:
- - Projects
- - Files
- summary: Delete project file
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: path
- in: query
- required: true
- schema:
- type: string
- - name: recursive
- in: query
- schema:
- type: string
- responses:
- '200':
- description: File deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/projects/{id}/files/download:
- get:
- tags:
- - Projects
- - Files
- summary: Download project file
- description: Streams raw file content with Content-Disposition attachment header.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: path
- in: query
- required: true
- schema:
- type: string
- responses:
- '200':
- description: File content
- content:
- application/octet-stream:
- schema:
- type: string
- format: binary
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/projects/{id}/files/mkdir:
- post:
- tags:
- - Projects
- - Files
- summary: Create directory in project workspace
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- path:
- type: string
- required:
- - path
- responses:
- '201':
- description: Directory created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/projects/{id}/archive/upload:
- post:
- tags:
- - Projects
- - Files
- summary: Upload and extract archive into project workspace
- description: Accepts .zip or .tar.gz, extracts contents into workspace root.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- multipart/form-data:
- schema:
- type: object
- properties:
- file:
- type: string
- format: binary
- responses:
- '200':
- description: Archive extracted
- content:
- application/json:
- schema:
- type: object
- properties:
- ok:
- type: boolean
- files_extracted:
- type: integer
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/projects/{id}/archive/download:
- get:
- tags:
- - Projects
- - Files
- summary: Download all project files as archive
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: format
- in: query
- schema:
- type: string
- enum: [zip, tar.gz, tgz]
- default: zip
- responses:
- '200':
- description: Archive download
- content:
- application/zip:
- schema:
- type: string
- format: binary
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces:
- get:
- tags:
- - Workspaces
- summary: List workspaces
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Workspace list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Workspaces
- summary: Create workspace
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - name
- properties:
- name:
- type: string
- owner_type:
- type: string
- enum:
- - user
- - project
- - channel
- - team
- owner_id:
- type: string
- format: uuid
- responses:
- '201':
- description: Workspace created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/default:
- get:
- tags:
- - Workspaces
- summary: Get or create user's default workspace
- description: Returns the current user's personal "My Files" workspace, auto-creating it on first access. Idempotent.
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Default workspace
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/workspaces/{id}:
- get:
- tags:
- - Workspaces
- summary: Get workspace
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Workspace object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- patch:
- tags:
- - Workspaces
- summary: Update workspace
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Workspaces
- summary: Delete workspace
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/files:
- get:
- tags:
- - Workspaces
- summary: List workspace files
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: File tree
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/files/read:
- get:
- tags:
- - Workspaces
- summary: Read file content
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: path
- in: query
- required: true
- schema:
- type: string
- responses:
- '200':
- description: File content
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/files/write:
- put:
- tags:
- - Workspaces
- summary: Write file content
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: File written
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/files/delete:
- delete:
- tags:
- - Workspaces
- summary: Delete workspace file
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/files/mkdir:
- post:
- tags:
- - Workspaces
- summary: Create directory
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Directory created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/archive/upload:
- post:
- tags:
- - Workspaces
- summary: Upload archive (zip/tar.gz) to workspace
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Archive extracted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/archive/download:
- get:
- tags:
- - Workspaces
- summary: Download workspace as archive
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Archive download
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/reconcile:
- post:
- tags:
- - Workspaces
- summary: Reconcile workspace state
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Reconciled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/stats:
- get:
- tags:
- - Workspaces
- summary: Get workspace statistics
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Workspace stats
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/index-status:
- get:
- tags:
- - Workspaces
- summary: Get full-text index status
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Index status
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/git/clone:
- post:
- tags:
- - Workspaces
- summary: Clone git remote into workspace
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Clone started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/git/pull:
- post:
- tags:
- - Workspaces
- summary: Pull from remote
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Pull complete
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/git/push:
- post:
- tags:
- - Workspaces
- summary: Push to remote
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Push complete
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/git/status:
- get:
- tags:
- - Workspaces
- summary: Git status
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Working tree status
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/git/diff:
- get:
- tags:
- - Workspaces
- summary: Git diff
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Diff output
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/git/commit:
- post:
- tags:
- - Workspaces
- summary: Commit changes
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Committed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workspaces/{id}/git/log:
- get:
- tags:
- - Workspaces
- summary: Git log
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Commit history
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/git/branches:
- get:
- tags:
- - Workspaces
- summary: List git branches
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Branch list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workspaces/{id}/git/checkout:
- post:
- tags:
- - Workspaces
- summary: Checkout branch
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Checked out
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/git-credentials:
- get:
- tags:
- - Workspaces
- summary: List git credentials
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Credential list (keys redacted)
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Workspaces
- summary: Create git credential
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Credential created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/git-credentials/generate:
- post:
- tags:
- - Workspaces
- summary: Generate SSH key pair
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Key pair generated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workspace'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/git-credentials/{id}/public-key:
- get:
- tags:
- - Workspaces
- summary: Get SSH public key
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Public key
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workspace'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/git-credentials/{id}:
- delete:
- tags:
- - Workspaces
- summary: Delete git credential
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/tasks:
- get:
- tags:
- - Tasks
- summary: List user's tasks
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Task list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Task'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Tasks
- summary: Create task
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Task created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/tasks/{id}:
- get:
- tags:
- - Tasks
- summary: Get task
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Task object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Tasks
- summary: Update task
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Tasks
- summary: Delete task
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/tasks/{id}/runs:
- get:
- tags:
- - Tasks
- summary: List task runs
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Run history
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Task'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/tasks/{id}/run:
- post:
- tags:
- - Tasks
- summary: Execute task immediately
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Run started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/tasks/{id}/kill:
- post:
- tags:
- - Tasks
- summary: Kill running task
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Task killed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflows:
- get:
- tags:
- - Workflows
- summary: List workflows
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Workflow list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Workflows
- summary: Create workflow
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Workflow created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflows/{id}:
- get:
- tags:
- - Workflows
- summary: Get workflow
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Workflow object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- patch:
- tags:
- - Workflows
- summary: Update workflow
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Workflows
- summary: Delete workflow
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workflows/{id}/stages:
- get:
- tags:
- - Workflows
- summary: List workflow stages
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Stage list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Workflows
- summary: Create workflow stage
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Stage created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflows/{id}/stages/{sid}:
- put:
- tags:
- - Workflows
- summary: Update workflow stage
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: sid
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Workflows
- summary: Delete workflow stage
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: sid
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workflows/{id}/stages/reorder:
- patch:
- tags:
- - Workflows
- summary: Reorder workflow stages
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Reordered
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workflows/{id}/publish:
- post:
- tags:
- - Workflows
- summary: Publish workflow version
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Published
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflows/{id}/versions/{version}:
- get:
- tags:
- - Workflows
- summary: Get specific workflow version
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: version
- in: path
- required: true
- schema:
- type: integer
- responses:
- '200':
- description: Version snapshot
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workflows/{id}/start:
- post:
- tags:
- - Workflows
- summary: Start workflow instance
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Instance started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/workflow/status:
- get:
- tags:
- - Workflows
- summary: Get workflow status for channel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Workflow instance status
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/channels/{id}/workflow/advance:
- post:
- tags:
- - Workflows
- summary: Advance workflow to next stage
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Advanced
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/channels/{id}/workflow/reject:
- post:
- tags:
- - Workflows
- summary: Reject and return to previous stage
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Rejected
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflow-assignments/mine:
- get:
- tags:
- - Workflows
- summary: List assignments for current user
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Assignment list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/workflow-assignments/{id}:
- get:
- tags:
- - Workflows
- summary: Get assignment details
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Assignment details
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/workflow-assignments/{id}/claim:
- post:
- tags:
- - Workflows
- summary: Claim an assignment
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Claimed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflow-assignments/{id}/complete:
- post:
- tags:
- - Workflows
- summary: Complete an assignment
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Completed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflow-assignments/{id}/comment:
- post:
- tags:
- - Workflows
- summary: Add comment to assignment
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Comment added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/w/{id}/form:
- get:
- tags:
- - Workflows
- summary: Get form template for workflow stage
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Form template JSON
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- /api/v1/w/{id}/form-submit:
- post:
- tags:
- - Workflows
- summary: Submit form data for workflow stage
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Form submitted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/workflow-entry/{scope}/{slug}:
- post:
- tags:
- - Workflows
- summary: Start workflow as visitor
- parameters:
- - name: scope
- in: path
- required: true
- schema:
- type: string
- - name: slug
- in: path
- required: true
- schema:
- type: string
- responses:
- '201':
- description: Workflow instance started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/notifications:
- get:
- tags:
- - Notifications
- summary: List notifications
- security:
- - bearerAuth: []
- parameters:
- - name: limit
- in: query
- schema:
- type: integer
- default: 20
- maximum: 100
- - name: offset
- in: query
- schema:
- type: integer
- default: 0
- - name: unread_only
- in: query
- schema:
- type: boolean
- default: false
- responses:
- '200':
- description: Notification list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Notification'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notifications/unread-count:
- get:
- tags:
- - Notifications
- summary: Get unread notification count
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Unread count
- content:
- application/json:
- schema:
- type: object
- properties:
- count:
- type: integer
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notifications/{id}/read:
- patch:
- tags:
- - Notifications
- summary: Mark notification as read
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Marked read
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Notification'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/notifications/mark-all-read:
- post:
- tags:
- - Notifications
- summary: Mark all notifications as read
- security:
- - bearerAuth: []
- responses:
- '200':
- description: All marked read
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Notification'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/notifications/{id}:
- delete:
- tags:
- - Notifications
- summary: Delete notification
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/notifications/preferences:
- get:
- tags:
- - Notifications
- summary: List notification preferences
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Preference list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Notification'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/notifications/preferences/{type}:
- put:
- tags:
- - Notifications
- summary: Set notification preference for type
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: path
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- in_app:
- type: boolean
- email:
- type: boolean
- responses:
- '200':
- description: Preference set
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Notification'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- delete:
- tags:
- - Notifications
- summary: Reset notification preference to default
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Reset to default
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/extensions:
- get:
- tags:
- - Extensions
- summary: List user-visible extensions
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Extension list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/extensions/{id}/settings:
- post:
- tags:
- - Extensions
- summary: Update user extension settings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Settings updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/extensions/{id}/manifest:
- get:
- tags:
- - Extensions
- summary: Get extension manifest
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Manifest JSON
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/extensions/tools:
- get:
- tags:
- - Extensions
- summary: List browser tool schemas from extensions
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Tool schema list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/surfaces:
- get:
- tags:
- - Surfaces
- summary: List enabled surface packages
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Surface list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Surface'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/packages:
- get:
- tags:
- - Extensions
- summary: List packages visible to user
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Package list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/packages/install:
- post:
- tags:
- - Extensions
- summary: Install personal package
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Package installed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/packages/{id}:
- delete:
- tags:
- - Extensions
- summary: Delete personal package
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/users:
- get:
- tags:
- - Admin
- summary: List all users
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User list
- content:
- application/json:
- schema: &id005
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/AuthUser'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- summary: Create user
- security:
- - bearerAuth: []
+ summary: Register new user (builtin auth)
+ operationId: register
+ tags: [Auth]
responses:
'201':
description: User created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/users/{id}/role:
- put:
- tags:
- - Admin
- summary: Update user role
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Role updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/users/{id}/active:
- put:
- tags:
- - Admin
- summary: Toggle user active status
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Status toggled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/users/{id}/reset-password:
- post:
- tags:
- - Admin
- summary: Reset user password
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Password reset
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/users/{id}/vault/reset:
- post:
- tags:
- - Admin
- summary: Reset user vault (BYOK keys lost)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Vault reset
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/users/{id}:
- delete:
- tags:
- - Admin
- summary: Delete user
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: User deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/users/{id}/permissions:
- get:
- tags:
- - Admin
- summary: Get effective permissions for user
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Permission list
- content:
- application/json:
- schema: &id003
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/settings:
- get:
- tags:
- - Admin
- summary: List all global settings
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Settings list
- content:
- application/json:
- schema: &id001
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/settings/{key}:
- get:
- tags:
- - Admin
- summary: Get global setting by key
- security:
- - bearerAuth: []
- parameters:
- - name: key
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Setting value
- content:
- application/json:
- schema: *id001
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Admin
- summary: Update global setting
- security:
- - bearerAuth: []
- parameters:
- - name: key
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Setting updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/configs:
- get:
- tags:
- - Admin
- - Providers
- summary: List global provider configs
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Provider config list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- - Providers
- summary: Create global provider config
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Config created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/configs/{id}:
- put:
- tags:
- - Admin
- - Providers
- summary: Update global provider config
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Providers
- summary: Delete global provider config
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/models:
- get:
- tags:
- - Admin
- - Models
- summary: List model catalog
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Model catalog
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/models/fetch:
- post:
- tags:
- - Admin
- - Models
- summary: Sync models from provider APIs
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Models synced
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/models/bulk:
- put:
- tags:
- - Admin
- - Models
- summary: Bulk update model visibility
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Bulk updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/models/{id}:
- put:
- tags:
- - Admin
- - Models
- summary: Update model config
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Models
- summary: Delete model from catalog
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/models/{id}/capabilities:
- get:
- tags:
- - Admin
- - Models
- summary: Get model capability overrides
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Capabilities
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Models
- summary: Set model capability override
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Override set
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/models/{id}/capabilities/{overrideId}:
- delete:
- tags:
- - Admin
- - Models
- summary: Delete capability override
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: overrideId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/capability-overrides:
- get:
- tags:
- - Admin
- - Models
- summary: List all capability overrides
- security:
- - bearerAuth: []
- responses:
- '200':
- description: All overrides
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/provider-types:
- get:
- tags:
- - Admin
- - Providers
- summary: List supported provider types
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Provider type registry
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/personas:
- get:
- tags:
- - Admin
- - Personas
- summary: List global personas
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Global persona list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- - Personas
- summary: Create global persona
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Persona created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/personas/{id}:
- put:
- tags:
- - Admin
- - Personas
- summary: Update global persona
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Personas
- summary: Delete global persona
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/personas/{id}/avatar:
- post:
- tags:
- - Admin
- - Personas
- summary: Upload global persona avatar
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Avatar uploaded
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- delete:
- tags:
- - Admin
- - Personas
- summary: Delete global persona avatar
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Avatar removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/personas/{id}/knowledge-bases:
- get:
- tags:
- - Admin
- - Personas
- summary: Get global persona KB bindings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: KB bindings
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Personas
- summary: Set global persona KB bindings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Bindings updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/personas/{id}/tool-grants:
- get:
- tags:
- - Admin
- - Personas
- summary: Get global persona tool grants
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Tool grants
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Persona'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Personas
- summary: Set global persona tool grants
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Grants updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Persona'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/memories/pending:
- get:
- tags:
- - Admin
- - Memory
- summary: List pending memories for review
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Pending memories
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Memory'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/memories/bulk-approve:
- post:
- tags:
- - Admin
- - Memory
- summary: Bulk approve pending memories
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Memories approved
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Memory'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/teams:
- get:
- tags:
- - Admin
- - Teams
- summary: List all teams
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Team list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- - Teams
- summary: Create team
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Team created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/teams/{id}:
- get:
- tags:
- - Admin
- - Teams
- summary: Get team
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Team object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Teams
- summary: Update team
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Teams
- summary: Delete team
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/teams/{id}/members:
- get:
- tags:
- - Admin
- - Teams
- summary: List team members
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Member list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Admin
- - Teams
- summary: Add team member
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Member added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/teams/{id}/members/{memberId}:
- put:
- tags:
- - Admin
- - Teams
- summary: Update team member
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: memberId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Teams
- summary: Remove team member
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: memberId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/teams/{id}/export:
- get:
- tags:
- - Admin
- - Teams
- - Data Portability
- summary: Export team data
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Team data archive
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/teams/{id}/import:
- post:
- tags:
- - Admin
- - Teams
- - Data Portability
- summary: Import team data
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Team data imported
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/notifications/broadcast:
- post:
- tags:
- - Admin
- - Notifications
- summary: Broadcast notification to all users
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required:
- - title
- - message
- properties:
- title:
- type: string
- message:
- type: string
- level:
- type: string
- enum:
- - info
- - warning
- - critical
- default: info
- responses:
- '200':
- description: Broadcast sent
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Notification'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/notifications/test-email:
- post:
- tags:
- - Admin
- - Notifications
- summary: Send test email to admin
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Test email sent
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Notification'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/audit:
- get:
- tags:
- - Admin
- summary: List platform audit log
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Audit entries
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/audit/actions:
- get:
- tags:
- - Admin
- summary: List distinct audit actions
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Action list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: string
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/groups:
- get:
- tags:
- - Admin
- summary: List permission groups
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Group list
- content:
- application/json:
- schema: &id002
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- summary: Create permission group
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Group created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/groups/{id}:
- get:
- tags:
- - Admin
- summary: Get permission group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Group object
- content:
- application/json:
- schema: *id002
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- summary: Update permission group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- summary: Delete permission group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/groups/{id}/members:
- get:
- tags:
- - Admin
- summary: List group members
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Member list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Admin
- summary: Add user to group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Member added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/groups/{id}/members/{userId}:
- delete:
- tags:
- - Admin
- summary: Remove user from group
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: userId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/permissions:
- get:
- tags:
- - Admin
- summary: List all available permissions
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Permission list
- content:
- application/json:
- schema: *id003
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/grants/{type}/{id}:
- get:
- tags:
- - Admin
- summary: Get resource grant
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: path
- required: true
- schema:
- type: string
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Grant object
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- summary: Set resource grant
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: path
- required: true
- schema:
- type: string
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Grant set
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- summary: Delete resource grant
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: path
- required: true
- schema:
- type: string
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/roles:
- get:
- tags:
- - Admin
- summary: List roles
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Role list
- content:
- application/json:
- schema: &id004
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/roles/{role}:
- get:
- tags:
- - Admin
- summary: Get role definition
- security:
- - bearerAuth: []
- parameters:
- - name: role
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Role object
- content:
- application/json:
- schema: *id004
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Admin
- summary: Update role permissions
- security:
- - bearerAuth: []
- parameters:
- - name: role
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/roles/{role}/test:
- post:
- tags:
- - Admin
- summary: Test role permission check
- security:
- - bearerAuth: []
- parameters:
- - name: role
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Test result
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/usage/users/{id}:
- get:
- tags:
- - Admin
- - Usage
- summary: Usage for specific user
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: User usage
- content:
- application/json:
- schema: *id005
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/usage/teams/{id}:
- get:
- tags:
- - Admin
- - Usage
- summary: Usage for specific team
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Team usage
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/pricing:
- get:
- tags:
- - Admin
- - Usage
- summary: List pricing overrides
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Pricing list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- put:
- tags:
- - Admin
- - Usage
- summary: Upsert pricing override
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Pricing set
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/pricing/{provider}/{model}:
- delete:
- tags:
- - Admin
- - Usage
- summary: Delete pricing override
- security:
- - bearerAuth: []
- parameters:
- - name: provider
- in: path
- required: true
- schema:
- type: string
- - name: model
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/storage/status:
- get:
- tags:
- - Admin
- summary: Storage backend status
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Storage status
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/storage/orphans:
- get:
- tags:
- - Admin
- summary: Count orphaned storage objects
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Orphan count
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/storage/cleanup:
- post:
- tags:
- - Admin
- summary: Clean up orphaned storage objects
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Cleanup results
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/storage/extraction:
- get:
- tags:
- - Admin
- summary: Text extraction pipeline status
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Extraction status
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/vault/status:
- get:
- tags:
- - Admin
- summary: Vault encryption status
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Vault status
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/channels/archived:
- get:
- tags:
- - Admin
- summary: List archived channels
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Archived channel list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Channel'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/channels/{id}/purge:
- delete:
- tags:
- - Admin
- summary: Permanently purge archived channel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Purged
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/extensions:
- get:
- tags:
- - Admin
- - Extensions
- summary: List all extensions
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Extension list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- - Extensions
- summary: Install extension
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Installed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/extensions/{id}:
- put:
- tags:
- - Admin
- - Extensions
- summary: Update extension
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Extensions
- summary: Uninstall extension
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Uninstalled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/extensions/{id}/permissions:
- get:
- tags:
- - Admin
- - Extensions
- summary: List extension permissions
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Permission list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/extensions/{id}/review:
- get:
- tags:
- - Admin
- - Extensions
- summary: Review package permissions
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Review summary
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/extensions/{id}/permissions/{perm}/grant:
- post:
- tags:
- - Admin
- - Extensions
- summary: Grant extension permission
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: perm
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Granted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/extensions/{id}/permissions/{perm}/revoke:
- post:
- tags:
- - Admin
- - Extensions
- summary: Revoke extension permission
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- - name: perm
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Revoked
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/extensions/{id}/permissions/grant-all:
- post:
- tags:
- - Admin
- - Extensions
- summary: Grant all requested permissions
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: All granted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/extensions/{id}/secrets:
- get:
- tags:
- - Admin
- - Extensions
- summary: Get extension secrets (redacted)
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Secret keys (values redacted)
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Extensions
- summary: Set extension secrets
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Secrets set
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Extensions
- summary: Delete extension secrets
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Secrets deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/routing/policies:
- get:
- tags:
- - Admin
- - Providers
- summary: List routing policies
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Policy list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- post:
- tags:
- - Admin
- - Providers
- summary: Create routing policy
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Policy created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/routing/policies/{id}:
- get:
- tags:
- - Admin
- - Providers
- summary: Get routing policy
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Policy object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Providers
- summary: Update routing policy
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Providers
- summary: Delete routing policy
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/routing/test:
- post:
- tags:
- - Admin
- - Providers
- summary: Dry-run routing evaluation
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Routing candidates
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ProviderConfig'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/packages/registry:
- get:
- tags:
- - Admin
- - Extensions
- summary: Browse package registry
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Registry entries
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/packages/registry/install:
- post:
- tags:
- - Admin
- - Extensions
- summary: Install from registry
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Installed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/packages:
- get:
- tags:
- - Admin
- - Extensions
- summary: List installed packages
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Package list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/packages/{id}:
- get:
- tags:
- - Admin
- - Extensions
- summary: Get package details
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Package object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Admin
- - Extensions
- summary: Delete package
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/packages/install:
- post:
- tags:
- - Admin
- - Extensions
- summary: Install package from upload
- description: |
- Upload a .pkg/.surface/.zip archive containing manifest.json and
- optional assets. For starlark-tier packages, the archive must
- include `script.star` (or the manifest's `entry_point` value).
- Starlark submodules in `star/` are extracted alongside static
- assets (js/, css/, assets/). The manifest `entry_point` field
- overrides the default entry script name (v0.38.0).
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Installed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/packages/{id}/enable:
- put:
- tags:
- - Admin
- - Extensions
- summary: Enable package
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Enabled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/packages/{id}/disable:
- put:
- tags:
- - Admin
- - Extensions
- summary: Disable package
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Disabled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/packages/{id}/settings:
- get:
- tags:
- - Admin
- - Extensions
- summary: Get package settings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Package settings
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Admin
- - Extensions
- summary: Update package settings
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Settings updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Extension'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/packages/{id}/export:
- get:
- tags:
- - Admin
- - Extensions
- summary: Export package as archive
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Package archive
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Extension'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- # ── Extension Dependencies (v0.38.2) ─────────────────────────────────
- /api/v1/admin/packages/{id}/dependencies:
- get:
- tags: [Admin, Extensions]
- summary: List libraries this package depends on
- description: "v0.38.2: Returns dependency records where this package is the consumer."
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Dependencies list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ExtDependency'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/packages/{id}/consumers:
- get:
- tags: [Admin, Extensions]
- summary: List packages that depend on this library
- description: "v0.38.2: Returns dependency records where this package is the library."
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Consumers list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ExtDependency'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/dependencies:
- get:
- tags: [Admin, Extensions]
- summary: List all package dependencies
- description: "v0.38.2: Returns the full dependency graph."
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Full dependency graph
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ExtDependency'
- '401':
- $ref: '#/components/responses/Unauthorized'
- # ── Connection Type Discovery (v0.38.4) ─────────────────────────────
- /api/v1/connection-types:
- get:
- tags: [Extensions]
- summary: List available connection types
- description: |
- Returns merged connection types from all active packages.
- Library declarations take precedence over non-library declarations.
- Any authenticated user can call this endpoint.
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Connection type list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ConnectionType'
- '401':
- $ref: '#/components/responses/Unauthorized'
- # ── Extension Connections (v0.38.1) ─────────────────────────────────
- /api/v1/connections:
- get:
- tags: [Extensions]
- summary: List personal connections
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Personal connections (secrets masked)
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ExtConnection'
- post:
- tags: [Extensions]
- summary: Create personal connection
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required: [type, package_id, name]
- properties:
- type: { type: string }
- package_id: { type: string }
- name: { type: string }
- config: { type: object }
- responses:
- '201':
- description: Created
- /api/v1/connections/{id}:
- get:
- tags: [Extensions]
- summary: Get personal connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Connection (secrets masked)
- put:
- tags: [Extensions]
- summary: Update personal connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- name: { type: string }
- config: { type: object }
- responses:
- '200':
- description: Updated
- delete:
- tags: [Extensions]
- summary: Delete personal connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- /api/v1/connections/resolve:
- get:
- tags: [Extensions]
- summary: Resolve connection via scope chain (personal > team > global)
- description: |
- Returns a single connection with decrypted config, resolved via
- the scope chain. Requires `type` query parameter. Optional `name`
- for named resolution.
- security:
- - bearerAuth: []
- parameters:
- - name: type
- in: query
- required: true
- schema: { type: string }
- - name: name
- in: query
- schema: { type: string }
- responses:
- '200':
- description: Resolved connection with decrypted config
- '404':
- description: No matching connection found
- /api/v1/teams/{teamId}/connections:
- get:
- tags: [Teams, Extensions]
- summary: List team connections
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/TeamID'
- responses:
- '200':
- description: Team connections
- post:
- tags: [Teams, Extensions]
- summary: Create team connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/TeamID'
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required: [type, package_id, name]
- properties:
- type: { type: string }
- package_id: { type: string }
- name: { type: string }
- config: { type: object }
- responses:
- '201':
- description: Created
- /api/v1/teams/{teamId}/connections/{id}:
- put:
- tags: [Teams, Extensions]
- summary: Update team connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/TeamID'
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- delete:
- tags: [Teams, Extensions]
- summary: Delete team connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/TeamID'
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- /api/v1/admin/connections:
- get:
- tags: [Admin, Extensions]
- summary: List global connections
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Global connections
- post:
- tags: [Admin, Extensions]
- summary: Create global connection
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- type: object
- required: [type, package_id, name]
- properties:
- type: { type: string }
- package_id: { type: string }
- name: { type: string }
- config: { type: object }
- responses:
- '201':
- description: Created
- /api/v1/admin/connections/{id}:
- put:
- tags: [Admin, Extensions]
- summary: Update global connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- delete:
- tags: [Admin, Extensions]
- summary: Delete global connection
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- /api/v1/admin/workflows/{id}/export:
- get:
- tags:
- - Admin
- - Workflows
- summary: Export workflow as package
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Workflow package archive
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/workflows/monitor/instances:
- get:
- tags:
- - Admin
- - Workflows
- summary: List active workflow instances
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Active instance list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/workflows/monitor/funnel/{id}:
- get:
- tags:
- - Admin
- - Workflows
- summary: Get workflow stage funnel
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Funnel data
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/workflows/monitor/stale:
- get:
- tags:
- - Admin
- - Workflows
- summary: List stale workflow instances
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Stale instances
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Workflow'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/surfaces:
- get:
- tags:
- - Admin
- - Surfaces
- summary: List installed surfaces
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Surface list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Surface'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/surfaces/{id}:
- delete:
- tags:
- - Admin
- - Surfaces
- summary: Delete surface
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/surfaces/install:
- post:
- tags:
- - Admin
- - Surfaces
- summary: Install surface package
- security:
- - bearerAuth: []
- responses:
- '201':
- description: Installed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Surface'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/surfaces/{id}/enable:
- put:
- tags:
- - Admin
- - Surfaces
- summary: Enable surface
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Enabled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Surface'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/surfaces/{id}/disable:
- put:
- tags:
- - Admin
- - Surfaces
- summary: Disable surface
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Disabled
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Surface'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/tasks:
- get:
- tags:
- - Admin
- - Tasks
- summary: List all tasks
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Task list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Task'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/tasks/{id}/run:
- post:
- tags:
- - Admin
- - Tasks
- summary: Force-run task
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Run started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/tasks/{id}/kill:
- post:
- tags:
- - Admin
- - Tasks
- summary: Kill task run
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Killed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Task'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/tasks/{id}:
- delete:
- tags:
- - Admin
- - Tasks
- summary: Delete task
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/admin/system-functions:
- get:
- tags:
- - Admin
- - Tasks
- summary: List registered system functions
- security:
- - bearerAuth: []
- responses:
- '200':
- description: System function registry
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Task'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/projects:
- get:
- tags:
- - Admin
- - Projects
- summary: List all projects (cross-user)
- security:
- - bearerAuth: []
- responses:
- '200':
- description: All projects
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Project'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/projects/{id}:
- delete:
- tags:
- - Admin
- - Projects
- summary: Delete project
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/mine:
- get:
- tags:
- - Teams
- summary: List teams the current user belongs to
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User's teams
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/teams/{teamId}/members:
- get:
- tags:
- - Teams
- summary: List team members
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Member list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Teams
- summary: Add team member
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '201':
- description: Member added
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/members/{memberId}:
- put:
- tags:
- - Teams
- summary: Update team member role
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - name: memberId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- summary: Remove team member
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - name: memberId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/models:
- get:
- tags:
- - Teams
- - Models
- summary: List models available to team
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team model list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/providers:
- get:
- tags:
- - Teams
- - Providers
- summary: List team provider configs
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team provider list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Teams
- - Providers
- summary: Create team provider config
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '201':
- description: Config created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/providers/{id}:
- put:
- tags:
- - Teams
- - Providers
- summary: Update team provider config
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- - Providers
- summary: Delete team provider config
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/providers/{id}/models:
- get:
- tags:
- - Teams
- - Providers
- summary: List models for team provider
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Model list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/personas:
- get:
- tags:
- - Teams
- - Personas
- summary: List team personas
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team persona list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Teams
- - Personas
- summary: Create team persona
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '201':
- description: Persona created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/personas/{id}:
- put:
- tags:
- - Teams
- - Personas
- summary: Update team persona
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- - Personas
- summary: Delete team persona
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/personas/{id}/avatar:
- post:
- tags:
- - Teams
- - Personas
- summary: Upload team persona avatar
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Avatar uploaded
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- delete:
- tags:
- - Teams
- - Personas
- summary: Delete team persona avatar
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Avatar removed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/personas/{id}/knowledge-bases:
- get:
- tags:
- - Teams
- - Personas
- summary: Get team persona KB bindings
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: KB bindings
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Teams
- - Personas
- summary: Set team persona KB bindings
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Bindings updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/personas/{id}/tool-grants:
- get:
- tags:
- - Teams
- - Personas
- summary: Get team persona tool grants
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Tool grants
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- put:
- tags:
- - Teams
- - Personas
- summary: Set team persona tool grants
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Grants updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/roles:
- get:
- tags:
- - Teams
- summary: List team roles
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team role list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/roles/{role}:
- put:
- tags:
- - Teams
- summary: Update team role
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - name: role
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- summary: Delete team role
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - name: role
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/audit:
- get:
- tags:
- - Teams
- summary: Team audit log
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Audit entries
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/audit/actions:
- get:
- tags:
- - Teams
- summary: List distinct team audit actions
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Action list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/usage:
- get:
- tags:
- - Teams
- - Usage
- summary: Team usage stats
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team usage
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/groups:
- get:
- tags:
- - Teams
- summary: List groups for team
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team groups
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/packages/install:
- post:
- tags:
- - Teams
- - Extensions
- summary: Install package for team
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '201':
- description: Package installed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/packages/{id}:
- delete:
- tags:
- - Teams
- - Extensions
- summary: Delete team package
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/assignments:
- get:
- tags:
- - Teams
- - Workflows
- summary: List workflow assignments for team
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team assignments
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/workflows:
- get:
- tags:
- - Teams
- - Workflows
- summary: List team workflows
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team workflow list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Teams
- - Workflows
- summary: Create team workflow
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '201':
- description: Workflow created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/workflows/{id}:
- get:
- tags:
- - Teams
- - Workflows
- summary: Get team workflow
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Workflow object
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- patch:
- tags:
- - Teams
- - Workflows
- summary: Update team workflow
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- - Workflows
- summary: Delete team workflow
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/workflows/{id}/stages:
- get:
- tags:
- - Teams
- - Workflows
- summary: List team workflow stages
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Stage list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Teams
- - Workflows
- summary: Create team workflow stage
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '201':
- description: Stage created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/workflows/{id}/stages/{sid}:
- put:
- tags:
- - Teams
- - Workflows
- summary: Update team workflow stage
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- - name: sid
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- - Workflows
- summary: Delete team workflow stage
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- - name: sid
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/workflows/{id}/stages/reorder:
- patch:
- tags:
- - Teams
- - Workflows
- summary: Reorder team workflow stages
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Reordered
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/workflows/{id}/publish:
- post:
- tags:
- - Teams
- - Workflows
- summary: Publish team workflow version
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Published
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/workflows/{id}/versions/{version}:
- get:
- tags:
- - Teams
- - Workflows
- summary: Get team workflow version
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- - name: version
- in: path
- required: true
- schema:
- type: integer
- responses:
- '200':
- description: Version snapshot
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/workflows/monitor/instances:
- get:
- tags:
- - Teams
- - Workflows
- summary: List team active workflow instances
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Active instances
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/tasks:
- get:
- tags:
- - Teams
- - Tasks
- summary: List team tasks (member view)
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '200':
- description: Team task list
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- post:
- tags:
- - Teams
- - Tasks
- summary: Create team task
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- responses:
- '201':
- description: Task created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '200':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/tasks/{id}:
- put:
- tags:
- - Teams
- - Tasks
- summary: Update team task
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Updated
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- delete:
- tags:
- - Teams
- - Tasks
- summary: Delete team task
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/teams/{teamId}/tasks/{id}/run:
- post:
- tags:
- - Teams
- - Tasks
- summary: Run team task
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Run started
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/tasks/{id}/kill:
- post:
- tags:
- - Teams
- - Tasks
- summary: Kill team task run
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Killed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Team'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/teams/{teamId}/tasks/{id}/runs:
- get:
- tags:
- - Teams
- - Tasks
- summary: List team task runs
- security:
- - bearerAuth: []
- parameters:
- - name: teamId
- in: path
- required: true
- schema:
- type: string
- format: uuid
- - $ref: '#/components/parameters/ResourceID'
- responses:
- '200':
- description: Run history
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '404':
- $ref: '#/components/responses/NotFound'
- /api/v1/groups/mine:
- get:
- tags:
- - Teams
- summary: List permission groups the user belongs to
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User's groups
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Team'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/usage:
- get:
- tags:
- - Usage
- summary: Personal usage stats
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Personal usage
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/export/me:
- get:
- tags:
- - Data Portability
- summary: Export all user data (GDPR)
- security:
- - bearerAuth: []
- responses:
- '200':
- description: User data archive
- content:
- application/json:
- schema:
- type: string
- format: binary
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/import/me:
- post:
- tags:
- - Data Portability
- summary: Import user data
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Data imported
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/import/chatgpt:
- post:
- tags:
- - Data Portability
- summary: Import ChatGPT conversation export
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Conversations imported
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/me:
- delete:
- tags:
- - Data Portability
- summary: Delete account and all data (GDPR)
- security:
- - bearerAuth: []
- requestBody:
- content:
- application/json:
- schema:
- type: object
- required:
- - password
- properties:
- password:
- type: string
- description: Confirmation password
- responses:
- '200':
- description: Account deleted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/hooks/t/{token}:
- post:
- tags:
- - Workflows
- summary: Webhook trigger endpoint
- parameters:
- - name: token
- in: path
- required: true
- schema:
- type: string
- responses:
- '200':
- description: Trigger processed
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Workflow'
- '201':
- description: Success
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResponse'
- /api/v1/admin/stats:
- get:
- tags:
- - Admin
- summary: Platform statistics
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Aggregate counts
- content:
- application/json:
- schema:
- type: object
- properties:
- users:
- type: integer
- channels:
- type: integer
- messages:
- type: integer
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/providers/health:
- get:
- tags:
- - Admin
- summary: Current health status for all providers
- security:
- - bearerAuth: []
- responses:
- '200':
- description: Provider health summaries
- content:
- application/json:
- schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/ProviderHealth'
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/usage:
- get:
- tags:
- - Admin
- - Usage
- summary: Aggregated token usage
- security:
- - bearerAuth: []
- parameters:
- - name: since
- in: query
- schema:
- type: string
- format: date-time
- - name: until
- in: query
- schema:
- type: string
- format: date-time
- - name: period
- in: query
- schema:
- type: string
- enum:
- - 7d
- - 30d
- - 90d
- - name: group_by
- in: query
- schema:
- type: string
- enum:
- - model
- - user
- - provider
- - day
- responses:
- '200':
- description: Usage data
- content:
- application/json:
- schema:
- type: object
- '401':
- $ref: '#/components/responses/Unauthorized'
- /api/v1/admin/dashboard:
- get:
- tags:
- - Admin
- summary: Real-time observability dashboard data
- description: 'Aggregates provider health, 24h usage, DB pool stats, WebSocket count,
- recent errors, and uptime into a single response.
-
- '
- security:
- - bearerAuth: []
+ /api/v1/auth/refresh:
+ post:
+ summary: Refresh access token
+ operationId: refreshToken
+ tags: [Auth]
responses:
'200':
- description: Dashboard snapshot
- content:
- application/json:
- schema:
- type: object
- properties:
- provider_health:
- type: array
- items:
- $ref: '#/components/schemas/ProviderHealth'
- usage_24h:
- type: object
- db_pool:
- type: object
- properties:
- open_connections:
- type: integer
- in_use:
- type: integer
- idle:
- type: integer
- ws_connections:
- type: integer
- recent_errors:
- type: array
- items:
- type: object
- runtime:
- type: object
- properties:
- goroutines:
- type: integer
- heap_mb:
- type: integer
- sys_mb:
- type: integer
- num_gc:
- type: integer
- go_version:
- type: string
- uptime:
- type: string
- description: Go duration string (e.g. "4h32m10s")
- '401':
- $ref: '#/components/responses/Unauthorized'
-components:
- securitySchemes:
- bearerAuth:
- type: http
- scheme: bearer
- bearerFormat: JWT
- description: 'JWT access token obtained via `POST /api/v1/auth/login` (builtin),
+ description: New token pair
- OIDC callback, or mTLS auto-provisioning. 15-minute TTL, refreshable
+ /api/v1/auth/oidc/login:
+ get:
+ summary: Initiate OIDC login
+ operationId: oidcLogin
+ tags: [Auth]
+ responses:
+ '302':
+ description: Redirect to IdP
- via `POST /api/v1/auth/refresh`.
+ /api/v1/auth/oidc/callback:
+ get:
+ summary: OIDC callback
+ operationId: oidcCallback
+ tags: [Auth]
+ responses:
+ '200':
+ description: JWT tokens returned
- '
- mtlsAuth:
- type: mutualTLS
- description: 'Mutual TLS via reverse proxy headers. The proxy terminates TLS and
-
- forwards certificate DN fields. Configured via `AUTH_MODE=mtls`.
-
- No explicit login — user identity extracted from certificate on
-
- every request. Auto-provisions on first connection.
-
- '
- parameters:
- ResourceID:
- name: id
- in: path
- required: true
- description: Resource UUID
- schema:
- type: string
- format: uuid
- Page:
- name: page
- in: query
- description: Page number (1-indexed)
- schema:
- type: integer
- default: 1
- minimum: 1
- PerPage:
- name: per_page
- in: query
- description: Items per page
- schema:
- type: integer
- default: 50
- minimum: 1
- maximum: 200
- responses:
- BadRequest:
- description: Invalid request
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- Unauthorized:
- description: Missing or invalid authentication
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- Forbidden:
- description: Insufficient permissions
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- NotFound:
- description: Resource not found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- Conflict:
- description: Resource conflict (duplicate, version mismatch)
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- RateLimited:
- description: Rate limited
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- schemas:
- ErrorResponse:
- type: object
- required:
- - error
- properties:
- error:
- type: string
- description: Human-readable error message
- example:
- error: resource not found
- MessageResponse:
- type: object
- properties:
- message:
- type: string
- example:
- message: operation completed
- AuthResponse:
- type: object
- properties:
- access_token:
- type: string
- description: JWT access token (15m TTL)
- refresh_token:
- type: string
- description: Opaque refresh token (7d TTL, rotation on use)
- token_type:
- type: string
- example: Bearer
- expires_in:
- type: integer
- description: Access token TTL in seconds
- example: 900
- user:
- $ref: '#/components/schemas/AuthUser'
- AuthUser:
- type: object
- properties:
- id:
- type: string
- format: uuid
- username:
- type: string
- email:
- type: string
- format: email
- display_name:
- type: string
- nullable: true
- role:
- type: string
- enum:
- - admin
- - user
- handle:
- type: string
- description: Unique @mention handle
- auth_source:
- type: string
- enum:
- - builtin
- - oidc
- - mtls
- UserProfile:
- type: object
- properties:
- id:
- type: string
- format: uuid
- username:
- type: string
- email:
- type: string
- format: email
- display_name:
- type: string
- nullable: true
- role:
- type: string
- enum:
- - admin
- - user
- avatar:
- type: string
- nullable: true
- description: Data URI (PNG, 128x128). Null if unset.
- settings:
- type: object
- additionalProperties: true
- description: User preferences (theme, keybindings, etc.)
- created_at:
- type: string
- format: date-time
- last_login_at:
- type: string
- format: date-time
- nullable: true
- Channel:
- type: object
- properties:
- id:
- type: string
- format: uuid
- title:
- type: string
- type:
- type: string
- enum:
- - direct
- - group
- - dm
- - workflow
- user_id:
- type: string
- format: uuid
- team_id:
- type: string
- format: uuid
- nullable: true
- ai_mode:
- type: string
- enum:
- - auto
- - false
- - mention_only
- system_prompt:
- type: string
- nullable: true
- model:
- type: string
- nullable: true
- persona_id:
- type: string
- format: uuid
- nullable: true
- project_id:
- type: string
- format: uuid
- nullable: true
- settings:
- type: object
- additionalProperties: true
- nullable: true
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- CompletionRequest:
- type: object
- required:
- - content
- - channel_id
- properties:
- channel_id:
- type: string
- format: uuid
- content:
- type: string
- model:
- type: string
- description: Override the default model
- persona_id:
- type: string
- format: uuid
- provider_config_id:
- type: string
- format: uuid
- max_tokens:
- type: integer
- temperature:
- type: number
- minimum: 0
- maximum: 2
- top_p:
- type: number
- minimum: 0
- maximum: 1
- stream:
- type: boolean
- default: true
- file_ids:
- type: array
- items:
- type: string
- format: uuid
- disabled_tools:
- type: array
- items:
- type: string
- CompletionResponse:
- type: object
- description: OpenAI-compatible non-streaming response
- properties:
- model:
- type: string
- choices:
- type: array
- items:
- type: object
- properties:
- message:
- type: object
- properties:
- role:
- type: string
- content:
- type: string
- finish_reason:
- type: string
- usage:
- $ref: '#/components/schemas/TokenUsage'
- TokenUsage:
- type: object
- properties:
- prompt_tokens:
- type: integer
- completion_tokens:
- type: integer
- total_tokens:
- type: integer
- ProviderHealth:
- type: object
- properties:
- provider_config_id:
- type: string
- format: uuid
- name:
- type: string
- provider_type:
- type: string
- status:
- type: string
- enum:
- - healthy
- - degraded
- - down
- - unknown
- request_count:
- type: integer
- error_count:
- type: integer
- error_rate:
- type: number
- avg_latency_ms:
- type: number
- max_latency_ms:
- type: integer
- Message:
- type: object
- properties:
- id:
- type: string
- format: uuid
- channel_id:
- type: string
- format: uuid
- parent_id:
- type: string
- format: uuid
- nullable: true
- role:
- type: string
- enum:
- - user
- - assistant
- - system
- - tool
- content:
- type: string
- model:
- type: string
- nullable: true
- provider_config_id:
- type: string
- format: uuid
- nullable: true
- participant_id:
- type: string
- format: uuid
- nullable: true
- participant_type:
- type: string
- enum:
- - user
- - persona
- - session
- nullable: true
- thinking:
- type: string
- nullable: true
- tool_calls:
- type: array
- items:
- type: object
- nullable: true
- tool_results:
- type: array
- items:
- type: object
- nullable: true
- attachments:
- type: array
- items:
- type: object
- nullable: true
- has_siblings:
- type: boolean
- sibling_index:
- type: integer
- sibling_count:
- type: integer
- created_at:
- type: string
- format: date-time
- Participant:
- type: object
- properties:
- id:
- type: string
- format: uuid
- channel_id:
- type: string
- format: uuid
- participant_type:
- type: string
- enum:
- - user
- - persona
- - session
- participant_id:
- type: string
- format: uuid
- role:
- type: string
- enum:
- - owner
- - member
- - observer
- display_name:
- type: string
- avatar_url:
- type: string
- nullable: true
- joined_at:
- type: string
- format: date-time
- FileObject:
- type: object
- properties:
- id:
- type: string
- format: uuid
- channel_id:
- type: string
- format: uuid
- nullable: true
- message_id:
- type: string
- format: uuid
- nullable: true
- user_id:
- type: string
- format: uuid
- origin:
- type: string
- enum:
- - user_upload
- - tool_output
- - system
- filename:
- type: string
- content_type:
- type: string
- size_bytes:
- type: integer
- display_hint:
- type: string
- enum:
- - inline
- - download
- - thumbnail
- extracted_text:
- type: string
- nullable: true
- metadata:
- type: object
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- ToolDef:
- type: object
- properties:
- name:
- type: string
- display_name:
- type: string
- description:
- type: string
- category:
- type: string
- parameters:
- type: object
- description: JSON Schema for tool parameters
- PersonaInput:
- type: object
- required:
- - name
- - base_model_id
- properties:
- name:
- type: string
- handle:
- type: string
- description: Auto-generated from name if omitted
- description:
- type: string
- icon:
- type: string
- base_model_id:
- type: string
- provider_config_id:
- type: string
- format: uuid
- system_prompt:
- type: string
- temperature:
- type: number
- max_tokens:
- type: integer
- thinking_budget:
- type: integer
- nullable: true
- is_active:
- type: boolean
- default: true
- memory_enabled:
- type: boolean
- default: false
- ProviderConfigInput:
- type: object
- required:
- - name
- - provider
- - api_key
- properties:
- name:
- type: string
- provider:
- type: string
- description: Provider type slug (anthropic, openai, etc.)
- endpoint:
- type: string
- format: uri
- api_key:
- type: string
- model_default:
- type: string
- config:
- type: object
- headers:
- type: object
- settings:
- type: object
- Persona:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- handle:
- type: string
- description:
- type: string
- icon:
- type: string
- avatar:
- type: string
- nullable: true
- base_model_id:
- type: string
- provider_config_id:
- type: string
- format: uuid
- nullable: true
- system_prompt:
- type: string
- temperature:
- type: number
- nullable: true
- max_tokens:
- type: integer
- nullable: true
- thinking_budget:
- type: integer
- nullable: true
- scope:
- type: string
- enum:
- - personal
- - team
- - global
- owner_id:
- type: string
- format: uuid
- nullable: true
- is_active:
- type: boolean
- memory_enabled:
- type: boolean
- kb_ids:
- type: array
- items:
- type: string
- format: uuid
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Note:
- type: object
- properties:
- id:
- type: string
- format: uuid
- user_id:
- type: string
- format: uuid
- title:
- type: string
- content:
- type: string
- folder:
- type: string
- nullable: true
- source_message_id:
- type: string
- format: uuid
- nullable: true
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- KnowledgeBase:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- description:
- type: string
- scope:
- type: string
- enum:
- - personal
- - team
- - global
- owner_id:
- type: string
- format: uuid
- nullable: true
- team_id:
- type: string
- format: uuid
- nullable: true
- document_count:
- type: integer
- chunk_count:
- type: integer
- total_bytes:
- type: integer
- status:
- type: string
- enum:
- - active
- - processing
- - error
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Memory:
- type: object
- properties:
- id:
- type: string
- format: uuid
- scope:
- type: string
- enum:
- - user
- - persona
- - persona_user
- owner_id:
- type: string
- format: uuid
- user_id:
- type: string
- format: uuid
- nullable: true
- key:
- type: string
- value:
- type: string
- source_channel_id:
- type: string
- format: uuid
- nullable: true
- confidence:
- type: number
- status:
- type: string
- enum:
- - active
- - pending_review
- - archived
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Project:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- description:
- type: string
- color:
- type: string
- nullable: true
- icon:
- type: string
- nullable: true
- scope:
- type: string
- enum:
- - personal
- - team
- - global
- owner_id:
- type: string
- format: uuid
- team_id:
- type: string
- format: uuid
- nullable: true
- workspace_id:
- type: string
- format: uuid
- nullable: true
- is_archived:
- type: boolean
- settings:
- type: object
- channel_count:
- type: integer
- kb_count:
- type: integer
- note_count:
- type: integer
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Workspace:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- owner_type:
- type: string
- enum:
- - user
- - project
- - channel
- - team
- owner_id:
- type: string
- format: uuid
- status:
- type: string
- enum:
- - active
- - archived
- - deleting
- max_bytes:
- type: integer
- nullable: true
- indexing_enabled:
- type: boolean
- git_remote_url:
- type: string
- nullable: true
- git_branch:
- type: string
- nullable: true
- git_credential_id:
- type: string
- format: uuid
- nullable: true
- git_last_sync:
- type: string
- format: date-time
- nullable: true
- file_count:
- type: integer
- total_bytes:
- type: integer
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Task:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- description:
- type: string
- task_type:
- type: string
- enum:
- - system
- - starlark
- schedule:
- type: string
- description: Cron expression
- trigger_type:
- type: string
- enum:
- - cron
- - manual
- - event
- owner_id:
- type: string
- format: uuid
- team_id:
- type: string
- format: uuid
- nullable: true
- is_active:
- type: boolean
- config:
- type: object
- last_run_at:
- type: string
- format: date-time
- nullable: true
- next_run_at:
- type: string
- format: date-time
- nullable: true
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Workflow:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- slug:
- type: string
- description:
- type: string
- scope:
- type: string
- enum:
- - personal
- - team
- - global
- owner_id:
- type: string
- format: uuid
- team_id:
- type: string
- format: uuid
- nullable: true
- persona_id:
- type: string
- format: uuid
- nullable: true
- published_version:
- type: integer
- nullable: true
- is_active:
- type: boolean
- branding:
- type: object
- nullable: true
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Notification:
- type: object
- properties:
- id:
- type: string
- format: uuid
- user_id:
- type: string
- format: uuid
- type:
- type: string
- description: domain.action convention
- title:
- type: string
- body:
- type: string
- resource_type:
- type: string
- nullable: true
- resource_id:
- type: string
- format: uuid
- nullable: true
- is_read:
- type: boolean
- created_at:
- type: string
- format: date-time
- ProviderConfig:
- type: object
- description: Provider config with API key redacted
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- provider:
- type: string
- description: Provider type slug
- endpoint:
- type: string
- format: uri
- model_default:
- type: string
- nullable: true
- scope:
- type: string
- enum:
- - personal
- - team
- - global
- owner_id:
- type: string
- format: uuid
- nullable: true
- is_active:
- type: boolean
- has_key:
- type: boolean
- config:
- type: object
- headers:
- type: object
- settings:
- type: object
- created_at:
- type: string
- format: date-time
- Team:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- slug:
- type: string
- description:
- type: string
- settings:
- type: object
- member_count:
- type: integer
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- ConnectionType:
- type: object
- description: "v0.38.4: Available connection type from an installed package"
- properties:
- type:
- type: string
- description: Connection type identifier (e.g. gitea, slack)
- label:
- type: string
- description: Human-readable label
- package_id:
- type: string
- description: Package that declares this connection type
- package_title:
- type: string
- description: Title of the declaring package
- fields:
- type: object
- description: Field definitions (name → {type, required, label})
- additionalProperties:
- type: object
- properties:
- type:
- type: string
- required:
- type: string
- label:
- type: string
- scopes:
- type: array
- items:
- type: string
- description: Allowed scopes (global, team, personal)
- ExtDependency:
- type: object
- description: "v0.38.2: Package dependency record (consumer → library)"
- properties:
- consumer_id:
- type: string
- description: Package ID of the consumer
- library_id:
- type: string
- description: Package ID of the library
- version_spec:
- type: string
- description: Semver version spec declared by consumer
- resolved_ver:
- type: string
- description: Actual library version at dependency creation time
- ConfigSection:
- type: object
- description: "v0.38.3: Manifest-declared config section for Settings/Admin/Team Admin surfaces"
- properties:
- label:
- type: string
- description: Nav link text
- icon:
- type: string
- description: Optional SVG path data (compact format)
- component:
- type: string
- description: Relative asset path within package archive (default js/config.js)
- surfaces:
- type: array
- items:
- type: string
- enum: [admin, settings, team-admin]
- description: Which surfaces show this section
- category:
- type: string
- description: Admin-only category tab (default system)
- ExtConnection:
- type: object
- description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
- properties:
- id:
- type: string
- format: uuid
- type:
- type: string
- description: Connection type identifier (e.g. "gitea", "github")
- package_id:
- type: string
- description: Declaring package ID (UI attribution)
- scope:
- type: string
- enum: [global, team, personal]
- name:
- type: string
- description: User label (e.g. "Work Gitea")
- is_active:
- type: boolean
- has_config:
- type: boolean
- description: Whether config fields are set (secrets never exposed in list)
- created_at:
- type: string
- format: date-time
- updated_at:
- type: string
- format: date-time
- Extension:
- type: object
- properties:
- id:
- type: string
- format: uuid
- name:
- type: string
- slug:
- type: string
- version:
- type: string
- description:
- type: string
- type:
- type: string
- enum:
- - package
- - surface
- is_enabled:
- type: boolean
- permissions:
- type: array
- items:
- type: string
- created_at:
- type: string
- format: date-time
- Surface:
- $ref: '#/components/schemas/Extension'
\ No newline at end of file
+# ═══════════════════════════════════════════════════════
+# TODO: Step 8 — Generate full kernel-only ICD from
+# the route registrations in main.go. This stub keeps
+# /api/docs functional during the transition.
+# ═══════════════════════════════════════════════════════
diff --git a/src/css/sw-chat-pane.css b/src/css/sw-chat-pane.css
deleted file mode 100644
index 206d2b7..0000000
--- a/src/css/sw-chat-pane.css
+++ /dev/null
@@ -1,847 +0,0 @@
-/* ==========================================
- Chat Switchboard — Preact ChatPane Kit CSS
- ==========================================
- v0.37.8: Styles for the composable ChatPane kit.
- sw- prefixed, no conflicts with legacy chat-pane.css.
- ========================================== */
-
-/* ── Container ─────────────────────────────── */
-
-.sw-chat-pane {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
- background: var(--bg, #0e0e10);
-}
-
-/* ── Header Bar (standalone mode) ──────────── */
-
-.sw-chat-pane__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 6px;
- padding: 6px 10px;
- border-bottom: 1px solid var(--border, #2a2a2e);
- background: var(--bg-secondary, #151517);
- flex-shrink: 0;
- min-height: 36px;
-}
-
-.sw-chat-pane__header-left {
- display: flex;
- align-items: center;
- gap: 4px;
- flex: 1;
- min-width: 0;
-}
-
-.sw-chat-pane__header-right {
- display: flex;
- align-items: center;
- gap: 4px;
- flex-shrink: 0;
-}
-
-/* ── Chat History Select ──────────────────── */
-
-.sw-chat-history__select {
- flex: 1;
- min-width: 0;
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 4px;
- color: var(--text, #eee);
- font-size: 11px;
- font-family: inherit;
- padding: 4px 6px;
- cursor: pointer;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sw-chat-history__select:focus {
- border-color: var(--accent, #b38a4e);
- outline: none;
-}
-
-.sw-chat-history__new-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- background: none;
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 4px;
- color: var(--text-2, #999);
- cursor: pointer;
- flex-shrink: 0;
- transition: color 0.15s, border-color 0.15s, background 0.15s;
-}
-
-.sw-chat-history__new-btn:hover {
- color: var(--accent, #b38a4e);
- border-color: var(--accent, #b38a4e);
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
-}
-
-/* ── Model Selector ───────────────────────── */
-
-.sw-model-selector__select {
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 4px;
- color: var(--text-2, #999);
- font-size: 11px;
- font-family: inherit;
- padding: 4px 6px;
- cursor: pointer;
- min-width: 100px;
- max-width: 160px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sw-model-selector__select:disabled {
- opacity: 0.5;
- cursor: default;
-}
-
-.sw-model-selector__select:focus {
- border-color: var(--accent, #b38a4e);
- outline: none;
- color: var(--text, #eee);
-}
-
-/* ── Messages Area ─────────────────────────── */
-
-.sw-chat-pane__messages {
- flex: 1;
- overflow-y: auto;
- scroll-behavior: smooth;
- padding: 12px 0;
- min-height: 0;
-}
-
-/* ── Message Bubbles ───────────────────────── */
-
-.sw-chat-msg {
- padding: 8px 16px;
- font-size: var(--msg-font, 14px);
- line-height: 1.65;
- color: var(--text, #eee);
-}
-
-.sw-chat-msg + .sw-chat-msg {
- margin-top: 2px;
-}
-
-/* User messages — right-aligned bubble */
-.sw-chat-msg--user {
- display: flex;
- justify-content: flex-end;
- padding: 8px 16px;
-}
-
-.sw-chat-msg--user .sw-chat-msg__content {
- background: var(--accent-dim, rgba(179, 138, 78, 0.15));
- color: var(--text, #eee);
- padding: 8px 14px;
- border-radius: 12px 12px 2px 12px;
- max-width: 85%;
- word-wrap: break-word;
-}
-
-/* Other user messages — left-aligned bubble */
-.sw-chat-msg--other {
- padding: 8px 16px;
-}
-
-.sw-chat-msg--other .sw-chat-msg__content {
- background: var(--bg-2, #1a1a1e);
- color: var(--text, #eee);
- padding: 8px 14px;
- border-radius: 12px 12px 12px 2px;
- max-width: 85%;
- word-wrap: break-word;
-}
-
-.sw-chat-msg__sender {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-3, #555);
- margin-bottom: 2px;
- padding-left: 2px;
-}
-
-/* ── @Mention highlights in rendered messages ─ */
-
-.sw-mention {
- color: var(--accent, #6366f1);
- background: rgba(var(--accent-rgb, 99, 102, 241), 0.1);
- border-radius: 3px;
- padding: 0 3px;
- font-weight: 500;
-}
-
-/* ── Typing Indicator ─────────────────────── */
-
-.sw-typing-indicator {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 16px;
- font-size: 12px;
- color: var(--text-3, #888);
- min-height: 22px;
-}
-
-.sw-typing-indicator__dots {
- display: flex;
- gap: 3px;
-}
-
-.sw-typing-indicator__dot {
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: var(--text-3, #888);
- animation: sw-typing-bounce 1.4s infinite ease-in-out both;
-}
-
-.sw-typing-indicator__dot:nth-child(1) { animation-delay: 0s; }
-.sw-typing-indicator__dot:nth-child(2) { animation-delay: 0.16s; }
-.sw-typing-indicator__dot:nth-child(3) { animation-delay: 0.32s; }
-
-@keyframes sw-typing-bounce {
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
- 40% { transform: scale(1); opacity: 1; }
-}
-
-/* Assistant messages — left-aligned */
-.sw-chat-msg--assistant {
- padding: 8px 16px;
-}
-
-.sw-chat-msg--assistant .sw-chat-msg__content {
- max-width: 100%;
- word-wrap: break-word;
-}
-
-/* System/error messages */
-.sw-chat-msg--system {
- padding: 6px 16px;
- font-size: 12px;
-}
-
-.sw-chat-msg--system .sw-chat-msg__content {
- color: var(--danger, #f44336);
- font-size: 12px;
-}
-
-/* ── Streaming indicator ───────────────────── */
-
-.sw-chat-msg--streaming .sw-chat-msg__content::after {
- content: '';
- display: inline-block;
- width: 6px;
- height: 14px;
- background: var(--accent, #b38a4e);
- margin-left: 2px;
- vertical-align: text-bottom;
- animation: sw-cursor-blink 0.8s step-end infinite;
-}
-
-@keyframes sw-cursor-blink {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0; }
-}
-
-/* ── Typing dots ───────────────────────────── */
-
-.sw-chat-msg--typing {
- padding: 8px 16px;
-}
-
-.sw-chat-msg--typing .sw-typing-dots {
- display: flex;
- gap: 4px;
- padding: 4px 0;
-}
-
-.sw-chat-msg--typing .sw-typing-dots span {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--text-3, #555);
- animation: sw-dot-bounce 1.2s ease-in-out infinite;
-}
-
-.sw-chat-msg--typing .sw-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
-.sw-chat-msg--typing .sw-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
-
-@keyframes sw-dot-bounce {
- 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
- 30% { transform: translateY(-4px); opacity: 1; }
-}
-
-/* ── Markdown content ──────────────────────── */
-
-.sw-chat-msg__content p { margin: 0 0 0.5em; }
-.sw-chat-msg__content p:last-child { margin-bottom: 0; }
-
-.sw-chat-msg__content pre {
- background: var(--bg-secondary, #151517);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 6px;
- padding: 10px 12px;
- overflow-x: auto;
- margin: 8px 0;
- font-size: 12px;
- line-height: 1.5;
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-.sw-chat-msg__content code {
- background: var(--bg-secondary, #151517);
- padding: 1px 5px;
- border-radius: 3px;
- font-size: 0.88em;
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-.sw-chat-msg__content pre code {
- background: none;
- padding: 0;
- border-radius: 0;
- font-size: inherit;
-}
-
-.sw-chat-msg__content ul, .sw-chat-msg__content ol {
- margin: 4px 0;
- padding-left: 1.5em;
-}
-
-.sw-chat-msg__content blockquote {
- border-left: 3px solid var(--accent, #b38a4e);
- padding: 4px 12px;
- margin: 8px 0;
- color: var(--text-2, #999);
-}
-
-.sw-chat-msg__content a {
- color: var(--accent, #b38a4e);
- text-decoration: none;
-}
-
-.sw-chat-msg__content a:hover {
- text-decoration: underline;
-}
-
-.sw-chat-msg__content table {
- border-collapse: collapse;
- margin: 8px 0;
- font-size: 13px;
-}
-
-.sw-chat-msg__content th,
-.sw-chat-msg__content td {
- border: 1px solid var(--border, #2a2a2e);
- padding: 4px 8px;
-}
-
-.sw-chat-msg__content th {
- background: var(--bg-secondary, #151517);
- font-weight: 600;
-}
-
-/* ── Welcome / Empty State ─────────────────── */
-
-.sw-chat-welcome {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- text-align: center;
- padding: 40px 20px;
- opacity: 0.6;
-}
-
-.sw-chat-welcome__title {
- font-size: 15px;
- font-weight: 600;
- color: var(--text, #eee);
- margin: 0 0 6px;
-}
-
-.sw-chat-welcome__hint {
- font-size: 13px;
- color: var(--text-3, #555);
- margin: 0;
-}
-
-/* ── Input Bar ─────────────────────────────── */
-
-.sw-chat-pane__input-bar {
- flex-shrink: 0;
- border-top: 1px solid var(--border, #2a2a2e);
- background: var(--bg-secondary, #151517);
- padding: 8px 12px;
-}
-
-.sw-msg-input__wrap {
- display: flex;
- align-items: flex-end;
- gap: 6px;
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 8px;
- padding: 6px 8px;
- transition: border-color 0.15s;
-}
-
-.sw-msg-input__wrap:focus-within {
- border-color: var(--accent, #b38a4e);
-}
-
-.sw-msg-input__textarea {
- flex: 1;
- min-height: 20px;
- max-height: 160px;
- width: 100%;
- background: transparent;
- border: none;
- color: var(--text, #eee);
- font-size: 13px;
- font-family: inherit;
- line-height: 1.5;
- resize: none;
- outline: none;
- padding: 0;
-}
-
-/* ── Send Button ───────────────────────────── */
-
-.sw-msg-input__send {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--accent, #b38a4e);
- cursor: pointer;
- transition: background 0.15s, color 0.15s;
-}
-
-.sw-msg-input__send:hover {
- background: var(--accent-dim, rgba(179, 138, 78, 0.15));
- color: var(--accent-hover, #c9a05e);
-}
-
-.sw-msg-input__send:disabled {
- opacity: 0.3;
- cursor: not-allowed;
-}
-
-/* ── Stop Button ──────────────────────────── */
-
-.sw-msg-input__stop {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--danger, #f44336);
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.sw-msg-input__stop:hover {
- background: rgba(244, 67, 54, 0.1);
-}
-
-/* ── Input Buttons Row ────────────────────── */
-
-.sw-msg-input__buttons {
- display: flex;
- align-items: center;
- gap: 2px;
- flex-shrink: 0;
-}
-
-/* ── Emoji Button + Picker ────────────────── */
-
-.sw-msg-input__emoji-wrap {
- position: relative;
-}
-
-.sw-msg-input__emoji-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- transition: color 0.15s;
-}
-
-.sw-msg-input__emoji-btn:hover {
- color: var(--text-2, #999);
-}
-
-.sw-emoji-picker {
- position: absolute;
- bottom: 40px;
- right: 0;
- width: 320px;
- max-height: 360px;
- background: var(--bg-secondary, #151517);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 10px;
- box-shadow: 0 8px 24px rgba(0,0,0,0.3);
- z-index: 100;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.sw-emoji-picker__tabs {
- display: flex;
- gap: 0;
- border-bottom: 1px solid var(--border, #2a2a2e);
- padding: 4px 4px 0;
- flex-shrink: 0;
-}
-
-.sw-emoji-picker__tab {
- flex: 1;
- background: none;
- border: none;
- border-bottom: 2px solid transparent;
- padding: 6px 4px;
- font-size: 16px;
- cursor: pointer;
- transition: border-color 0.15s;
- line-height: 1;
-}
-
-.sw-emoji-picker__tab--active {
- border-bottom-color: var(--accent, #b38a4e);
-}
-
-.sw-emoji-picker__search {
- margin: 6px 8px;
- padding: 6px 10px;
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 6px;
- color: var(--text, #eee);
- font-size: 12px;
- font-family: inherit;
- outline: none;
- flex-shrink: 0;
-}
-
-.sw-emoji-picker__search:focus {
- border-color: var(--accent, #b38a4e);
-}
-
-.sw-emoji-picker__grid {
- display: grid;
- grid-template-columns: repeat(8, 1fr);
- gap: 2px;
- padding: 6px 8px;
- overflow-y: auto;
- flex: 1;
- min-height: 0;
-}
-
-.sw-emoji-picker__emoji {
- width: 100%;
- aspect-ratio: 1;
- background: none;
- border: none;
- border-radius: 6px;
- font-size: 20px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: background 0.1s;
- line-height: 1;
- padding: 0;
-}
-
-.sw-emoji-picker__emoji:hover {
- background: var(--bg, #0e0e10);
-}
-
-.sw-emoji-picker__empty {
- grid-column: 1 / -1;
- text-align: center;
- padding: 20px;
- color: var(--text-3, #555);
- font-size: 12px;
-}
-
-/* ── CM6 Chat Input ──────────────────────── */
-
-.sw-msg-input__cm-container {
- flex: 1;
- min-width: 0;
-}
-
-.sw-msg-input__cm-container .cm-editor {
- font-size: 14px;
- line-height: 1.5;
- max-height: 160px;
- overflow-y: auto;
-}
-
-.sw-msg-input__cm-container .cm-editor.cm-focused {
- outline: none;
-}
-
-/* ── Error Banner ─────────────────────────── */
-
-.sw-chat-pane__error {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: rgba(244, 67, 54, 0.1);
- border-bottom: 1px solid rgba(244, 67, 54, 0.2);
- color: var(--danger, #f44336);
- font-size: 12px;
- flex-shrink: 0;
-}
-
-.sw-chat-pane__error-text {
- flex: 1;
- min-width: 0;
-}
-
-.sw-chat-pane__error-dismiss {
- background: none;
- border: none;
- color: var(--danger, #f44336);
- font-size: 16px;
- cursor: pointer;
- padding: 0 4px;
- line-height: 1;
- opacity: 0.7;
-}
-
-.sw-chat-pane__error-dismiss:hover {
- opacity: 1;
-}
-
-/* ── Load More ────────────────────────────── */
-
-.sw-chat-pane__load-more {
- text-align: center;
- padding: 8px;
-}
-
-.sw-chat-pane__load-more-btn {
- background: none;
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 6px;
- color: var(--text-2, #999);
- font-size: 12px;
- padding: 6px 16px;
- cursor: pointer;
- transition: border-color 0.15s, color 0.15s;
-}
-
-.sw-chat-pane__load-more-btn:hover {
- border-color: var(--accent, #b38a4e);
- color: var(--text, #eee);
-}
-
-.sw-chat-pane__load-more-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-/* ── Message Footer (time + actions) ──────── */
-
-.sw-chat-msg__footer {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 4px;
- opacity: 0;
- transition: opacity 0.15s;
-}
-
-.sw-chat-msg:hover .sw-chat-msg__footer {
- opacity: 1;
-}
-
-.sw-chat-msg__time {
- font-size: 10px;
- color: var(--text-3, #555);
-}
-
-/* ── Message Actions Toolbar ──────────────── */
-
-.sw-chat-msg__actions {
- display: flex;
- gap: 2px;
-}
-
-.sw-chat-msg__action-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- background: none;
- border: none;
- border-radius: 4px;
- color: var(--text-3, #555);
- cursor: pointer;
- transition: color 0.15s, background 0.15s;
- padding: 0;
-}
-
-.sw-chat-msg__action-btn:hover {
- color: var(--text, #eee);
- background: var(--bg-secondary, #151517);
-}
-
-.sw-chat-msg__action-btn--danger:hover {
- color: var(--danger, #f44336);
-}
-
-/* ── Code Block ───────────────────────────── */
-
-.sw-code-block {
- position: relative;
- margin: 8px 0;
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 6px;
- overflow: hidden;
-}
-
-.sw-code-block__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 8px;
- background: var(--bg, #0e0e10);
- border-bottom: 1px solid var(--border, #2a2a2e);
- min-height: 28px;
-}
-
-.sw-code-block__lang {
- font-size: 10px;
- font-weight: 600;
- color: var(--text-3, #555);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-.sw-code-block__copy {
- display: flex;
- align-items: center;
- gap: 4px;
- background: none;
- border: none;
- color: var(--text-3, #555);
- font-size: 11px;
- cursor: pointer;
- padding: 2px 6px;
- border-radius: 3px;
- transition: color 0.15s, background 0.15s;
- margin-left: auto;
-}
-
-.sw-code-block__copy:hover {
- color: var(--text, #eee);
- background: var(--bg-secondary, #151517);
-}
-
-.sw-code-block__copy-text {
- font-family: inherit;
-}
-
-.sw-code-block__pre {
- background: var(--bg-secondary, #151517);
- padding: 10px 12px;
- overflow-x: auto;
- margin: 0;
- font-size: 12px;
- line-height: 1.5;
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-.sw-code-block__pre code {
- background: none;
- padding: 0;
- border-radius: 0;
- font-size: inherit;
- font-family: inherit;
-}
-
-/* ── Task List Checkboxes ─────────────────── */
-
-.sw-task-item {
- list-style: none;
- margin-left: -1.5em;
-}
-
-.sw-task-checkbox {
- margin-right: 6px;
- vertical-align: middle;
- accent-color: var(--accent, #b38a4e);
-}
-
-/* ── Thinking / Reasoning Blocks ────────── */
-
-.sw-chat-msg__thinking {
- margin-bottom: 8px;
- border-left: 3px solid var(--border, rgba(255,255,255,0.1));
- border-radius: 4px;
- font-size: 0.85em;
- color: var(--text-muted, #888);
-}
-
-.sw-chat-msg__thinking-toggle {
- cursor: pointer;
- padding: 6px 10px;
- user-select: none;
- font-weight: 500;
- opacity: 0.7;
-}
-
-.sw-chat-msg__thinking-toggle:hover { opacity: 1; }
-
-.sw-chat-msg__thinking[open] .sw-chat-msg__thinking-toggle {
- border-bottom: 1px solid var(--border, rgba(255,255,255,0.06));
- margin-bottom: 4px;
-}
-
-.sw-chat-msg__thinking-content {
- padding: 6px 10px 10px;
- line-height: 1.5;
- white-space: pre-wrap;
- overflow-wrap: break-word;
-}
diff --git a/src/css/sw-chat-surface.css b/src/css/sw-chat-surface.css
deleted file mode 100644
index c86ac87..0000000
--- a/src/css/sw-chat-surface.css
+++ /dev/null
@@ -1,1031 +0,0 @@
-/* ==========================================
- Chat Switchboard — Chat Surface CSS
- ==========================================
- v0.37.10: New Preact chat surface layout.
- sw-chat-surface__ prefixed, no legacy conflicts.
- ========================================== */
-
-/* ── Root Layout ──────────────────────────── */
-
-.sw-chat-surface {
- display: flex;
- width: 100%;
- height: 100%;
- flex: 1 1 0%;
- overflow: hidden;
- background: var(--bg, #0e0e10);
-}
-
-/* ── Sidebar ──────────────────────────────── */
-
-.sw-chat-surface__sidebar {
- width: 260px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- background: var(--bg-secondary, #151517);
- border-right: 1px solid var(--border, #2a2a2e);
- overflow: hidden;
- z-index: 20;
-}
-
-.sw-chat-surface__sidebar-header {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 10px 12px 6px;
- flex-shrink: 0;
-}
-
-.sw-chat-surface__new-chat-btn {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 8px 12px;
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- border: 1px solid var(--accent, #b38a4e);
- border-radius: 8px;
- color: var(--accent, #b38a4e);
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.sw-chat-surface__new-folder-btn,
-.sw-chat-surface__collapse-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- flex-shrink: 0;
- transition: color 0.15s, background 0.15s;
-}
-
-.sw-chat-surface__new-folder-btn:hover,
-.sw-chat-surface__collapse-btn:hover {
- color: var(--text, #eee);
- background: var(--bg, #0e0e10);
-}
-
-.sw-chat-surface__new-chat-btn:hover {
- background: rgba(179, 138, 78, 0.2);
-}
-
-.sw-chat-surface__new-menu-wrap {
- position: relative;
- flex: 1;
- min-width: 0;
-}
-
-.sw-chat-surface__new-menu {
- position: absolute;
- top: calc(100% + 4px);
- left: 0;
- right: 0;
- background: var(--surface, #1a1a1e);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 8px;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
- z-index: 130;
- padding: 4px;
- display: flex;
- flex-direction: column;
-}
-
-.sw-chat-surface__new-menu button {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text, #eee);
- font-size: 12px;
- font-family: inherit;
- cursor: pointer;
- text-align: left;
-}
-
-.sw-chat-surface__new-menu button:hover {
- background: var(--bg-hover, rgba(255, 255, 255, 0.06));
-}
-
-.sw-chat-surface__new-menu-icon {
- width: 18px;
- text-align: center;
- flex-shrink: 0;
-}
-
-/* ── Search ───────────────────────────────── */
-
-.sw-chat-surface__search-wrap {
- position: relative;
- padding: 4px 12px 8px;
- flex-shrink: 0;
-}
-
-.sw-chat-surface__search-icon {
- position: absolute;
- left: 20px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--text-3, #555);
- pointer-events: none;
- display: flex;
-}
-
-input.sw-chat-surface__search {
- width: 100%;
- padding: 6px 8px 6px 34px;
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 6px;
- color: var(--text, #eee);
- font-size: 12px;
- font-family: inherit;
- outline: none;
- box-sizing: border-box;
-}
-
-input.sw-chat-surface__search:focus {
- border-color: var(--accent, #b38a4e);
-}
-
-/* ── Sidebar Body (scrollable) ────────────── */
-
-.sw-chat-surface__sidebar-body {
- flex: 1;
- overflow-y: auto;
- min-height: 0;
- padding-bottom: 8px;
-}
-
-/* ── Section ──────────────────────────────── */
-
-.sw-chat-surface__section {
- margin-top: 4px;
-}
-
-.sw-chat-surface__section-header {
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- padding: 6px 12px;
- background: none;
- border: none;
- color: var(--text-2, #999);
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- cursor: pointer;
- transition: color 0.15s;
-}
-
-.sw-chat-surface__section-header:hover {
- color: var(--text, #eee);
-}
-
-.sw-chat-surface__chevron {
- display: flex;
- transition: transform 0.15s;
-}
-
-.sw-chat-surface__chevron--open {
- transform: rotate(90deg);
-}
-
-.sw-chat-surface__section-count {
- margin-left: auto;
- font-size: 10px;
- color: var(--text-3, #555);
- font-weight: 400;
-}
-
-.sw-chat-surface__section-body {
- padding: 0 4px;
-}
-
-.sw-chat-surface__empty {
- padding: 8px 16px;
- font-size: 12px;
- color: var(--text-3, #555);
-}
-
-/* ── Sidebar Item ─────────────────────────── */
-
-.sw-chat-surface__item {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 6px 12px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-2, #999);
- font-size: 13px;
- cursor: pointer;
- text-align: left;
- transition: background 0.1s, color 0.1s;
- position: relative;
-}
-
-.sw-chat-surface__item:hover {
- background: var(--bg, #0e0e10);
- color: var(--text, #eee);
-}
-
-.sw-chat-surface__item--active {
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- color: var(--text, #eee);
-}
-
-.sw-chat-surface__item--indent {
- padding-left: 28px;
-}
-
-.sw-chat-surface__item-icon {
- display: flex;
- flex-shrink: 0;
- color: var(--text-3, #555);
-}
-
-.sw-chat-surface__item-title {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sw-chat-surface__item-menu {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- background: none;
- border: none;
- border-radius: 4px;
- color: var(--text-3, #555);
- cursor: pointer;
- flex-shrink: 0;
- padding: 0;
- opacity: 0;
- pointer-events: none;
-}
-
-.sw-chat-surface__item:hover .sw-chat-surface__item-menu,
-.sw-chat-surface__folder-header:hover .sw-chat-surface__item-menu {
- opacity: 1;
- pointer-events: auto;
-}
-
-.sw-chat-surface__item-menu:hover {
- color: var(--text, #eee);
- background: var(--bg-secondary, #151517);
-}
-
-/* ── Unread Badge ─────────────────────────── */
-
-.sw-chat-surface__unread {
- background: var(--accent, #b38a4e);
- color: var(--bg, #0e0e10);
- font-size: 10px;
- font-weight: 700;
- padding: 1px 6px;
- border-radius: 10px;
- flex-shrink: 0;
-}
-
-/* ── Folder ───────────────────────────────── */
-
-.sw-chat-surface__folder {
- margin: 2px 0;
- transition: background 0.15s;
-}
-
-.sw-chat-surface__folder--drag-over {
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- border-radius: 6px;
-}
-
-.sw-chat-surface__root-drop {
- margin: 4px 8px;
- padding: 10px;
- border: 2px dashed var(--border, #2a2a2e);
- border-radius: 6px;
- text-align: center;
- font-size: 11px;
- color: var(--text-3, #555);
- transition: border-color 0.15s, background 0.15s;
-}
-
-.sw-chat-surface__root-drop--active {
- border-color: var(--accent, #b38a4e);
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- color: var(--text-2, #999);
-}
-
-.sw-chat-surface__folder-header {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 12px;
- color: var(--text-3, #555);
- font-size: 11px;
- cursor: pointer;
- border-radius: 6px;
- transition: background 0.15s;
-}
-
-.sw-chat-surface__folder-header:hover {
- background: var(--bg, #0e0e10);
-}
-
-/* Draggable items */
-.sw-chat-surface__item[draggable="true"],
-.sw-chat-surface__folder[draggable="true"] {
- cursor: grab;
-}
-
-.sw-chat-surface__item[draggable="true"]:active,
-.sw-chat-surface__folder[draggable="true"]:active {
- cursor: grabbing;
- opacity: 0.6;
-}
-
-.sw-chat-surface__folder-name {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 600;
-}
-
-.sw-chat-surface__folder-count {
- font-size: 10px;
- color: var(--text-3, #555);
-}
-
-/* ── Context Menu ─────────────────────────── */
-
-.sw-chat-surface__context-menu {
- position: fixed;
- z-index: 200;
- background: var(--bg-secondary, #151517);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 8px;
- box-shadow: 0 8px 24px rgba(0,0,0,0.3);
- padding: 4px;
- min-width: 160px;
-}
-
-.sw-chat-surface__context-menu button {
- display: block;
- width: 100%;
- padding: 6px 12px;
- background: none;
- border: none;
- border-radius: 4px;
- color: var(--text, #eee);
- font-size: 12px;
- cursor: pointer;
- text-align: left;
-}
-
-.sw-chat-surface__context-menu button:hover {
- background: var(--bg, #0e0e10);
-}
-
-.sw-chat-surface__ctx-danger {
- color: var(--danger, #f44336) !important;
-}
-
-/* ── Sidebar Footer ───────────────────────── */
-
-.sw-chat-surface__sidebar-footer {
- display: flex;
- align-items: center;
- padding: 8px 12px;
- border-top: 1px solid var(--border, #2a2a2e);
- flex-shrink: 0;
- position: relative;
-}
-
-/* ── Main Area ────────────────────────────── */
-
-.sw-chat-surface__main {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-width: 0;
- position: relative;
-}
-
-/* ── Workspace Header ─────────────────────── */
-
-.sw-chat-surface__workspace-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 10px;
- border-bottom: 1px solid var(--border, #2a2a2e);
- background: var(--bg-secondary, #151517);
- flex-shrink: 0;
- min-height: 40px;
-}
-
-.sw-chat-surface__workspace-header-left,
-.sw-chat-surface__workspace-header-right {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.sw-chat-surface__sidebar-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- transition: color 0.15s, background 0.15s;
-}
-
-.sw-chat-surface__sidebar-toggle:hover {
- color: var(--text, #eee);
- background: var(--bg, #0e0e10);
-}
-
-/* ── Header Buttons (members, settings) ───── */
-
-.sw-chat-surface__header-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- transition: color 0.15s, background 0.15s;
-}
-
-.sw-chat-surface__header-btn:hover {
- color: var(--text, #eee);
- background: var(--bg, #0e0e10);
-}
-
-/* ── Channel Panel Shared ─────────────────── */
-
-.sw-panel-loading,
-.sw-panel-empty {
- padding: 24px 16px;
- text-align: center;
- color: var(--text-muted, #888);
- font-size: 0.9em;
-}
-
-/* ── Members Panel ────────────────────────── */
-
-.sw-members-list {
- padding: 8px 0;
-}
-
-.sw-members-row {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 16px;
- transition: background 0.12s;
-}
-
-.sw-members-row:hover {
- background: var(--bg, rgba(255,255,255,0.03));
-}
-
-.sw-members-avatar {
- width: 32px;
- height: 32px;
- border-radius: 50%;
- background: var(--accent, #b38a4e);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 0.75em;
- font-weight: 600;
- flex-shrink: 0;
-}
-
-.sw-members-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.sw-members-name {
- font-size: 0.9em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sw-members-type {
- font-size: 0.75em;
- color: var(--text-muted, #888);
-}
-
-.sw-members-role {
- font-size: 0.8em;
- padding: 2px 6px;
- border-radius: 4px;
- background: var(--bg-secondary, #151517);
- color: var(--text, #eee);
- border: 1px solid var(--border, #2a2a2e);
-}
-
-.sw-members-remove {
- background: none;
- border: none;
- color: var(--text-muted, #888);
- cursor: pointer;
- font-size: 1.1em;
- padding: 2px 6px;
- border-radius: 4px;
- transition: color 0.15s, background 0.15s;
-}
-
-.sw-members-remove:hover {
- color: #e55;
- background: rgba(238,85,85,0.1);
-}
-
-.sw-members-add {
- display: flex;
- gap: 6px;
- padding: 12px 16px;
- border-top: 1px solid var(--border, #2a2a2e);
-}
-
-.sw-members-add input {
- flex: 1;
- padding: 6px 10px;
- border-radius: 6px;
- border: 1px solid var(--border, #2a2a2e);
- background: var(--bg, #0e0e10);
- color: var(--text, #eee);
- font-size: 0.85em;
-}
-
-/* ── Settings Panel ───────────────────────── */
-
-.sw-settings-form {
- padding: 16px;
- display: flex;
- flex-direction: column;
- gap: 14px;
-}
-
-.sw-settings-field {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.sw-settings-field label {
- font-size: 0.8em;
- font-weight: 500;
- color: var(--text-muted, #888);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.sw-settings-field input,
-.sw-settings-field textarea,
-.sw-settings-field select {
- padding: 8px 10px;
- border-radius: 6px;
- border: 1px solid var(--border, #2a2a2e);
- background: var(--bg, #0e0e10);
- color: var(--text, #eee);
- font-size: 0.9em;
- resize: vertical;
-}
-
-.sw-settings-info {
- padding: 10px 0;
- border-top: 1px solid var(--border, rgba(255,255,255,0.06));
- font-size: 0.82em;
- color: var(--text-muted, #888);
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.sw-settings-save {
- align-self: flex-start;
-}
-
-/* ── Tools Button ─────────────────────────── */
-
-.sw-chat-surface__tools-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- position: relative;
- transition: color 0.15s;
-}
-
-.sw-chat-surface__tools-btn:hover {
- color: var(--text, #eee);
-}
-
-.sw-chat-surface__tools-badge {
- position: absolute;
- top: 2px;
- right: 2px;
- background: var(--danger, #f44336);
- color: #fff;
- font-size: 9px;
- font-weight: 700;
- width: 14px;
- height: 14px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
-}
-
-/* ── Workspace ────────────────────────────── */
-
-.sw-chat-surface__workspace {
- flex: 1 1 0%;
- display: flex;
- flex-direction: column;
- min-height: 0;
- min-width: 0;
- position: relative;
- overflow: hidden;
-}
-
-.sw-chat-surface__chat-pane {
- flex: 1 1 0%;
- min-height: 0;
- min-width: 0;
-}
-
-/* ── Sidebar Overlay (mobile) ─────────────── */
-
-.sw-chat-surface__sidebar-overlay {
- display: none; /* Hidden on desktop */
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.5);
- z-index: 15;
-}
-
-/* ── Tools Popup ──────────────────────────── */
-
-.sw-tools-popup {
- position: absolute;
- top: 44px;
- right: 8px;
- width: 300px;
- max-height: 420px;
- background: var(--bg-secondary, #151517);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 10px;
- box-shadow: 0 8px 24px rgba(0,0,0,0.3);
- z-index: 100;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.sw-tools-popup__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px 8px;
- border-bottom: 1px solid var(--border, #2a2a2e);
- flex-shrink: 0;
-}
-
-.sw-tools-popup__title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text, #eee);
-}
-
-.sw-tools-popup__badge {
- font-size: 10px;
- color: var(--danger, #f44336);
- background: rgba(244, 67, 54, 0.1);
- padding: 2px 8px;
- border-radius: 10px;
-}
-
-.sw-tools-popup__body {
- flex: 1;
- overflow-y: auto;
- min-height: 0;
- padding: 8px;
-}
-
-.sw-tools-popup__empty {
- padding: 16px;
- text-align: center;
- color: var(--text-3, #555);
- font-size: 12px;
-}
-
-.sw-tools-popup__category {
- margin-bottom: 4px;
-}
-
-.sw-tools-popup__cat-header {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.sw-tools-popup__cat-toggle {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 6px;
- background: none;
- border: none;
- color: var(--text, #eee);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- padding: 4px 6px;
- border-radius: 4px;
- text-align: left;
-}
-
-.sw-tools-popup__cat-toggle:hover {
- background: var(--bg, #0e0e10);
-}
-
-.sw-tools-popup__chevron {
- font-size: 8px;
- transition: transform 0.15s;
- display: inline-block;
-}
-
-.sw-tools-popup__chevron--open {
- transform: rotate(90deg);
-}
-
-.sw-tools-popup__cat-count {
- color: var(--text-3, #555);
- font-weight: 400;
- font-size: 10px;
-}
-
-.sw-tools-popup__cat-switch {
- flex-shrink: 0;
- cursor: pointer;
-}
-
-.sw-tools-popup__cat-switch input {
- accent-color: var(--accent, #b38a4e);
-}
-
-.sw-tools-popup__tool {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- padding: 4px 6px 4px 22px;
- font-size: 12px;
- cursor: pointer;
- border-radius: 4px;
-}
-
-.sw-tools-popup__tool:hover {
- background: var(--bg, #0e0e10);
-}
-
-.sw-tools-popup__tool input {
- margin-top: 2px;
- accent-color: var(--accent, #b38a4e);
-}
-
-.sw-tools-popup__tool-name {
- color: var(--text, #eee);
- font-size: 12px;
-}
-
-.sw-tools-popup__tool-desc {
- color: var(--text-3, #555);
- font-size: 11px;
- margin-left: auto;
-}
-
-/* ── Responsive: Mobile ───────────────────── */
-
-@media (max-width: 768px) {
- .sw-chat-surface__sidebar {
- position: fixed;
- left: 0;
- top: 0;
- bottom: 0;
- width: 280px;
- z-index: 120;
- }
-
- .sw-chat-surface__sidebar-overlay {
- display: block;
- z-index: 110;
- }
-
- .sw-chat-surface__collapse-btn {
- display: none;
- }
-
- /* Ensure touch targets are >= 44px */
- .sw-chat-surface__item {
- min-height: 44px;
- }
-
- .sw-chat-surface__folder-header {
- min-height: 40px;
- }
-
- /* Prevent iOS zoom on search */
- input.sw-chat-surface__search {
- font-size: 16px;
- }
-
- /* Context menu: constrain to viewport */
- .sw-chat-surface__context-menu {
- max-width: calc(100vw - 16px);
- right: 8px;
- left: auto !important;
- }
-
- /* Header buttons: tighter on mobile */
- .sw-chat-surface__sidebar-header {
- gap: 4px;
- }
-
- .sw-chat-surface__new-chat-btn {
- font-size: 12px;
- padding: 6px 10px;
- }
-}
-
-/* ── Dashboard (no chat selected) ─────────── */
-
-.sw-chat-dashboard {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- padding: 40px 24px;
- gap: 32px;
- overflow-y: auto;
-}
-
-.sw-chat-dashboard__welcome {
- text-align: center;
-}
-
-.sw-chat-dashboard__title {
- font-size: 20px;
- font-weight: 600;
- color: var(--text, #eee);
- margin: 0 0 8px;
-}
-
-.sw-chat-dashboard__hint {
- font-size: 13px;
- color: var(--text-3, #555);
- margin: 0;
-}
-
-.sw-chat-dashboard__actions {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 12px;
- max-width: 360px;
- width: 100%;
-}
-
-.sw-chat-dashboard__card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- padding: 20px 12px;
- background: var(--surface, #1a1a1e);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 10px;
- color: var(--text, #eee);
- font-family: inherit;
- cursor: pointer;
- transition: border-color 0.15s, background 0.15s;
-}
-
-.sw-chat-dashboard__card:hover {
- border-color: var(--accent, #b38a4e);
- background: var(--accent-dim, rgba(179, 138, 78, 0.08));
-}
-
-.sw-chat-dashboard__card-icon {
- font-size: 24px;
- line-height: 1;
-}
-
-.sw-chat-dashboard__card-label {
- font-size: 12px;
- font-weight: 500;
-}
-
-.sw-chat-dashboard__recent {
- max-width: 360px;
- width: 100%;
-}
-
-.sw-chat-dashboard__section-title {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-3, #555);
- margin: 0 0 8px;
-}
-
-.sw-chat-dashboard__recent-list {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.sw-chat-dashboard__recent-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- border-radius: 6px;
- font-size: 12px;
- color: var(--text-2, #999);
-}
-
-.sw-chat-dashboard__recent-icon {
- width: 16px;
- text-align: center;
- flex-shrink: 0;
- font-size: 11px;
-}
-
-.sw-chat-dashboard__recent-title {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-@media (max-width: 768px) {
- .sw-chat-dashboard {
- padding: 24px 16px;
- }
- .sw-chat-dashboard__actions {
- gap: 8px;
- }
- .sw-chat-dashboard__card {
- padding: 16px 8px;
- }
-}
\ No newline at end of file
diff --git a/src/css/sw-notes-pane.css b/src/css/sw-notes-pane.css
deleted file mode 100644
index 99c3595..0000000
--- a/src/css/sw-notes-pane.css
+++ /dev/null
@@ -1,897 +0,0 @@
-/* ==========================================
- Chat Switchboard — Preact NotesPane Kit CSS
- ==========================================
- v0.37.9: Styles for the composable NotesPane kit.
- sw-notes-pane prefixed, no conflicts with legacy.
- ========================================== */
-
-/* ── Container ─────────────────────────────── */
-
-.sw-notes-pane {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
- background: var(--bg, #0e0e10);
-}
-
-/* ── Toolbar ───────────────────────────────── */
-
-.sw-notes-pane__toolbar {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 6px 10px;
- border-bottom: 1px solid var(--border, #2e2e35);
- background: var(--bg-surface, #18181b);
- flex-shrink: 0;
-}
-
-.sw-notes-pane__toolbar-spacer { flex: 1; }
-
-.sw-notes-pane__toolbar-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 4px 8px;
- background: none;
- border: 1px solid var(--border, #2e2e35);
- border-radius: 4px;
- color: var(--text-2, #9898a8);
- font-size: 12px;
- font-family: inherit;
- cursor: pointer;
- transition: color 0.15s, border-color 0.15s, background 0.15s;
- white-space: nowrap;
-}
-
-.sw-notes-pane__toolbar-btn:hover {
- color: var(--accent, #6c9fff);
- border-color: var(--accent, #6c9fff);
- background: var(--accent-dim, rgba(108,159,255,0.12));
-}
-
-/* ── Search + Filters ──────────────────────── */
-
-.sw-notes-pane__search-row {
- display: flex;
- padding: 6px 10px 0;
- flex-shrink: 0;
-}
-
-.sw-notes-pane__search-input {
- width: 100%;
- background: var(--input-bg, #1a1a1f);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 4px;
- color: var(--text, #e8e8ed);
- font-size: 12px;
- font-family: inherit;
- padding: 5px 8px;
- outline: none;
- transition: border-color 0.15s;
-}
-
-.sw-notes-pane__search-input:focus {
- border-color: var(--accent, #6c9fff);
-}
-
-.sw-notes-pane__filter-row {
- display: flex;
- gap: 4px;
- padding: 4px 10px 6px;
- flex-shrink: 0;
-}
-
-.sw-notes-pane__filter-select {
- flex: 1;
- background: var(--input-bg, #1a1a1f);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 4px;
- color: var(--text-2, #9898a8);
- font-size: 11px;
- font-family: inherit;
- padding: 3px 6px;
- cursor: pointer;
- outline: none;
-}
-
-.sw-notes-pane__filter-select:focus {
- border-color: var(--accent, #6c9fff);
-}
-
-/* ── Tag Cloud ─────────────────────────────── */
-
-.sw-notes-pane__tag-cloud {
- display: flex;
- flex-wrap: wrap;
- gap: 3px;
- padding: 0 10px 6px;
- flex-shrink: 0;
-}
-
-.sw-notes-pane__tag-chip {
- display: inline-flex;
- padding: 1px 6px;
- background: var(--purple-dim, rgba(167,139,250,0.1));
- color: var(--purple, #a78bfa);
- font-size: 10px;
- border-radius: 3px;
- cursor: pointer;
- border: 1px solid transparent;
- transition: border-color 0.15s, background 0.15s;
-}
-
-.sw-notes-pane__tag-chip:hover,
-.sw-notes-pane__tag-chip--active {
- border-color: var(--purple, #a78bfa);
- background: rgba(167,139,250,0.18);
-}
-
-/* ── Note List ─────────────────────────────── */
-
-.sw-notes-pane__list {
- flex: 1;
- overflow-y: auto;
- min-height: 0;
-}
-
-.sw-notes-pane__list-empty {
- padding: 24px 16px;
- text-align: center;
- color: var(--text-3, #6b6b7b);
- font-size: 13px;
-}
-
-.sw-notes-pane__pinned-divider {
- padding: 2px 10px;
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- border-bottom: 1px solid var(--border, #2e2e35);
- background: var(--bg-surface, #18181b);
-}
-
-/* ── Note List Item ────────────────────────── */
-
-.sw-notes-pane__item {
- display: flex;
- gap: 8px;
- padding: 8px 10px;
- border-bottom: 1px solid var(--border, #2e2e35);
- cursor: pointer;
- transition: background 0.12s;
-}
-
-.sw-notes-pane__item:hover {
- background: var(--bg-hover, #2a2a30);
-}
-
-.sw-notes-pane__item--selected {
- background: var(--accent-dim, rgba(108,159,255,0.12));
-}
-
-.sw-notes-pane__item-checkbox {
- flex-shrink: 0;
- margin-top: 2px;
-}
-
-.sw-notes-pane__item-body {
- flex: 1;
- min-width: 0;
-}
-
-.sw-notes-pane__item-header {
- display: flex;
- align-items: baseline;
- gap: 6px;
-}
-
-.sw-notes-pane__item-title {
- flex: 1;
- font-size: 13px;
- font-weight: 500;
- color: var(--text, #e8e8ed);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sw-notes-pane__item-time {
- flex-shrink: 0;
- font-size: 11px;
- color: var(--text-3, #6b6b7b);
-}
-
-.sw-notes-pane__item-preview {
- font-size: 12px;
- color: var(--text-2, #9898a8);
- margin-top: 2px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sw-notes-pane__item-preview mark {
- background: var(--warning-dim, rgba(234,179,8,0.2));
- color: var(--warning-light, #fbbf24);
- border-radius: 2px;
- padding: 0 1px;
-}
-
-.sw-notes-pane__item-meta {
- display: flex;
- gap: 4px;
- margin-top: 3px;
- flex-wrap: wrap;
-}
-
-.sw-notes-pane__item-folder {
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
- background: var(--bg-raised, #222227);
- padding: 0 4px;
- border-radius: 2px;
-}
-
-.sw-notes-pane__item-tag {
- font-size: 10px;
- color: var(--purple, #a78bfa);
- background: var(--purple-dim, rgba(167,139,250,0.1));
- padding: 0 4px;
- border-radius: 2px;
-}
-
-.sw-notes-pane__item-pin {
- flex-shrink: 0;
- background: none;
- border: none;
- color: var(--text-3, #6b6b7b);
- cursor: pointer;
- font-size: 12px;
- padding: 2px;
- opacity: 0.4;
- transition: opacity 0.15s;
-}
-
-.sw-notes-pane__item:hover .sw-notes-pane__item-pin,
-.sw-notes-pane__item-pin--active {
- opacity: 1;
-}
-
-.sw-notes-pane__item-pin--active {
- color: var(--accent, #6c9fff);
-}
-
-/* ── Selection Bar ─────────────────────────── */
-
-.sw-notes-pane__selection-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 10px;
- background: var(--bg-surface, #18181b);
- border-top: 1px solid var(--border, #2e2e35);
- font-size: 12px;
- color: var(--text-2, #9898a8);
- flex-shrink: 0;
-}
-
-.sw-notes-pane__selection-bar label {
- display: flex;
- align-items: center;
- gap: 4px;
- cursor: pointer;
-}
-
-/* ── Load More ─────────────────────────────── */
-
-.sw-notes-pane__load-more {
- display: block;
- width: 100%;
- padding: 8px;
- background: none;
- border: none;
- border-top: 1px solid var(--border, #2e2e35);
- color: var(--accent, #6c9fff);
- font-size: 12px;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.sw-notes-pane__load-more:hover {
- background: var(--accent-dim, rgba(108,159,255,0.12));
-}
-
-/* ── Editor ────────────────────────────────── */
-
-.sw-notes-pane__editor-header {
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 6px 10px;
- border-bottom: 1px solid var(--border, #2e2e35);
- background: var(--bg-surface, #18181b);
- flex-shrink: 0;
-}
-
-.sw-notes-pane__editor-header-spacer { flex: 1; }
-
-.sw-notes-pane__editor {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- padding: 10px;
- gap: 6px;
-}
-
-.sw-notes-pane__editor-title {
- width: 100%;
- background: transparent;
- border: none;
- border-bottom: 1px solid var(--border, #2e2e35);
- color: var(--text, #e8e8ed);
- font-size: 16px;
- font-weight: 600;
- font-family: inherit;
- padding: 4px 0;
- outline: none;
-}
-
-.sw-notes-pane__editor-title:focus {
- border-color: var(--accent, #6c9fff);
-}
-
-.sw-notes-pane__editor-meta {
- display: flex;
- gap: 6px;
-}
-
-.sw-notes-pane__editor-meta input {
- flex: 1;
- background: var(--input-bg, #1a1a1f);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 4px;
- color: var(--text-2, #9898a8);
- font-size: 11px;
- font-family: inherit;
- padding: 3px 6px;
- outline: none;
-}
-
-.sw-notes-pane__editor-meta input:focus {
- border-color: var(--accent, #6c9fff);
- color: var(--text, #e8e8ed);
-}
-
-.sw-notes-pane__editor-content {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-.sw-notes-pane__editor-textarea {
- flex: 1;
- width: 100%;
- min-height: 120px;
- background: var(--input-bg, #1a1a1f);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 4px;
- color: var(--text, #e8e8ed);
- font-size: 13px;
- font-family: var(--mono, 'JetBrains Mono', monospace);
- line-height: 1.6;
- padding: 8px;
- resize: none;
- outline: none;
-}
-
-.sw-notes-pane__editor-textarea:focus {
- border-color: var(--accent, #6c9fff);
-}
-
-.sw-notes-pane__editor-footer {
- display: flex;
- justify-content: space-between;
- font-size: 11px;
- color: var(--text-3, #6b6b7b);
- padding-top: 4px;
- flex-shrink: 0;
-}
-
-/* ── Reader ────────────────────────────────── */
-
-.sw-notes-pane__reader {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-/* With outline: side-by-side layout */
-.sw-notes-pane__reader--with-outline {
- flex-direction: row;
-}
-
-.sw-notes-pane__reader-scroll {
- flex: 1;
- overflow-y: auto;
- padding: 12px 16px;
- min-height: 0;
-}
-
-/* ── Outline Sidebar (sticky, right side) ─── */
-
-.sw-notes-pane__outline-sidebar {
- width: 160px;
- flex-shrink: 0;
- overflow-y: auto;
- padding: 12px 8px;
- border-left: 1px solid var(--border, #2e2e35);
- position: sticky;
- top: 0;
- align-self: flex-start;
- max-height: 100%;
-}
-
-@media (max-width: 600px) {
- .sw-notes-pane__reader--with-outline { flex-direction: column; }
- .sw-notes-pane__outline-sidebar {
- width: 100%;
- border-left: none;
- border-top: 1px solid var(--border, #2e2e35);
- position: static;
- max-height: none;
- }
-}
-
-.sw-notes-pane__reader-title {
- font-size: 20px;
- font-weight: 700;
- color: var(--text, #e8e8ed);
- margin: 0 0 4px;
- line-height: 1.3;
-}
-
-.sw-notes-pane__reader-meta {
- display: flex;
- gap: 4px;
- flex-wrap: wrap;
- margin-bottom: 12px;
-}
-
-.sw-notes-pane__reader-content {
- font-size: 14px;
- line-height: 1.7;
- color: var(--text, #e8e8ed);
-}
-
-/* Reader markdown */
-.sw-notes-pane__reader-content p { margin: 0 0 0.6em; }
-.sw-notes-pane__reader-content p:last-child { margin-bottom: 0; }
-
-.sw-notes-pane__reader-content pre {
- background: var(--bg-surface, #18181b);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 6px;
- padding: 10px 12px;
- overflow-x: auto;
- margin: 8px 0;
- font-size: 12px;
- line-height: 1.5;
- font-family: var(--mono, 'JetBrains Mono', monospace);
-}
-
-.sw-notes-pane__reader-content code {
- background: var(--bg-surface, #18181b);
- padding: 1px 5px;
- border-radius: 3px;
- font-size: 0.88em;
- font-family: var(--mono, 'JetBrains Mono', monospace);
-}
-
-.sw-notes-pane__reader-content pre code {
- background: none; padding: 0; border-radius: 0; font-size: inherit;
-}
-
-.sw-notes-pane__reader-content ul,
-.sw-notes-pane__reader-content ol { margin: 4px 0; padding-left: 1.5em; }
-
-.sw-notes-pane__reader-content blockquote {
- border-left: 3px solid var(--accent, #6c9fff);
- padding: 4px 12px;
- margin: 8px 0;
- color: var(--text-2, #9898a8);
-}
-
-.sw-notes-pane__reader-content a {
- color: var(--accent, #6c9fff);
- text-decoration: none;
-}
-.sw-notes-pane__reader-content a:hover { text-decoration: underline; }
-
-.sw-notes-pane__reader-content table { border-collapse: collapse; margin: 8px 0; font-size: 13px; }
-.sw-notes-pane__reader-content th,
-.sw-notes-pane__reader-content td { border: 1px solid var(--border, #2e2e35); padding: 4px 8px; }
-.sw-notes-pane__reader-content th { background: var(--bg-surface, #18181b); font-weight: 600; }
-
-/* ── Daily Nav ─────────────────────────────── */
-
-.sw-notes-pane__daily-nav {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 0 8px;
- font-size: 12px;
- color: var(--text-2, #9898a8);
-}
-
-.sw-notes-pane__daily-nav button:disabled {
- opacity: 0.3;
- cursor: not-allowed;
-}
-
-/* ── Outline / TOC ─────────────────────────── */
-
-/* Outline items shared by sidebar + inline fallback */
-.sw-notes-pane__outline-sidebar,
-.sw-notes-pane__outline {
- /* shared base */
-}
-
-.sw-notes-pane__outline-title {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-3, #6b6b7b);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- padding: 0 0 4px;
-}
-
-.sw-notes-pane__outline-item {
- display: block;
- padding: 2px 0;
- font-size: 12px;
- color: var(--text-2, #9898a8);
- cursor: pointer;
- text-decoration: none;
- transition: color 0.12s;
-}
-
-.sw-notes-pane__outline-item:hover {
- color: var(--accent, #6c9fff);
-}
-
-.sw-notes-pane__outline-item--h2 { padding-left: 12px; }
-.sw-notes-pane__outline-item--h3 { padding-left: 24px; }
-
-/* ── Backlinks ─────────────────────────────── */
-
-.sw-notes-pane__backlinks {
- border-top: 1px solid var(--border, #2e2e35);
- padding: 8px 0;
- margin-top: 12px;
-}
-
-.sw-notes-pane__backlinks-header {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- font-weight: 600;
- color: var(--text-3, #6b6b7b);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- cursor: pointer;
- user-select: none;
-}
-
-.sw-notes-pane__backlinks-badge {
- background: var(--accent-dim, rgba(108,159,255,0.12));
- color: var(--accent, #6c9fff);
- font-size: 10px;
- padding: 0 5px;
- border-radius: 8px;
- font-weight: 600;
-}
-
-.sw-notes-pane__backlinks-list {
- margin-top: 4px;
-}
-
-.sw-notes-pane__backlink-item {
- display: flex;
- align-items: baseline;
- gap: 6px;
- padding: 3px 0;
- font-size: 12px;
- cursor: pointer;
- color: var(--accent, #6c9fff);
- transition: color 0.12s;
-}
-
-.sw-notes-pane__backlink-item:hover {
- color: var(--accent-hover, #84b0ff);
-}
-
-.sw-notes-pane__backlink-folder {
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
-}
-
-/* ── Wikilinks ─────────────────────────────── */
-
-.sw-notes-pane__wikilink {
- display: inline;
- padding: 1px 5px;
- background: var(--accent-dim, rgba(108,159,255,0.12));
- color: var(--accent, #6c9fff);
- border-radius: 3px;
- cursor: pointer;
- font-size: 0.92em;
- transition: background 0.12s;
-}
-
-.sw-notes-pane__wikilink:hover {
- background: rgba(108,159,255,0.22);
-}
-
-.sw-notes-pane__wikilink--transclusion {
- color: var(--purple, #a78bfa);
- background: var(--purple-dim, rgba(167,139,250,0.1));
-}
-
-.sw-notes-pane__wikilink--transclusion:hover {
- background: rgba(167,139,250,0.2);
-}
-
-.sw-notes-pane__transclusion {
- border: 1px solid var(--border, #2e2e35);
- border-left: 3px solid var(--purple, #a78bfa);
- border-radius: 4px;
- margin: 8px 0;
- overflow: hidden;
-}
-
-.sw-notes-pane__transclusion-header {
- padding: 4px 8px;
- background: var(--bg-surface, #18181b);
- font-size: 11px;
-}
-
-.sw-notes-pane__transclusion-content {
- padding: 8px 12px;
- font-size: 13px;
- line-height: 1.6;
- color: var(--text-2, #9898a8);
-}
-
-/* ── Unlinked Mentions ─────────────────────── */
-
-.sw-notes-pane__unlinked {
- border-top: 1px solid var(--border, #2e2e35);
- padding: 8px 0;
- margin-top: 8px;
-}
-
-.sw-notes-pane__unlinked-title {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-3, #6b6b7b);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- padding: 0 0 4px;
-}
-
-.sw-notes-pane__unlinked-item {
- display: inline-flex;
- padding: 1px 6px;
- margin: 1px 2px;
- background: var(--bg-raised, #222227);
- color: var(--text-2, #9898a8);
- font-size: 11px;
- border-radius: 3px;
- cursor: pointer;
- border: 1px dashed var(--border, #2e2e35);
- transition: border-color 0.12s, color 0.12s;
-}
-
-.sw-notes-pane__unlinked-item:hover {
- border-color: var(--accent, #6c9fff);
- color: var(--accent, #6c9fff);
-}
-
-/* ── Graph ─────────────────────────────────── */
-
-.sw-notes-pane__graph {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.sw-notes-pane__graph-toolbar {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 10px;
- border-bottom: 1px solid var(--border, #2e2e35);
- background: var(--bg-surface, #18181b);
- flex-shrink: 0;
-}
-
-.sw-notes-pane__graph-stats {
- font-size: 11px;
- color: var(--text-3, #6b6b7b);
-}
-
-.sw-notes-pane__graph-canvas-wrap {
- flex: 1;
- min-height: 0;
- position: relative;
-}
-
-.sw-notes-pane__graph-canvas-wrap canvas {
- display: block;
- width: 100%;
- height: 100%;
-}
-
-/* ── Quick Switcher ────────────────────────── */
-
-.sw-notes-pane__switcher-overlay {
- position: fixed;
- inset: 0;
- background: var(--overlay, rgba(0,0,0,0.55));
- display: flex;
- align-items: flex-start;
- justify-content: center;
- padding-top: 15vh;
- z-index: 9999;
-}
-
-.sw-notes-pane__switcher {
- width: min(420px, 90vw);
- background: var(--bg-surface, #18181b);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 8px;
- box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
- overflow: hidden;
-}
-
-.sw-notes-pane__switcher-input {
- width: 100%;
- background: transparent;
- border: none;
- border-bottom: 1px solid var(--border, #2e2e35);
- color: var(--text, #e8e8ed);
- font-size: 15px;
- font-family: inherit;
- padding: 12px 14px;
- outline: none;
-}
-
-.sw-notes-pane__switcher-results {
- max-height: 300px;
- overflow-y: auto;
-}
-
-.sw-notes-pane__switcher-section {
- padding: 4px 12px 2px;
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.sw-notes-pane__switcher-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 14px;
- cursor: pointer;
- transition: background 0.1s;
-}
-
-.sw-notes-pane__switcher-item:hover,
-.sw-notes-pane__switcher-item--active {
- background: var(--accent-dim, rgba(108,159,255,0.12));
-}
-
-.sw-notes-pane__switcher-item-title {
- flex: 1;
- font-size: 13px;
- color: var(--text, #e8e8ed);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sw-notes-pane__switcher-item-folder {
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
-}
-
-.sw-notes-pane__switcher-create {
- padding: 8px 14px;
- border-top: 1px solid var(--border, #2e2e35);
- font-size: 12px;
- color: var(--accent, #6c9fff);
- cursor: pointer;
- transition: background 0.1s;
-}
-
-.sw-notes-pane__switcher-create:hover {
- background: var(--accent-dim, rgba(108,159,255,0.12));
-}
-
-.sw-notes-pane__switcher-hint {
- padding: 6px 14px;
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
- text-align: right;
- border-top: 1px solid var(--border, #2e2e35);
-}
-
-/* ── Save-to-Note Dialog ───────────────────── */
-
-.sw-notes-pane__save-mode {
- display: flex;
- gap: 12px;
- margin-bottom: 10px;
- font-size: 13px;
-}
-
-.sw-notes-pane__save-mode label {
- display: flex;
- align-items: center;
- gap: 4px;
- cursor: pointer;
- color: var(--text-2, #9898a8);
-}
-
-.sw-notes-pane__save-search-results {
- max-height: 150px;
- overflow-y: auto;
- margin-top: 4px;
-}
-
-.sw-notes-pane__save-search-item {
- padding: 6px 8px;
- font-size: 12px;
- color: var(--text, #e8e8ed);
- cursor: pointer;
- border-radius: 3px;
- transition: background 0.1s;
-}
-
-.sw-notes-pane__save-search-item:hover,
-.sw-notes-pane__save-search-item--selected {
- background: var(--accent-dim, rgba(108,159,255,0.12));
-}
-
-.sw-notes-pane__save-preview {
- max-height: 120px;
- overflow-y: auto;
- padding: 8px;
- background: var(--bg-raised, #222227);
- border-radius: 4px;
- font-size: 12px;
- line-height: 1.5;
- color: var(--text-2, #9898a8);
-}
-
-/* ── Shared: loading spinner ───────────────── */
-
-.sw-notes-pane__loading {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 24px;
- color: var(--text-3, #6b6b7b);
- font-size: 13px;
-}
diff --git a/src/css/sw-notes-surface.css b/src/css/sw-notes-surface.css
deleted file mode 100644
index 7a25927..0000000
--- a/src/css/sw-notes-surface.css
+++ /dev/null
@@ -1,377 +0,0 @@
-/* ==========================================
- Chat Switchboard — Notes Surface CSS
- ==========================================
- v0.37.11: Preact notes surface layout.
- sw-notes-surface__ prefixed, no legacy conflicts.
- ========================================== */
-
-/* ── Root Layout ──────────────────────────── */
-
-.sw-notes-surface {
- display: flex;
- width: 100%;
- height: 100%;
- flex: 1 1 0%;
- overflow: hidden;
- background: var(--bg, #0e0e10);
-}
-
-/* ── Sidebar ──────────────────────────────── */
-
-.sw-notes-surface__sidebar {
- width: 260px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- background: var(--bg-secondary, #151517);
- border-right: 1px solid var(--border, #2a2a2e);
- overflow: hidden;
- z-index: 20;
-}
-
-.sw-notes-surface__sidebar-header {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 10px 12px 6px;
- flex-shrink: 0;
-}
-
-.sw-notes-surface__new-note-btn {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 1;
- padding: 8px 12px;
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- border: 1px solid var(--accent, #b38a4e);
- border-radius: 8px;
- color: var(--accent, #b38a4e);
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- transition: background 0.15s;
-}
-
-.sw-notes-surface__new-note-btn:hover {
- background: rgba(179, 138, 78, 0.2);
-}
-
-.sw-notes-surface__collapse-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- flex-shrink: 0;
- transition: color 0.15s, background 0.15s;
-}
-
-.sw-notes-surface__collapse-btn:hover {
- color: var(--text, #eee);
- background: var(--bg, #0e0e10);
-}
-
-/* ── Search ───────────────────────────────── */
-
-.sw-notes-surface__search-wrap {
- position: relative;
- padding: 4px 12px 8px;
- flex-shrink: 0;
-}
-
-.sw-notes-surface__search-icon {
- position: absolute;
- left: 20px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--text-3, #555);
- pointer-events: none;
- display: flex;
-}
-
-input.sw-notes-surface__search {
- width: 100%;
- padding: 6px 8px 6px 30px;
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 6px;
- color: var(--text, #eee);
- font-size: 12px;
- font-family: inherit;
- outline: none;
- box-sizing: border-box;
-}
-
-input.sw-notes-surface__search:focus {
- border-color: var(--accent, #b38a4e);
-}
-
-/* ── Sidebar Body (scrollable) ────────────── */
-
-.sw-notes-surface__sidebar-body {
- flex: 1;
- overflow-y: auto;
- min-height: 0;
- padding-bottom: 8px;
-}
-
-/* ── Section ──────────────────────────────── */
-
-.sw-notes-surface__section {
- margin-top: 4px;
-}
-
-.sw-notes-surface__section-header {
- display: flex;
- align-items: center;
- gap: 6px;
- width: 100%;
- padding: 6px 12px;
- background: none;
- border: none;
- color: var(--text-2, #999);
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- cursor: pointer;
- transition: color 0.15s;
-}
-
-.sw-notes-surface__section-header:hover {
- color: var(--text, #eee);
-}
-
-.sw-notes-surface__chevron {
- display: flex;
- transition: transform 0.15s;
-}
-
-.sw-notes-surface__chevron--open {
- transform: rotate(90deg);
-}
-
-.sw-notes-surface__section-count {
- margin-left: auto;
- font-size: 10px;
- color: var(--text-3, #555);
- font-weight: 400;
-}
-
-.sw-notes-surface__section-body {
- padding: 0 4px;
-}
-
-.sw-notes-surface__empty {
- padding: 8px 16px;
- font-size: 12px;
- color: var(--text-3, #555);
-}
-
-/* ── Sidebar Item ─────────────────────────── */
-
-.sw-notes-surface__item {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- padding: 6px 12px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-2, #999);
- font-size: 13px;
- cursor: pointer;
- text-align: left;
- transition: background 0.1s, color 0.1s;
- position: relative;
-}
-
-.sw-notes-surface__item:hover {
- background: var(--bg, #0e0e10);
- color: var(--text, #eee);
-}
-
-.sw-notes-surface__item--active {
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- color: var(--text, #eee);
-}
-
-.sw-notes-surface__item-icon {
- display: flex;
- flex-shrink: 0;
- color: var(--text-3, #555);
-}
-
-.sw-notes-surface__item-title {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sw-notes-surface__item-count {
- font-size: 10px;
- color: var(--text-3, #555);
- flex-shrink: 0;
-}
-
-/* ── Tag Cloud ───────────────────────────── */
-
-.sw-notes-surface__tag-cloud {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- padding: 4px 8px;
-}
-
-.sw-notes-surface__tag-chip {
- display: inline-block;
- padding: 2px 8px;
- background: var(--bg, #0e0e10);
- border: 1px solid var(--border, #2a2a2e);
- border-radius: 12px;
- color: var(--text-2, #999);
- font-size: 11px;
- cursor: pointer;
- transition: background 0.1s, color 0.1s, border-color 0.1s;
- user-select: none;
-}
-
-.sw-notes-surface__tag-chip:hover {
- color: var(--text, #eee);
- border-color: var(--text-3, #555);
-}
-
-.sw-notes-surface__tag-chip--active {
- background: var(--accent-dim, rgba(179, 138, 78, 0.1));
- border-color: var(--accent, #b38a4e);
- color: var(--accent, #b38a4e);
-}
-
-/* ── Sidebar Footer ──────────────────────── */
-
-.sw-notes-surface__sidebar-footer {
- display: flex;
- align-items: center;
- padding: 8px 12px;
- border-top: 1px solid var(--border, #2a2a2e);
- flex-shrink: 0;
- position: relative;
-}
-
-/* ── Main Area ───────────────────────────── */
-
-.sw-notes-surface__main {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-width: 0;
- position: relative;
-}
-
-/* ── Workspace ───────────────────────────── */
-
-.sw-notes-surface__workspace {
- flex: 1 1 0%;
- display: flex;
- flex-direction: column;
- min-height: 0;
- min-width: 0;
- position: relative;
- overflow: hidden;
-}
-
-.sw-notes-surface__workspace-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 4px 10px;
- border-bottom: 1px solid var(--border, #2a2a2e);
- background: var(--bg-secondary, #151517);
- flex-shrink: 0;
- min-height: 40px;
-}
-
-.sw-notes-surface__workspace-header-left,
-.sw-notes-surface__workspace-header-right {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.sw-notes-surface__sidebar-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- background: none;
- border: none;
- border-radius: 6px;
- color: var(--text-3, #555);
- cursor: pointer;
- transition: color 0.15s, background 0.15s;
-}
-
-.sw-notes-surface__sidebar-toggle:hover {
- color: var(--text, #eee);
- background: var(--bg, #0e0e10);
-}
-
-/* ── NotesPane Container ─────────────────── */
-
-.sw-notes-surface__notes-pane {
- flex: 1 1 0%;
- min-height: 0;
- min-width: 0;
-}
-
-/* ── Sidebar Overlay (mobile) ────────────── */
-
-.sw-notes-surface__sidebar-overlay {
- display: none; /* Hidden on desktop */
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.5);
- z-index: 15;
-}
-
-/* ── Responsive: Mobile ──────────────────── */
-
-@media (max-width: 768px) {
- .sw-notes-surface__sidebar {
- position: fixed;
- left: 0;
- top: 0;
- bottom: 0;
- width: 280px;
- z-index: 120;
- }
-
- .sw-notes-surface__sidebar-overlay {
- display: block;
- z-index: 110;
- }
-
- .sw-notes-surface__collapse-btn {
- display: none;
- }
-
- /* Touch targets */
- .sw-notes-surface__item {
- min-height: 44px;
- }
-
- /* Prevent iOS zoom */
- .sw-notes-surface__search {
- font-size: 16px;
- }
-}
diff --git a/src/css/sw-projects-surface.css b/src/css/sw-projects-surface.css
deleted file mode 100644
index f6ac18a..0000000
--- a/src/css/sw-projects-surface.css
+++ /dev/null
@@ -1,793 +0,0 @@
-/* ==========================================
- Chat Switchboard — Projects Surface CSS
- ==========================================
- v0.37.16: Card grid list + detail with inline chat.
- sw-projects__ prefixed, BEM convention.
- ========================================== */
-
-/* ── Root ────────────────────────────────── */
-
-.sw-projects {
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background: var(--bg, #0e0e10);
-}
-
-/* ── List View ───────────────────────────── */
-
-.sw-projects__list {
- flex: 1;
- overflow-y: auto;
- padding: 24px 32px 40px;
- max-width: 940px;
- margin: 0 auto;
- width: 100%;
-}
-
-.sw-projects__list-header {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 16px;
-}
-
-.sw-projects__list-header .sw-projects__new-btn {
- margin-left: auto;
-}
-
-.sw-projects__list-title {
- font-size: 22px;
- font-weight: 600;
- color: var(--text, #e8e8ed);
- margin: 0;
-}
-
-.sw-projects__new-btn {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 8px 16px;
- background: var(--bg-raised, #222227);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 8px;
- color: var(--text, #e8e8ed);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s, border-color 0.15s;
-}
-
-.sw-projects__new-btn:hover {
- background: var(--bg-hover, #2a2a30);
- border-color: var(--border-light, #3a3a42);
-}
-
-.sw-projects__search {
- width: 100%;
- padding: 10px 14px 10px 36px;
- border: 1px solid var(--border, #2e2e35);
- border-radius: 10px;
- background: var(--bg-surface, #18181b);
- color: var(--text, #e8e8ed);
- font-size: 14px;
- margin-bottom: 8px;
- outline: none;
- transition: border-color 0.15s;
-}
-
-.sw-projects__search:focus {
- border-color: var(--accent, #6c9fff);
-}
-
-.sw-projects__search-wrap {
- position: relative;
-}
-
-.sw-projects__search-icon {
- position: absolute;
- left: 12px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--text-3, #6b6b7b);
- font-size: 14px;
- pointer-events: none;
-}
-
-.sw-projects__sort {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- gap: 6px;
- margin-bottom: 16px;
- font-size: 13px;
- color: var(--text-3, #6b6b7b);
-}
-
-.sw-projects__sort select {
- background: var(--bg-raised, #222227);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 6px;
- color: var(--text-2, #9898a8);
- font-size: 13px;
- padding: 4px 8px;
- cursor: pointer;
-}
-
-/* ── Card Grid ───────────────────────────── */
-
-.sw-projects__grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 12px;
-}
-
-.sw-projects__card {
- background: var(--bg-surface, #18181b);
- border: 1px solid var(--border, #2e2e35);
- border-radius: 12px;
- padding: 20px;
- cursor: pointer;
- transition: border-color 0.15s, box-shadow 0.15s;
- display: flex;
- flex-direction: column;
- min-height: 120px;
-}
-
-.sw-projects__card:hover {
- border-color: var(--border-light, #3a3a42);
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
-}
-
-.sw-projects__card-name {
- font-size: 15px;
- font-weight: 600;
- color: var(--text, #e8e8ed);
- margin-bottom: 8px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.sw-projects__card-desc {
- font-size: 13px;
- color: var(--text-2, #9898a8);
- flex: 1;
- overflow: hidden;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- line-height: 1.5;
-}
-
-.sw-projects__card-meta {
- font-size: 12px;
- color: var(--text-3, #6b6b7b);
- margin-top: 12px;
-}
-
-.sw-projects__card-badge {
- display: inline-flex;
- padding: 2px 8px;
- font-size: 11px;
- font-weight: 500;
- border-radius: 4px;
- background: var(--bg-raised, #222227);
- border: 1px solid var(--border, #2e2e35);
- color: var(--text-3, #6b6b7b);
-}
-
-.sw-projects__empty {
- text-align: center;
- padding: 60px 20px;
- color: var(--text-3, #6b6b7b);
- font-size: 14px;
-}
-
-
-/* ── Detail View ─────────────────────────── */
-
-.sw-projects__detail {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
-}
-
-.sw-projects__detail-header {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px 20px;
- border-bottom: 1px solid var(--border, #2e2e35);
- flex-shrink: 0;
- min-height: 48px;
-}
-
-.sw-projects__back {
- font-size: 13px;
- color: var(--text-3, #6b6b7b);
- cursor: pointer;
- text-decoration: none;
- white-space: nowrap;
- transition: color 0.15s;
-}
-
-.sw-projects__back:hover {
- color: var(--text, #e8e8ed);
-}
-
-.sw-projects__detail-name {
- font-size: 17px;
- font-weight: 600;
- color: var(--text, #e8e8ed);
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- margin: 0;
-}
-
-.sw-projects__detail-name-input {
- font-size: 17px;
- font-weight: 600;
- color: var(--text, #e8e8ed);
- background: var(--bg-surface, #18181b);
- border: 1px solid var(--accent, #6c9fff);
- border-radius: 6px;
- padding: 2px 8px;
- flex: 1;
- min-width: 0;
- outline: none;
-}
-
-.sw-projects__header-actions {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
-}
-
-.sw-projects__icon-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--text-3, #6b6b7b);
- cursor: pointer;
- transition: background 0.15s, color 0.15s;
- font-size: 16px;
-}
-
-.sw-projects__icon-btn:hover {
- background: var(--bg-hover, #2a2a30);
- color: var(--text, #e8e8ed);
-}
-
-.sw-projects__icon-btn--active {
- color: var(--warning, #eab308);
-}
-
-/* ── Detail Body (2-column) ──────────────── */
-
-.sw-projects__detail-body {
- display: flex;
- flex: 1;
- overflow: hidden;
-}
-
-.sw-projects__conversations {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- min-width: 0;
-}
-
-.sw-projects__conv-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 20px;
- flex-shrink: 0;
-}
-
-.sw-projects__conv-list {
- flex: 1;
- overflow-y: auto;
- padding: 0 12px 20px;
-}
-
-.sw-projects__conv-item {
- display: flex;
- flex-direction: column;
- padding: 12px;
- border-radius: 8px;
- cursor: pointer;
- transition: background 0.12s;
-}
-
-.sw-projects__conv-item:hover {
- background: var(--bg-hover, #2a2a30);
-}
-
-.sw-projects__conv-title {
- font-size: 14px;
- font-weight: 500;
- color: var(--text, #e8e8ed);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sw-projects__conv-meta {
- font-size: 12px;
- color: var(--text-3, #6b6b7b);
- margin-top: 4px;
-}
-
-.sw-projects__conv-empty {
- padding: 40px 20px;
- text-align: center;
- color: var(--text-3, #6b6b7b);
- font-size: 13px;
-}
-
-/* ── Chat Embed ──────────────────────────── */
-
-.sw-projects__chat-wrap {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- min-width: 0;
-}
-
-.sw-projects__chat-back {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 8px 16px;
- font-size: 12px;
- color: var(--text-3, #6b6b7b);
- cursor: pointer;
- border-bottom: 1px solid var(--border, #2e2e35);
- flex-shrink: 0;
- transition: color 0.15s;
-}
-
-.sw-projects__chat-back:hover {
- color: var(--text, #e8e8ed);
-}
-
-/* ── Right Sidebar ───────────────────────── */
-
-.sw-projects__sidebar {
- width: 320px;
- flex-shrink: 0;
- border-left: 1px solid var(--border, #2e2e35);
- overflow-y: auto;
- padding: 12px 16px;
- background: var(--bg-surface, #18181b);
-}
-
-/* ── Collapsible Sections ────────────────── */
-
-.sw-projects__section {
- margin-bottom: 4px;
-}
-
-.sw-projects__section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 0;
- cursor: pointer;
- user-select: none;
-}
-
-.sw-projects__section-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-2, #9898a8);
-}
-
-.sw-projects__section-count {
- font-size: 11px;
- color: var(--text-3, #6b6b7b);
- margin-left: 6px;
-}
-
-.sw-projects__section-actions {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.sw-projects__section-add {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- border-radius: 6px;
- border: none;
- background: transparent;
- color: var(--text-3, #6b6b7b);
- cursor: pointer;
- font-size: 16px;
- transition: background 0.15s, color 0.15s;
-}
-
-.sw-projects__section-add:hover {
- background: var(--bg-hover, #2a2a30);
- color: var(--text, #e8e8ed);
-}
-
-.sw-projects__section-chevron {
- font-size: 10px;
- color: var(--text-3, #6b6b7b);
- transition: transform 0.15s;
-}
-
-.sw-projects__section-chevron--open {
- transform: rotate(90deg);
-}
-
-.sw-projects__section-body {
- padding-bottom: 8px;
-}
-
-.sw-projects__section-textarea {
- width: 100%;
- padding: 8px 10px;
- border: 1px solid var(--border, #2e2e35);
- border-radius: 8px;
- background: var(--bg-raised, #222227);
- color: var(--text, #e8e8ed);
- font-size: 13px;
- line-height: 1.5;
- resize: vertical;
- min-height: 60px;
- outline: none;
- transition: border-color 0.15s;
- font-family: inherit;
-}
-
-.sw-projects__section-textarea:focus {
- border-color: var(--accent, #6c9fff);
-}
-
-.sw-projects__section-hint {
- font-size: 12px;
- color: var(--text-3, #6b6b7b);
- padding: 8px 0;
-}
-
-/* ── Section Items (KBs, Notes, Files) ──── */
-
-.sw-projects__section-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 8px;
- border-radius: 6px;
- font-size: 13px;
- color: var(--text, #e8e8ed);
- transition: background 0.12s;
-}
-
-.sw-projects__section-item:hover {
- background: var(--bg-hover, #2a2a30);
-}
-
-.sw-projects__section-item-name {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sw-projects__section-item-meta {
- font-size: 11px;
- color: var(--text-3, #6b6b7b);
- flex-shrink: 0;
-}
-
-.sw-projects__section-item-remove {
- display: none;
- font-size: 11px;
- color: var(--danger, #ef4444);
- cursor: pointer;
- padding: 2px 6px;
- border-radius: 4px;
- background: transparent;
- border: none;
- transition: background 0.15s;
-}
-
-.sw-projects__section-item:hover .sw-projects__section-item-remove {
- display: inline-flex;
-}
-
-.sw-projects__section-item-remove:hover {
- background: var(--danger-dim, rgba(239, 68, 68, 0.2));
-}
-
-/* ── File Drop Zone ──────────────────────── */
-
-.sw-projects__dropzone {
- border: 2px dashed var(--border, #2e2e35);
- border-radius: 8px;
- padding: 20px;
- text-align: center;
- color: var(--text-3, #6b6b7b);
- font-size: 13px;
- transition: border-color 0.15s, background 0.15s;
- cursor: pointer;
-}
-
-.sw-projects__dropzone:hover {
- border-color: var(--border-light, #3a3a42);
-}
-
-.sw-projects__dropzone--active {
- border-color: var(--accent, #6c9fff);
- background: var(--accent-dim, rgba(108, 159, 255, 0.12));
- color: var(--text, #e8e8ed);
-}
-
-/* ── Picker Dropdown ─────────────────────── */
-
-.sw-projects__picker {
- position: absolute;
- right: 0;
- top: 100%;
- z-index: 10;
- width: 280px;
- margin-top: 4px;
- padding: 6px;
- border: 1px solid var(--border, #2e2e35);
- border-radius: 8px;
- background: var(--bg-raised, #222227);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
- max-height: 240px;
- overflow-y: auto;
-}
-
-.sw-projects__picker-item {
- padding: 6px 10px;
- border-radius: 6px;
- font-size: 13px;
- color: var(--text, #e8e8ed);
- cursor: pointer;
- transition: background 0.12s;
-}
-
-.sw-projects__picker-item:hover {
- background: var(--bg-hover, #2a2a30);
-}
-
-.sw-projects__picker-hint {
- padding: 8px 10px;
- font-size: 12px;
- color: var(--text-3, #6b6b7b);
-}
-
-/* ── Context Menu (... menu) ─────────────── */
-
-.sw-projects__ctx-menu {
- position: absolute;
- right: 0;
- top: 100%;
- z-index: 20;
- min-width: 180px;
- padding: 6px;
- border: 1px solid var(--border, #2e2e35);
- border-radius: 8px;
- background: var(--bg-raised, #222227);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
-}
-
-.sw-projects__ctx-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- border-radius: 6px;
- font-size: 13px;
- color: var(--text, #e8e8ed);
- cursor: pointer;
- transition: background 0.12s;
- border: none;
- background: none;
- width: 100%;
- text-align: left;
-}
-
-.sw-projects__ctx-item:hover {
- background: var(--bg-hover, #2a2a30);
-}
-
-.sw-projects__ctx-item--danger {
- color: var(--danger, #ef4444);
-}
-
-.sw-projects__ctx-divider {
- height: 1px;
- background: var(--border, #2e2e35);
- margin: 4px 0;
-}
-
-/* ── Color Swatches (in menu) ────────────── */
-
-.sw-projects__color-row {
- display: flex;
- gap: 4px;
- padding: 8px 12px;
-}
-
-.sw-projects__color-swatch {
- width: 20px;
- height: 20px;
- border-radius: 50%;
- cursor: pointer;
- border: 2px solid transparent;
- transition: border-color 0.15s;
-}
-
-.sw-projects__color-swatch--active {
- border-color: var(--text, #e8e8ed);
-}
-
-/* ── File Browser (v0.37.17) ──────────────── */
-
-.sw-projects__file-browser {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-
-.sw-projects__file-actions-bar {
- display: flex;
- gap: 6px;
- padding: 4px 0 8px;
- border-bottom: 1px solid var(--border, #2e2e35);
- margin-bottom: 4px;
-}
-
-.sw-projects__file-action-btn {
- background: none;
- border: 1px solid var(--border, #2e2e35);
- color: var(--text-3, #999);
- border-radius: 4px;
- padding: 3px 8px;
- font-size: 11px;
- cursor: pointer;
- transition: color 0.15s, border-color 0.15s;
-}
-.sw-projects__file-action-btn:hover {
- color: var(--text-1, #fff);
- border-color: var(--text-3, #999);
-}
-
-.sw-projects__file-row {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 8px;
- border-radius: 4px;
- cursor: default;
- transition: background 0.1s;
-}
-.sw-projects__file-row:hover {
- background: var(--bg-3, rgba(255,255,255,0.04));
-}
-
-.sw-projects__file-chevron {
- font-size: 8px;
- cursor: pointer;
- color: var(--text-3, #999);
- transition: transform 0.15s;
- display: inline-block;
- width: 12px;
- text-align: center;
- flex-shrink: 0;
-}
-.sw-projects__file-chevron--open {
- transform: rotate(90deg);
-}
-
-.sw-projects__file-icon {
- flex-shrink: 0;
- font-size: 13px;
-}
-
-.sw-projects__file-name {
- flex: 1;
- font-size: 12px;
- color: var(--text-2, #ccc);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- cursor: pointer;
-}
-.sw-projects__file-name:hover {
- color: var(--text-1, #fff);
- text-decoration: underline;
-}
-
-.sw-projects__file-meta {
- font-size: 11px;
- color: var(--text-3, #999);
- flex-shrink: 0;
-}
-
-.sw-projects__file-delete {
- opacity: 0;
- background: none;
- border: none;
- color: var(--danger, #ef4444);
- cursor: pointer;
- font-size: 14px;
- padding: 0 2px;
- flex-shrink: 0;
- transition: opacity 0.1s;
-}
-.sw-projects__file-row:hover .sw-projects__file-delete {
- opacity: 0.7;
-}
-.sw-projects__file-delete:hover {
- opacity: 1 !important;
-}
-
-.sw-projects__dropzone {
- margin-top: 8px;
- padding: 16px;
- border: 2px dashed var(--border, #2e2e35);
- border-radius: 8px;
- text-align: center;
- font-size: 12px;
- color: var(--text-3, #999);
- cursor: pointer;
- transition: border-color 0.15s, color 0.15s, background 0.15s;
-}
-.sw-projects__dropzone:hover {
- border-color: var(--text-3, #999);
- color: var(--text-2, #ccc);
-}
-.sw-projects__dropzone--active {
- border-color: var(--accent, #4f8cff);
- color: var(--accent, #4f8cff);
- background: rgba(79, 140, 255, 0.05);
-}
-
-/* ── Responsive ──────────────────────────── */
-
-@media (max-width: 768px) {
- .sw-projects__grid {
- grid-template-columns: 1fr;
- }
-
- .sw-projects__list {
- padding: 16px;
- }
-
- .sw-projects__detail-body {
- flex-direction: column;
- }
-
- .sw-projects__sidebar {
- width: 100%;
- border-left: none;
- border-top: 1px solid var(--border, #2e2e35);
- max-height: 40vh;
- }
-}
diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js
index 01547a2..73a50e7 100644
--- a/src/js/sw/sdk/api-domains.js
+++ b/src/js/sw/sdk/api-domains.js
@@ -1,7 +1,7 @@
// ==========================================
-// Chat Switchboard — SDK: API Domain Namespaces
+// Switchboard Core — SDK: API Domain Namespaces
// ==========================================
-// 18 namespaced domain clients wrapping restClient.
+// Namespaced domain clients wrapping restClient.
// Each domain is a plain object with typed methods.
//
// Factory: createDomains(restClient)
@@ -65,136 +65,13 @@ export function createDomains(restClient) {
logout: (refreshToken) => rc.post('/api/v1/auth/logout', { refresh_token: refreshToken }),
},
- // ── 2. Channels ────────────────────────
- channels: {
- ...crud(rc, '/api/v1/channels'),
- messages: (id, opts) => rc.get(`/api/v1/channels/${id}/messages` + _qs(opts)),
- send: (id, data) => rc.post(`/api/v1/channels/${id}/messages`, data),
- complete: (data, signal) => rc.stream('/api/v1/chat/completions', data, signal),
- regenerate: (id, msgId, data, signal) => rc.stream(`/api/v1/channels/${id}/messages/${msgId}/regenerate`, data, signal),
- editMessage: (id, msgId, content) => rc.post(`/api/v1/channels/${id}/messages/${msgId}/edit`, { content }),
- deleteMessage: (id, msgId) => rc.del(`/api/v1/channels/${id}/messages/${msgId}`),
- siblings: (id, msgId) => rc.get(`/api/v1/channels/${id}/messages/${msgId}/siblings`),
- path: (id) => rc.get(`/api/v1/channels/${id}/path`),
- cursor: (id, leafId) => rc.put(`/api/v1/channels/${id}/cursor`, { active_leaf_id: leafId }),
- markRead: (id) => rc.post(`/api/v1/channels/${id}/mark-read`, {}),
- generateTitle: (id) => rc.post(`/api/v1/channels/${id}/generate-title`, {}),
- summarize: (id) => rc.post(`/api/v1/channels/${id}/summarize`, {}),
- // Participants
- participants: (id) => rc.get(`/api/v1/channels/${id}/participants`),
- addParticipant: (id, data) => rc.post(`/api/v1/channels/${id}/participants`, data),
- updateParticipant: (id, pid, data) => rc.patch(`/api/v1/channels/${id}/participants/${pid}`, data),
- removeParticipant: (id, pid) => rc.del(`/api/v1/channels/${id}/participants/${pid}`),
- // Channel models
- models: (id) => rc.get(`/api/v1/channels/${id}/models`),
- addModel: (id, data) => rc.post(`/api/v1/channels/${id}/models`, data),
- updateModel: (id, modelId, data) => rc.patch(`/api/v1/channels/${id}/models/${modelId}`, data),
- removeModel: (id, modelId) => rc.del(`/api/v1/channels/${id}/models/${modelId}`),
- // Knowledge bases
- kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
- setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
- // Files
- files: (id, opts) => rc.get(`/api/v1/channels/${id}/files` + _qs(opts)),
- uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file),
- },
-
- // ── 3. Personas ────────────────────────
- personas: {
- ...crud(rc, '/api/v1/personas'),
- kbs: (id) => rc.get(`/api/v1/personas/${id}/knowledge-bases`),
- setKbs: (id, data) => rc.put(`/api/v1/personas/${id}/knowledge-bases`, data),
- },
-
- // ── 4. Knowledge Bases ─────────────────
- knowledge: {
- ...crud(rc, '/api/v1/knowledge-bases'),
- discoverable: () => rc.get('/api/v1/knowledge-bases-discoverable'),
- search: (id, query, limit) => rc.post(`/api/v1/knowledge-bases/${id}/search`, { query, limit }),
- documents: (id) => rc.get(`/api/v1/knowledge-bases/${id}/documents`),
- upload: (id, file) => rc.upload(`/api/v1/knowledge-bases/${id}/documents`, file),
- docStatus: (id, docId) => rc.get(`/api/v1/knowledge-bases/${id}/documents/${docId}/status`),
- delDoc: (id, docId) => rc.del(`/api/v1/knowledge-bases/${id}/documents/${docId}`),
- },
-
- // ── 5. Notes ───────────────────────────
- notes: {
- ...crud(rc, '/api/v1/notes'),
- search: (query, limit) => rc.get(`/api/v1/notes/search` + _qs({ q: query, limit })),
- searchTitles: (query, limit) => rc.get(`/api/v1/notes/search-titles` + _qs({ q: query, limit })),
- folders: () => rc.get('/api/v1/notes/folders'),
- backlinks: (id) => rc.get(`/api/v1/notes/${id}/backlinks`),
- graph: () => rc.get('/api/v1/notes/graph'),
- bulkDelete: (ids) => rc.post('/api/v1/notes/bulk-delete', { ids }),
- },
-
- // ── 6. Projects ────────────────────────
- projects: {
- ...crud(rc, '/api/v1/projects'),
- channels: (id) => rc.get(`/api/v1/projects/${id}/channels`),
- addChannel: (id, channelId, pos) => rc.post(`/api/v1/projects/${id}/channels`, { channel_id: channelId, position: pos || 0 }),
- removeChannel: (id, channelId) => rc.del(`/api/v1/projects/${id}/channels/${channelId}`),
- reorderChannels:(id, channelIds) => rc.put(`/api/v1/projects/${id}/channels/reorder`, { channel_ids: channelIds }),
- kbs: (id) => rc.get(`/api/v1/projects/${id}/knowledge-bases`),
- addKb: (id, kbId, autoSearch) => rc.post(`/api/v1/projects/${id}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false }),
- removeKb: (id, kbId) => rc.del(`/api/v1/projects/${id}/knowledge-bases/${kbId}`),
- notes: (id) => rc.get(`/api/v1/projects/${id}/notes`),
- addNote: (id, noteId) => rc.post(`/api/v1/projects/${id}/notes`, { note_id: noteId }),
- removeNote: (id, noteId) => rc.del(`/api/v1/projects/${id}/notes/${noteId}`),
- files: (id, opts) => rc.get(`/api/v1/projects/${id}/files` + _qs(opts)),
- uploadFile: (id, file, path) => rc.upload(`/api/v1/projects/${id}/files` + (path ? _qs({ path }) : ''), file),
- deleteFile: (id, path) => rc.del(`/api/v1/projects/${id}/files` + _qs({ path })),
- mkdir: (id, path) => rc.post(`/api/v1/projects/${id}/files/mkdir`, { path }),
- uploadArchive: (id, file) => rc.upload(`/api/v1/projects/${id}/archive/upload`, file),
- },
-
- // ── 7. Workspaces ──────────────────────
- workspaces: {
- ...crud(rc, '/api/v1/workspaces'),
- update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data),
- getDefault: () => rc.get('/api/v1/workspaces/default'),
- files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
- readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
- writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
- deleteFile: (id, path) => rc.del(`/api/v1/workspaces/${id}/files/delete` + _qs({ path })),
- mkdir: (id, path) => rc.post(`/api/v1/workspaces/${id}/files/mkdir`, { path }),
- gitStatus: (id) => rc.get(`/api/v1/workspaces/${id}/git/status`),
- gitBranches: (id) => rc.get(`/api/v1/workspaces/${id}/git/branches`),
- credentials: () => rc.get('/api/v1/git-credentials'),
- },
-
- // ── 8. Memory ──────────────────────────
- memory: {
- list: (opts) => rc.get('/api/v1/memories' + _qs(opts)),
- get: (id) => rc.get(`/api/v1/memories/${id}`),
- update: (id, data) => rc.put(`/api/v1/memories/${id}`, data),
- del: (id) => rc.del(`/api/v1/memories/${id}`),
- count: () => rc.get('/api/v1/memories/count'),
- approve: (id) => rc.post(`/api/v1/memories/${id}/approve`, {}),
- reject: (id) => rc.post(`/api/v1/memories/${id}/reject`, {}),
- },
-
- // ── 9. Models ──────────────────────────
- models: {
- enabled: () => rc.get('/api/v1/models/enabled'),
- preferences: () => rc.get('/api/v1/models/preferences'),
- setPref: (modelId, provId, hidden) => rc.put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: provId, hidden }),
- bulkSetPref: (entries, hidden) => rc.post('/api/v1/models/preferences/bulk', { entries, hidden }),
- },
-
- // ── 10. Providers (user BYOK) ──────────
- providers: {
- ...crud(rc, '/api/v1/api-configs'),
- models: (id) => rc.get(`/api/v1/api-configs/${id}/models`),
- fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`),
- },
-
- // ── 10b. Connections (v0.38.1) ────────
+ // ── 2. Connections ────────────────────
connections: {
...crud(rc, '/api/v1/connections'),
resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })),
},
- // ── 10c. Connection Types (v0.38.4) ──
+ // ── 3. Connection Types ────────────────
connectionTypes: {
list: () => rc.get('/api/v1/connection-types'),
},
@@ -239,30 +116,14 @@ export function createDomains(restClient) {
addMember: (id, userId, role) => rc.post(`/api/v1/teams/${id}/members`, { user_id: userId, role }),
updateMember:(id, memberId, role) => rc.put(`/api/v1/teams/${id}/members/${memberId}`, { role }),
removeMember:(id, memberId) => rc.del(`/api/v1/teams/${id}/members/${memberId}`),
- personas: (id) => rc.get(`/api/v1/teams/${id}/personas`),
- createPersona:(id, data) => rc.post(`/api/v1/teams/${id}/personas`, data),
- updatePersona:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}`, data),
- deletePersona:(id, pid) => rc.del(`/api/v1/teams/${id}/personas/${pid}`),
- personaKbs: (id, pid) => rc.get(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`),
- setPersonaKbs:(id, pid, data) => rc.put(`/api/v1/teams/${id}/personas/${pid}/knowledge-bases`, data),
- models: (id) => rc.get(`/api/v1/teams/${id}/models`),
groups: (id) => rc.get(`/api/v1/teams/${id}/groups`),
- providers: (id) => rc.get(`/api/v1/teams/${id}/providers`),
- createProvider:(id, data) => rc.post(`/api/v1/teams/${id}/providers`, data),
- updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data),
- deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
- providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`),
- // Team connections (v0.38.1)
+ // Team connections
connections: (id) => rc.get(`/api/v1/teams/${id}/connections`),
createConnection: (id, data) => rc.post(`/api/v1/teams/${id}/connections`, data),
updateConnection: (id, cid, data) => rc.put(`/api/v1/teams/${id}/connections/${cid}`, data),
deleteConnection: (id, cid) => rc.del(`/api/v1/teams/${id}/connections/${cid}`),
- roles: (id) => rc.get(`/api/v1/teams/${id}/roles`),
- updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
- deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`),
audit: (id, opts) => rc.get(`/api/v1/teams/${id}/audit` + _qs(opts)),
auditActions:(id) => rc.get(`/api/v1/teams/${id}/audit/actions`),
- usage: (id, opts) => rc.get(`/api/v1/teams/${id}/usage` + _qs(opts)),
// Team workflows
workflows: (id, opts) => rc.get(`/api/v1/teams/${id}/workflows` + _qs(opts)),
createWorkflow: (id, data) => rc.post(`/api/v1/teams/${id}/workflows`, data),
@@ -276,22 +137,9 @@ export function createDomains(restClient) {
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
- // Team assignments + monitor (v0.37.15)
- assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
- workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
- cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
- // Team tasks
- tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
- createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
- updateTask: (id, taskId, data) => rc.put(`/api/v1/teams/${id}/tasks/${taskId}`, data),
- deleteTask: (id, taskId) => rc.del(`/api/v1/teams/${id}/tasks/${taskId}`),
- runTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/run`, {}),
- killTask: (id, taskId) => rc.post(`/api/v1/teams/${id}/tasks/${taskId}/kill`, {}),
- // Team knowledge bases
- knowledgeBases:(id) => rc.get(`/api/v1/teams/${id}/knowledge-bases`),
},
- // ── 15. Workflows ──────────────────────
+ // ── 9. Workflows ───────────────────────
workflows: {
...crud(rc, '/api/v1/workflows'),
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
@@ -302,27 +150,7 @@ export function createDomains(restClient) {
cancel: (channelId) => rc.post(`/api/v1/channels/${channelId}/workflow/cancel`, {}),
},
- // ── 15b. Workflow Assignments (v0.37.15) ─
- workflowAssignments: {
- mine: () => rc.get('/api/v1/workflow-assignments/mine'),
- get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
- claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
- unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
- complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
- reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
- cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
- comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
- },
-
- // ── 16. Tasks ──────────────────────────
- tasks: {
- ...crud(rc, '/api/v1/tasks'),
- runs: (id, opts) => rc.get(`/api/v1/tasks/${id}/runs` + _qs(opts)),
- start: (id, data) => rc.post(`/api/v1/tasks/${id}/run`, data || {}),
- stop: (id) => rc.post(`/api/v1/tasks/${id}/kill`, {}),
- },
-
- // ── 17. Surfaces ───────────────────────
+ // ── 10. Surfaces ──────────────────────
surfaces: {
list: (opts) => rc.get('/api/v1/surfaces' + _qs(opts)),
get: (id) => rc.get(`/api/v1/surfaces/${id}`),
@@ -332,7 +160,6 @@ export function createDomains(restClient) {
// ── 18. Admin ──────────────────────────
admin: {
stats: () => rc.get('/api/v1/admin/stats'),
- dashboard: () => rc.get('/api/v1/admin/dashboard'),
users: {
list: (opts) => rc.get('/api/v1/admin/users' + _qs(opts)),
@@ -351,38 +178,6 @@ export function createDomains(restClient) {
public: () => rc.get('/api/v1/settings/public'),
},
- configs: {
- list: () => rc.get('/api/v1/admin/configs'),
- create: (data) => rc.post('/api/v1/admin/configs', data),
- update: (id, data) => rc.put(`/api/v1/admin/configs/${id}`, data),
- del: (id) => rc.del(`/api/v1/admin/configs/${id}`),
- },
-
- models: {
- list: () => rc.get('/api/v1/admin/models'),
- fetch: () => rc.post('/api/v1/admin/models/fetch', {}),
- update: (id, data) => rc.put(`/api/v1/admin/models/${id}`, data),
- bulkUpdate: (visibility) => rc.put('/api/v1/admin/models/bulk', { visibility, is_enabled: visibility === 'enabled' }),
- del: (id) => rc.del(`/api/v1/admin/models/${id}`),
- capabilities: (modelId) => rc.get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`),
- setCapability: (modelId, d) => rc.put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, d),
- delCapability: (modelId, oId) => rc.del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${oId}`),
- overrides: () => rc.get('/api/v1/admin/capability-overrides'),
- },
-
- personas: {
- list: () => rc.get('/api/v1/admin/personas'),
- create: (data) => rc.post('/api/v1/admin/personas', data),
- update: (id, data) => rc.put(`/api/v1/admin/personas/${id}`, data),
- del: (id) => rc.del(`/api/v1/admin/personas/${id}`),
- avatar: (id, base64) => rc.post(`/api/v1/admin/personas/${id}/avatar`, { image: base64 }),
- delAvatar: (id) => rc.del(`/api/v1/admin/personas/${id}/avatar`),
- kbs: (id) => rc.get(`/api/v1/admin/personas/${id}/knowledge-bases`),
- setKbs: (id, data) => rc.put(`/api/v1/admin/personas/${id}/knowledge-bases`, data),
- toolGrants: (id) => rc.get(`/api/v1/admin/personas/${id}/tool-grants`),
- setToolGrants: (id, data) => rc.put(`/api/v1/admin/personas/${id}/tool-grants`, data),
- },
-
teams: {
list: () => rc.get('/api/v1/admin/teams'),
get: (id) => rc.get(`/api/v1/admin/teams/${id}`),
@@ -421,71 +216,19 @@ export function createDomains(restClient) {
actions: () => rc.get('/api/v1/admin/audit/actions'),
},
- roles: {
- list: () => rc.get('/api/v1/admin/roles'),
- get: (role) => rc.get(`/api/v1/admin/roles/${role}`),
- update: (role, config) => rc.put(`/api/v1/admin/roles/${role}`, config),
- test: (role) => rc.post(`/api/v1/admin/roles/${role}/test`, {}),
- },
-
- usage: {
- get: (opts) => rc.get('/api/v1/admin/usage' + _qs(opts)),
- user: (id, opts) => rc.get(`/api/v1/admin/usage/users/${id}` + _qs(opts)),
- team: (id, opts) => rc.get(`/api/v1/admin/usage/teams/${id}` + _qs(opts)),
- },
-
- pricing: {
- list: () => rc.get('/api/v1/admin/pricing'),
- upsert: (entry) => rc.put('/api/v1/admin/pricing', entry),
- del: (provId, modelId) => rc.del(`/api/v1/admin/pricing/${provId}/${encodeURIComponent(modelId)}`),
- },
-
- providers: {
- health: () => rc.get('/api/v1/admin/providers/health'),
- healthOne: (id) => rc.get(`/api/v1/admin/providers/${id}/health`),
- types: () => rc.get('/api/v1/admin/provider-types'),
- },
-
- routing: {
- policies: () => rc.get('/api/v1/admin/routing/policies'),
- getPolicy: (id) => rc.get(`/api/v1/admin/routing/policies/${id}`),
- createPolicy:(data) => rc.post('/api/v1/admin/routing/policies', data),
- updatePolicy:(id, data) => rc.put(`/api/v1/admin/routing/policies/${id}`, data),
- deletePolicy:(id) => rc.del(`/api/v1/admin/routing/policies/${id}`),
- test: (data) => rc.post('/api/v1/admin/routing/test', data),
- },
-
storage: {
status: () => rc.get('/api/v1/admin/storage/status'),
- orphans: () => rc.get('/api/v1/admin/storage/orphans'),
- cleanup: () => rc.post('/api/v1/admin/storage/cleanup', {}),
- extraction: () => rc.get('/api/v1/admin/storage/extraction'),
},
vault: {
status: () => rc.get('/api/v1/admin/vault/status'),
},
- projects: {
- list: (opts) => rc.get('/api/v1/admin/projects' + _qs(opts)),
- del: (id) => rc.del(`/api/v1/admin/projects/${id}`),
- },
-
- memories: {
- pending: () => rc.get('/api/v1/admin/memories/pending'),
- bulkApprove: (ids) => rc.post('/api/v1/admin/memories/bulk-approve', { ids }),
- },
-
notifications: {
testEmail: () => rc.post('/api/v1/admin/notifications/test-email', {}),
broadcast: (data) => rc.post('/api/v1/admin/notifications/broadcast', data),
},
- channels: {
- archived: (opts) => rc.get('/api/v1/admin/channels/archived' + _qs(opts)),
- purge: (id) => rc.del(`/api/v1/admin/channels/${id}/purge`),
- },
-
extensions: {
list: () => rc.get('/api/v1/admin/extensions'),
create: (data) => rc.post('/api/v1/admin/extensions', data),
@@ -516,13 +259,6 @@ export function createDomains(restClient) {
list: () => rc.get('/api/v1/admin/dependencies'),
},
- tasks: {
- list: () => rc.get('/api/v1/admin/tasks'),
- run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),
- kill: (id) => rc.post(`/api/v1/admin/tasks/${id}/kill`, {}),
- del: (id) => rc.del(`/api/v1/admin/tasks/${id}`),
- },
-
surfaces: {
list: () => rc.get('/api/v1/admin/surfaces'),
get: (id) => rc.get(`/api/v1/admin/surfaces/${id}`),
@@ -533,42 +269,6 @@ export function createDomains(restClient) {
},
},
- // ── 19. Git Credentials ──────────────────
- git: {
- credentials: {
- list: () => rc.get('/api/v1/git-credentials'),
- create: (data) => rc.post('/api/v1/git-credentials', data),
- del: (id) => rc.del(`/api/v1/git-credentials/${id}`),
- },
- },
-
- // ── 20. Data Portability ─────────────────
- dataPortability: {
- exportMe: () => rc.get('/api/v1/export/me'),
- deleteAccount: (data) => rc.post('/api/v1/profile/delete', data),
- },
-
- // ── Misc (not domain-specific) ─────────
- folders: {
- list: () => rc.get('/api/v1/folders'),
- create: (name, opts) => rc.post('/api/v1/folders', { name, parent_id: opts?.parent_id || null, sort_order: opts?.sort_order || 0 }),
- update: (id, data) => rc.put(`/api/v1/folders/${id}`, data),
- del: (id) => rc.del(`/api/v1/folders/${id}`),
- },
-
- // NOTE: notifications is defined in the main domain block above (§11).
- // The duplicate here was overwriting prefs/setPref/delPref methods.
- // Removed — see line ~190 for the canonical definition.
-
- files: {
- get: (id) => rc.get(`/api/v1/files/${id}`),
- del: (id) => rc.del(`/api/v1/files/${id}`),
- },
-
- tools: {
- list: () => rc.get('/api/v1/tools'),
- },
-
// ── Users ──────────────────────────────
users: {
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
@@ -578,16 +278,10 @@ export function createDomains(restClient) {
heartbeat: () => rc.post('/api/v1/presence/heartbeat', {}),
},
- usage: {
- mine: (opts) => rc.get('/api/v1/usage' + _qs(opts)),
- },
-
groups: {
mine: () => rc.get('/api/v1/groups/mine'),
},
health: () => rc.get('/api/v1/health'),
-
- export: (content, format, filename) => rc.post('/api/v1/export', { content, format, filename }),
};
}
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
index 4c56a47..a66cba5 100644
--- a/src/js/sw/sdk/index.js
+++ b/src/js/sw/sdk/index.js
@@ -1,5 +1,5 @@
// ==========================================
-// Chat Switchboard — SDK Entry Point
+// Switchboard Core — SDK Entry Point
// ==========================================
// Assembles all SDK modules into the `sw` object
// and runs the boot sequence.
@@ -129,28 +129,6 @@ export async function boot() {
});
};
- // ChatPane render helper — surfaces call sw.chatPane(container, opts)
- // Returns Promise for backward-compat with old ChatPane API.
- sw.chatPane = function (container, opts = {}) {
- return import('../components/chat-pane/index.js').then(({ ChatPane }) => {
- const handleRef = { current: null };
- const { render } = preact;
- render(html`<${ChatPane} handleRef=${handleRef} ...${opts} />`, container);
- return handleRef.current;
- });
- };
-
- // NotesPane render helper — surfaces call sw.notesPane(container, opts)
- // Returns Promise.
- sw.notesPane = function (container, opts = {}) {
- return import('../components/notes-pane/index.js').then(({ NotesPane }) => {
- const handleRef = { current: null };
- const { render } = preact;
- render(html`<${NotesPane} handleRef=${handleRef} ...${opts} />`, container);
- return handleRef.current;
- });
- };
-
// Marker for idempotency
sw._sdk = '0.38.1';
diff --git a/src/js/sw/surfaces/chat/channel-members-panel.js b/src/js/sw/surfaces/chat/channel-members-panel.js
deleted file mode 100644
index 4ebacce..0000000
--- a/src/js/sw/surfaces/chat/channel-members-panel.js
+++ /dev/null
@@ -1,104 +0,0 @@
-// ==========================================
-// Chat Surface — Channel Members Panel
-// ==========================================
-// Drawer showing channel participants with role management.
-// Uses Drawer primitive + channels.participants API.
-
-import { Drawer } from '../../primitives/drawer.js';
-import { UserPicker } from '../../primitives/user-picker.js';
-
-const { html } = window;
-const { useState, useEffect, useCallback } = hooks;
-
-const ROLES = ['owner', 'member', 'observer'];
-
-function _initials(name) {
- if (!name) return '?';
- return name.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
-}
-
-/**
- * @param {{ open: boolean, channelId: string, onClose: () => void }} props
- */
-export function ChannelMembersPanel({ open, channelId, onClose }) {
- const [members, setMembers] = useState([]);
- const [loading, setLoading] = useState(false);
- const [adding, setAdding] = useState(false);
-
- const load = useCallback(async () => {
- if (!channelId) return;
- setLoading(true);
- try {
- const data = await sw.api.channels.participants(channelId);
- setMembers(data || []);
- } catch (e) { sw.toast(e.message, 'error'); }
- finally { setLoading(false); }
- }, [channelId]);
-
- useEffect(() => {
- if (open && channelId) load();
- }, [open, channelId, load]);
-
- async function updateRole(pid, role) {
- try {
- await sw.api.channels.updateParticipant(channelId, pid, { role });
- sw.toast('Role updated', 'success');
- load();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function remove(pid) {
- const ok = await sw.confirm('Remove this participant?', true);
- if (!ok) return;
- try {
- await sw.api.channels.removeParticipant(channelId, pid);
- sw.toast('Participant removed', 'success');
- load();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- const _onUserSelect = useCallback(async (user) => {
- setAdding(true);
- try {
- await sw.api.channels.addParticipant(channelId, {
- participant_type: 'user',
- participant_id: user.id,
- });
- sw.toast('Participant added', 'success');
- load();
- } catch (e) { sw.toast(e.message, 'error'); }
- finally { setAdding(false); }
- }, [channelId, load]);
-
- if (!open) return null;
-
- return html`
- <${Drawer} open=${open} side="right" width="340px" title="Members" onClose=${onClose}>
- ${loading && !members.length
- ? html`Loading\u2026
`
- : html`
-
- ${members.map(m => html`
-
-
${_initials(m.display_name || m.participant_id)}
-
- ${m.display_name || m.participant_id}
- ${m.participant_type}
-
-
-
-
`)}
- ${members.length === 0 && html`
-
No participants
`}
-
-
- <${UserPicker}
- onSelect=${_onUserSelect}
- placeholder=${adding ? 'Adding\u2026' : 'Add participant\u2026'} />
-
`}
- />`;
-}
diff --git a/src/js/sw/surfaces/chat/channel-settings-panel.js b/src/js/sw/surfaces/chat/channel-settings-panel.js
deleted file mode 100644
index ae1f8cd..0000000
--- a/src/js/sw/surfaces/chat/channel-settings-panel.js
+++ /dev/null
@@ -1,108 +0,0 @@
-// ==========================================
-// Chat Surface — Channel Settings Panel
-// ==========================================
-// Drawer for editing channel title, description, topic, ai_mode.
-// Uses Drawer primitive + channels.update API.
-
-import { Drawer } from '../../primitives/drawer.js';
-
-const { html } = window;
-const { useState, useEffect, useCallback } = hooks;
-
-const AI_MODES = [
- { value: 'auto', label: 'Auto (always reply)' },
- { value: 'mention_only', label: 'Mention only' },
- { value: 'off', label: 'Off' },
-];
-
-/**
- * @param {{ open: boolean, channelId: string, onClose: () => void }} props
- */
-export function ChannelSettingsPanel({ open, channelId, onClose }) {
- const [channel, setChannel] = useState(null);
- const [loading, setLoading] = useState(false);
- const [saving, setSaving] = useState(false);
- const [form, setForm] = useState({ title: '', description: '', topic: '', ai_mode: 'auto' });
-
- const load = useCallback(async () => {
- if (!channelId) return;
- setLoading(true);
- try {
- const data = await sw.api.channels.get(channelId);
- setChannel(data);
- setForm({
- title: data.title || '',
- description: data.description || '',
- topic: data.topic || '',
- ai_mode: data.ai_mode || 'auto',
- });
- } catch (e) { sw.toast(e.message, 'error'); }
- finally { setLoading(false); }
- }, [channelId]);
-
- useEffect(() => {
- if (open && channelId) load();
- }, [open, channelId, load]);
-
- function _set(key, value) {
- setForm(prev => ({ ...prev, [key]: value }));
- }
-
- async function save(e) {
- e.preventDefault();
- setSaving(true);
- try {
- await sw.api.channels.update(channelId, {
- title: form.title || undefined,
- description: form.description || undefined,
- topic: form.topic || undefined,
- ai_mode: form.ai_mode || undefined,
- });
- sw.toast('Settings saved', 'success');
- } catch (e) { sw.toast(e.message, 'error'); }
- finally { setSaving(false); }
- }
-
- if (!open) return null;
-
- return html`
- <${Drawer} open=${open} side="right" width="340px" title="Channel Settings" onClose=${onClose}>
- ${loading && !channel
- ? html`Loading\u2026
`
- : html`
- `}
- />`;
-}
diff --git a/src/js/sw/surfaces/chat/chat-workspace.js b/src/js/sw/surfaces/chat/chat-workspace.js
deleted file mode 100644
index caf5343..0000000
--- a/src/js/sw/surfaces/chat/chat-workspace.js
+++ /dev/null
@@ -1,231 +0,0 @@
-// ==========================================
-// Chat Surface — ChatWorkspace Component
-// ==========================================
-// Header bar (model selector, tools, sidebar toggle) + ChatPane.
-// ChatPane runs standalone=false — the surface manages navigation.
-
-import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
-import { NotificationBell } from '../../shell/notification-bell.js';
-import { ChannelMembersPanel } from './channel-members-panel.js';
-import { ChannelSettingsPanel } from './channel-settings-panel.js';
-import { UserPicker } from '../../primitives/user-picker.js';
-
-const html = window.html;
-const { useState, useRef, useEffect, useCallback } = window.hooks;
-
-
-const PANEL_SVG = html`
- `;
-
-const PEOPLE_SVG = html`
- `;
-
-const GEAR_SVG = html`
- `;
-
-/**
- * @param {{
- * activeId: string|null,
- * activeType: 'chat'|'channel'|null,
- * onChannelChange: (id: string) => void,
- * onNewChat: () => void,
- * sidebarOpen: boolean,
- * onToggleSidebar: () => void,
- * toolsButton?: any,
- * }} props
- */
-export function ChatWorkspace({
- activeId, activeType, channelType, onChannelChange, onNewChat, onCreateChannel,
- sidebarOpen, onToggleSidebar, toolsButton, sidebar,
-}) {
- const chatRef = useRef(null);
- const [selectedModel, setSelectedModel] = useState(null);
- const [membersOpen, setMembersOpen] = useState(false);
- const [settingsOpen, setSettingsOpen] = useState(false);
- const activeChannel = sidebar?.items?.find(c => c.id === activeId);
- const showModelSelector = (activeChannel?.ai_mode || 'auto') !== 'off';
-
- // Close panels on channel switch
- useEffect(() => {
- setMembersOpen(false);
- setSettingsOpen(false);
- }, [activeId]);
-
- // Sync model selection into ChatPane's useChat
- useEffect(() => {
- if (selectedModel && chatRef.current?.setModel) {
- chatRef.current.setModel(selectedModel);
- }
- }, [selectedModel]);
-
- // When activeId changes, tell ChatPane to switch channel
- useEffect(() => {
- if (chatRef.current) {
- chatRef.current.setChannel(activeId);
- }
- }, [activeId]);
-
- // Dashboard view when no channel selected
- if (!activeId) {
- return html`
-
-
- <${ChatDashboard} onNewChat=${onNewChat} onCreateChannel=${onCreateChannel}
- items=${sidebar?.items} onNavigate=${onChannelChange} />
-
`;
- }
-
- return html`
-
-
- <${ChatPane}
- channelId=${activeId}
- standalone=${false}
- modelId=${selectedModel}
- handleRef=${chatRef}
- onChannelChange=${onChannelChange}
- className="sw-chat-surface__chat-pane" />
- <${ChannelMembersPanel}
- open=${membersOpen}
- channelId=${activeId}
- onClose=${() => setMembersOpen(false)} />
- <${ChannelSettingsPanel}
- open=${settingsOpen}
- channelId=${activeId}
- onClose=${() => setSettingsOpen(false)} />
-
`;
-}
-
-// ── Dashboard (no chat selected) ────────────
-
-function ChatDashboard({ onNewChat, onCreateChannel, items, onNavigate }) {
- const recentItems = (items || []).slice(0, 8);
- const [showDmPicker, setShowDmPicker] = useState(false);
-
- async function _create(type) {
- if (type === 'dm') {
- setShowDmPicker(true);
- return;
- }
- const label = type === 'channel' ? 'Channel' : 'Group';
- const name = await window.sw.prompt(label + ' name:', '');
- if (name && name.trim()) onCreateChannel(type, name.trim());
- }
-
- const _onDmSelect = useCallback((user) => {
- setShowDmPicker(false);
- const name = user.display_name || user.username;
- onCreateChannel('dm', 'DM: ' + name, { participant: user.id });
- }, [onCreateChannel]);
-
- return html`
-
-
-
Chat Switchboard
-
Start a conversation or browse your channels.
-
-
-
- ${sw.can('channel.create') && html`
- `}
- ${sw.can('channel.create') && html`
- `}
-
-
- ${showDmPicker && html`
-
-
Start a DM
- <${UserPicker}
- onSelect=${_onDmSelect}
- placeholder="Search for a user\u2026"
- autoFocus=${true} />
-
- `}
- ${recentItems.length > 0 && html`
-
-
Recent
-
- ${recentItems.map(ch => html`
-
onNavigate?.(ch.id)}
- style="cursor: pointer">
-
- ${ch.type === 'channel' || ch.type === 'group' ? '#' : '\u{1F4AC}'}
-
- ${ch.title || 'Untitled'}
-
`)}
-
-
`}
-
`;
-}
diff --git a/src/js/sw/surfaces/chat/index.js b/src/js/sw/surfaces/chat/index.js
deleted file mode 100644
index 1cb0d28..0000000
--- a/src/js/sw/surfaces/chat/index.js
+++ /dev/null
@@ -1,151 +0,0 @@
-// ==========================================
-// Chat Surface — Root Component
-// ==========================================
-// Main chat surface: sidebar + workspace.
-// Follows the settings/admin mount pattern.
-//
-// v0.37.10: New Preact surface replacing the legacy SPA.
-
-import { ToastContainer } from '../../primitives/toast.js';
-import { DialogStack } from '../../shell/dialog-stack.js';
-import { useWorkspace } from './use-workspace.js';
-import { useSidebar } from './use-sidebar.js';
-import { Sidebar } from './sidebar.js';
-import { ChatWorkspace } from './chat-workspace.js';
-import { useTools, ToolsPopup } from './tools-popup.js';
-
-const { render } = window.preact;
-const html = window.html;
-const { useState, useCallback } = window.hooks;
-
-const WRENCH_SVG = html`
- `;
-
-function ChatSurface() {
- const workspace = useWorkspace();
- const sidebar = useSidebar({ activeId: workspace.activeId });
- const tools = useTools();
- const [toolsOpen, setToolsOpen] = useState(false);
-
- const _onNewChat = useCallback(async () => {
- try {
- const resp = await window.sw.api.channels.create({ type: 'direct', title: 'New Chat' });
- const ch = resp;
- if (ch?.id) {
- sidebar.reload();
- workspace.select(ch.id, 'direct');
- }
- } catch (err) {
- console.error('[chat] New chat failed:', err);
- window.sw?.toast?.('Failed to create chat', 'error');
- }
- }, [workspace.select, sidebar.reload]);
-
- const _onCreateChannel = useCallback(async (type, title, opts) => {
- try {
- const body = { type, title };
- if (opts?.participant) body.participants = [opts.participant];
- const resp = await window.sw.api.channels.create(body);
- const ch = resp;
- if (ch?.id) {
- sidebar.reload();
- workspace.select(ch.id, type);
- }
- } catch (err) {
- console.error('[chat] Create channel failed:', err);
- window.sw?.toast?.('Failed to create ' + type, 'error');
- }
- }, [workspace.select, sidebar.reload]);
-
- const _onDeleteChat = useCallback(async (id) => {
- await sidebar.deleteChat(id);
- if (id === workspace.activeId) workspace.clearSelection();
- }, [sidebar.deleteChat, workspace.activeId, workspace.clearSelection]);
-
- const _onChannelChange = useCallback((id) => {
- if (id) workspace.select(id, 'direct');
- else workspace.clearSelection();
- }, [workspace.select, workspace.clearSelection]);
-
- const _toggleSidebar = useCallback(() => {
- workspace.setSidebarOpen(!workspace.sidebarOpen);
- }, [workspace.sidebarOpen, workspace.setSidebarOpen]);
-
- const _toggleTools = useCallback(() => {
- setToolsOpen(v => !v);
- }, []);
-
- const _closeTools = useCallback(() => {
- setToolsOpen(false);
- }, []);
-
- // Tools button — shared between mobile bar and workspace header
- const toolsButton = html`
- `;
-
- return html`
-
- ${/* Mobile sidebar overlay */''}
- ${workspace.sidebarOpen && html`
- `}
-
- ${/* Sidebar */''}
- ${workspace.sidebarOpen && html`
- <${Sidebar}
- sidebar=${sidebar}
- activeId=${workspace.activeId}
- onSelect=${workspace.select}
- onNewChat=${_onNewChat}
- onCreateChannel=${_onCreateChannel}
- onDeleteChat=${_onDeleteChat}
- onCollapse=${_toggleSidebar} />`}
-
- ${/* Main workspace */''}
-
- <${ChatWorkspace}
- activeId=${workspace.activeId}
- activeType=${workspace.activeType}
- channelType=${workspace.channelType}
- onChannelChange=${_onChannelChange}
- onNewChat=${_onNewChat}
- onCreateChannel=${_onCreateChannel}
- sidebarOpen=${workspace.sidebarOpen}
- onToggleSidebar=${_toggleSidebar}
- toolsButton=${toolsButton}
- sidebar=${sidebar} />
- <${ToolsPopup}
- open=${toolsOpen}
- onClose=${_closeTools}
- categories=${tools.categories}
- disabled=${tools.disabled}
- onToggle=${tools.toggle}
- onToggleCategory=${tools.toggleCategory} />
-
-
- <${ToastContainer} />
- <${DialogStack} />
- `;
-}
-
-// ── Mount ────────────────────────────────────
-const mount = document.getElementById('chat-mount');
-if (mount) {
- render(html`<${ChatSurface} />`, mount);
- console.log('[chat] Surface mounted (v0.37.10)');
-}
-
-export { ChatSurface };
diff --git a/src/js/sw/surfaces/chat/sidebar-chats.js b/src/js/sw/surfaces/chat/sidebar-chats.js
deleted file mode 100644
index 7a4d035..0000000
--- a/src/js/sw/surfaces/chat/sidebar-chats.js
+++ /dev/null
@@ -1,361 +0,0 @@
-// ==========================================
-// Chat Surface — SidebarItems Component
-// ==========================================
-// Unified channel list with folder grouping, context menus,
-// nested folders, and HTML5 drag-and-drop.
-
-const html = window.html;
-const { useState, useCallback, useRef, useEffect } = window.hooks;
-
-// ── Icons ────────────────────────────────
-
-const CHAT_SVG = html`
- `;
-
-const HASH_SVG = html`
- `;
-
-const FOLDER_SVG = html`
- `;
-
-const DOTS_SVG = html`
- `;
-
-const CHEVRON_SVG = html`
- `;
-
-function _iconForType(type) {
- if (type === 'channel' || type === 'group') return HASH_SVG;
- return CHAT_SVG;
-}
-
-// Max nesting depth for folders
-const MAX_DEPTH = 3;
-
-// ── Main Component ───────────────────────
-
-export function SidebarItems({
- itemsByFolder, folders, folderTree, activeId, onSelect,
- onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder,
- onRenameFolder, onDeleteFolder,
-}) {
- const [contextMenu, setContextMenu] = useState(null);
- const [dragOver, setDragOver] = useState(null); // { id, type } of drop target
- const [dragging, setDragging] = useState(false); // true while any drag in progress
- const [collapsedFolders, setCollapsedFolders] = useState({});
- const menuRef = useRef(null);
-
- // ── Context menu close ──────────────
- useEffect(() => {
- if (!contextMenu) return;
- function _close(e) {
- if (e.type === 'keydown' && e.key !== 'Escape') return;
- if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
- setContextMenu(null);
- }
- document.addEventListener('click', _close);
- document.addEventListener('keydown', _close);
- return () => {
- document.removeEventListener('click', _close);
- document.removeEventListener('keydown', _close);
- };
- }, [contextMenu]);
-
- const _openMenu = useCallback((e, type, item) => {
- e.preventDefault();
- e.stopPropagation();
- const scale = sw.shell.getScale();
- // The dots button may be display:none (zero rect) — fall back to parent
- let rect = e.currentTarget.getBoundingClientRect();
- if (!rect.width && !rect.height) {
- const parent = e.currentTarget.closest('.sw-chat-surface__item, .sw-chat-surface__folder-header');
- if (parent) rect = parent.getBoundingClientRect();
- }
- const x = rect.right / scale;
- const y = rect.bottom / scale;
- setContextMenu({ type, item, x, y });
- }, []);
-
- const _bodyContextMenu = useCallback((e) => {
- if (e.target.closest('.sw-chat-surface__item') || e.target.closest('.sw-chat-surface__folder-header')) return;
- e.preventDefault();
- const scale = sw.shell.getScale();
- setContextMenu({ type: 'body', item: null, x: e.clientX / scale, y: e.clientY / scale });
- }, []);
-
- const _action = useCallback(async (action, item) => {
- setContextMenu(null);
- if (action === 'rename') {
- const newTitle = await window.sw.prompt('Rename:', item.title || item.name || '');
- if (newTitle && newTitle.trim()) {
- if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
- else await onRename(item.id, newTitle.trim());
- }
- } else if (action === 'delete') {
- const label = item.name !== undefined ? 'folder' : 'conversation';
- const ok = await window.sw.confirm('Delete this ' + label + '?', true);
- if (ok) {
- if (item.name !== undefined) await onDeleteFolder(item.id);
- else await onDelete(item.id);
- }
- } else if (action === 'move-out') {
- await onMoveToFolder(item.id, null);
- } else if (action === 'new-folder') {
- const name = await window.sw.prompt('Folder name:', '');
- if (name && name.trim()) await onCreateFolder(name.trim());
- } else if (action === 'new-subfolder') {
- const name = await window.sw.prompt('Subfolder name:', '');
- if (name && name.trim()) await onCreateFolder(name.trim(), item.id);
- } else if (action === 'unnest-folder') {
- await onMoveFolderTo(item.id, null);
- } else if (action.startsWith('move-to:')) {
- const fid = action.slice(8);
- await onMoveToFolder(item.id, fid);
- }
- }, [onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder, onRenameFolder, onDeleteFolder]);
-
- // ── Folder collapse toggle ──────────
- const _toggleFolder = useCallback((folderId) => {
- setCollapsedFolders(prev => ({ ...prev, [folderId]: !prev[folderId] }));
- }, []);
-
- // ── Drag & Drop ─────────────────────
- const _onDragStart = useCallback((e, dragType, item) => {
- e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('text/plain', JSON.stringify({ dragType, id: item.id }));
- setDragging(true);
- }, []);
-
- const _onDragOver = useCallback((e, targetId, targetType) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
- setDragOver({ id: targetId, type: targetType });
- }, []);
-
- const _onDragLeave = useCallback(() => {
- setDragOver(null);
- }, []);
-
- const _onDropOnFolder = useCallback((e, folderId) => {
- e.preventDefault();
- setDragOver(null);
- try {
- const data = JSON.parse(e.dataTransfer.getData('text/plain'));
- if (data.dragType === 'channel') {
- onMoveToFolder(data.id, folderId);
- } else if (data.dragType === 'folder' && data.id !== folderId) {
- onMoveFolderTo(data.id, folderId);
- }
- } catch (_) {}
- }, [onMoveToFolder, onMoveFolderTo]);
-
- const _onDropOnRoot = useCallback((e) => {
- e.preventDefault();
- setDragOver(null);
- setDragging(false);
- try {
- const data = JSON.parse(e.dataTransfer.getData('text/plain'));
- if (data.dragType === 'channel') {
- onMoveToFolder(data.id, null);
- } else if (data.dragType === 'folder') {
- onMoveFolderTo(data.id, null);
- }
- } catch (_) {}
- }, [onMoveToFolder, onMoveFolderTo]);
-
- const _onDragEnd = useCallback(() => {
- setDragging(false);
- setDragOver(null);
- }, []);
-
- // ── Render ───────────────────────────
- const unfolderedItems = itemsByFolder[''] || [];
- const totalItems = Object.values(itemsByFolder).reduce((sum, arr) => sum + arr.length, 0);
- const tree = folderTree || [];
-
- const rootDropActive = dragging && dragOver?.id === '__root' && dragOver?.type === 'root';
-
- return html`
- { e.preventDefault(); }}
- onDrop=${_onDropOnRoot}
- onDragEnd=${_onDragEnd}>
- ${/* Render folder tree recursively */''}
- ${tree.map(node => html`
- <${FolderNode}
- key=${'f-' + node.id}
- node=${node}
- depth=${0}
- itemsByFolder=${itemsByFolder}
- activeId=${activeId}
- collapsedFolders=${collapsedFolders}
- dragOver=${dragOver}
- onSelect=${onSelect}
- onOpenMenu=${_openMenu}
- onToggleFolder=${_toggleFolder}
- onDragStart=${_onDragStart}
- onDragOver=${_onDragOver}
- onDragLeave=${_onDragLeave}
- onDropOnFolder=${_onDropOnFolder} />`)}
- ${/* Unfoldered items */''}
- ${unfolderedItems.map(ch => html`
- <${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId}
- onSelect=${onSelect} onOpenMenu=${_openMenu}
- onDragStart=${_onDragStart} />`)}
- ${totalItems === 0 && tree.length === 0 && html`
-
No conversations yet
`}
- ${/* Visible root drop zone — shown during drag */''}
- ${dragging && tree.length > 0 && html`
-
_onDragOver(e, '__root', 'root')}
- onDragLeave=${_onDragLeave}
- onDrop=${_onDropOnRoot}>
- Drop here to remove from folder
-
`}
-
-
- ${/* Context Menu */''}
- ${contextMenu && html`
- `}
- `;
-}
-
-// ── FolderNode (recursive) ───────────────
-
-function FolderNode({
- node, depth, itemsByFolder, activeId, collapsedFolders, dragOver,
- onSelect, onOpenMenu, onToggleFolder,
- onDragStart, onDragOver, onDragLeave, onDropOnFolder,
-}) {
- const folderItems = itemsByFolder[node.id] || [];
- const totalCount = folderItems.length + (node.children || []).length;
- const isCollapsed = !!collapsedFolders[node.id];
- const isDragTarget = dragOver?.id === node.id && dragOver?.type === 'folder';
- const hasChildren = (node.children?.length > 0) || folderItems.length > 0;
- const indent = depth * 12;
-
- return html`
- { e.stopPropagation(); onDragStart(e, 'folder', node); }}
- onDragOver=${(e) => { if (depth < MAX_DEPTH - 1) onDragOver(e, node.id, 'folder'); }}
- onDragLeave=${onDragLeave}
- onDrop=${(e) => { e.stopPropagation(); onDropOnFolder(e, node.id); }}>
-
- ${!isCollapsed && html`
- ${/* Child folders */''}
- ${(node.children || []).map(child => html`
- <${FolderNode}
- key=${'f-' + child.id}
- node=${child}
- depth=${depth + 1}
- itemsByFolder=${itemsByFolder}
- activeId=${activeId}
- collapsedFolders=${collapsedFolders}
- dragOver=${dragOver}
- onSelect=${onSelect}
- onOpenMenu=${onOpenMenu}
- onToggleFolder=${onToggleFolder}
- onDragStart=${onDragStart}
- onDragOver=${onDragOver}
- onDragLeave=${onDragLeave}
- onDropOnFolder=${onDropOnFolder} />`)}
- ${/* Channel items in this folder */''}
- ${folderItems.map(ch => html`
- <${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId}
- onSelect=${onSelect} onOpenMenu=${onOpenMenu}
- onDragStart=${onDragStart}
- indent=${true} />`)}
- `}
-
`;
-}
-
-// ── ChannelItem ──────────────────────────
-
-function ChannelItem({ channel, activeId, onSelect, onOpenMenu, onDragStart, indent }) {
- const icon = _iconForType(channel.type);
- return html`
- `;
-}
-
-// Scale detection moved to sw.shell.getScale() in v0.37.19 (CR P3-3).
-
-// Folder count = direct children only (subfolders + channels in this folder).
diff --git a/src/js/sw/surfaces/chat/sidebar.js b/src/js/sw/surfaces/chat/sidebar.js
deleted file mode 100644
index 357a83f..0000000
--- a/src/js/sw/surfaces/chat/sidebar.js
+++ /dev/null
@@ -1,186 +0,0 @@
-// ==========================================
-// Chat Surface — Sidebar Component
-// ==========================================
-// Search bar, new chat, unified channel list with folders.
-// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
-
-import { SidebarItems } from './sidebar-chats.js';
-import { UserMenu } from '../../shell/user-menu.js';
-import { UserPicker } from '../../primitives/user-picker.js';
-
-const html = window.html;
-const { useState, useCallback, useRef, useEffect } = window.hooks;
-
-const PLUS_SVG = html`
- `;
-
-const SEARCH_SVG = html`
- `;
-
-const COLLAPSE_SVG = html`
- `;
-
-const FOLDER_PLUS_SVG = html`
- `;
-
-/**
- * @param {{
- * sidebar: ReturnType,
- * activeId: string|null,
- * onSelect: (id: string, type: string) => void,
- * onNewChat: () => void,
- * onCollapse: () => void,
- * }} props
- */
-export function Sidebar({ sidebar, activeId, onSelect, onNewChat, onCreateChannel, onDeleteChat, onCollapse }) {
- const [newMenuOpen, setNewMenuOpen] = useState(false);
- const [showDmPicker, setShowDmPicker] = useState(false);
- const newMenuRef = useRef(null);
-
- const _onSearch = useCallback((e) => {
- sidebar.setSearch(e.target.value);
- }, [sidebar.setSearch]);
-
- const _onNewFolder = useCallback(async () => {
- const name = await window.sw.prompt('Folder name:', '');
- if (name && name.trim()) sidebar.createFolder(name.trim());
- }, [sidebar.createFolder]);
-
- // Close new-channel dropdown on outside click / Escape
- useEffect(() => {
- if (!newMenuOpen) return;
- function _close(e) {
- if (e.type === 'keydown' && e.key !== 'Escape') return;
- if (e.type === 'click' && newMenuRef.current?.contains(e.target)) return;
- setNewMenuOpen(false);
- }
- document.addEventListener('click', _close);
- document.addEventListener('keydown', _close);
- return () => {
- document.removeEventListener('click', _close);
- document.removeEventListener('keydown', _close);
- };
- }, [newMenuOpen]);
-
- const _newChannel = useCallback(async (type) => {
- setNewMenuOpen(false);
- if (type === 'direct') {
- onNewChat();
- } else if (type === 'dm') {
- setShowDmPicker(true);
- } else {
- const name = await window.sw.prompt((type === 'channel' ? 'Channel' : 'Group') + ' name:', '');
- if (name && name.trim()) onCreateChannel(type, name.trim());
- }
- }, [onNewChat, onCreateChannel]);
-
- const _onDmSelect = useCallback((user) => {
- setShowDmPicker(false);
- const name = user.display_name || user.username;
- onCreateChannel('dm', 'DM: ' + name, { participant: user.id });
- }, [onCreateChannel]);
-
- return html`
- `;
-}
diff --git a/src/js/sw/surfaces/chat/tools-popup.js b/src/js/sw/surfaces/chat/tools-popup.js
deleted file mode 100644
index b965161..0000000
--- a/src/js/sw/surfaces/chat/tools-popup.js
+++ /dev/null
@@ -1,166 +0,0 @@
-// ==========================================
-// Chat Surface — ToolsPopup Component
-// ==========================================
-// Scrollable popup anchored to a toolbar icon.
-// Category-based tool toggles with persistence to localStorage.
-
-const html = window.html;
-const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
-
-const STORAGE_KEY = 'sw-disabled-tools';
-
-/**
- * Load tools list from API and organize by category.
- * @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
- */
-export function useTools() {
- const [tools, setTools] = useState([]);
- const [disabled, setDisabled] = useState(_loadDisabled);
-
- useEffect(() => {
- // Load available tools from API
- if (window.sw?.api?.tools?.list) {
- window.sw.api.tools.list().then(resp => {
- setTools(resp || []);
- }).catch(() => {});
- }
- }, []);
-
- const categories = useMemo(() => {
- const map = {};
- for (const t of tools) {
- const cat = t.category || 'Other';
- if (!map[cat]) map[cat] = [];
- map[cat].push(t);
- }
- return Object.entries(map).map(([name, items]) => ({ name, items }));
- }, [tools]);
-
- const toggle = useCallback((toolName) => {
- setDisabled(prev => {
- const next = new Set(prev);
- if (next.has(toolName)) next.delete(toolName);
- else next.add(toolName);
- _saveDisabled(next);
- return next;
- });
- }, []);
-
- const toggleCategory = useCallback((catName) => {
- setDisabled(prev => {
- const cat = categories.find(c => c.name === catName);
- if (!cat) return prev;
- const next = new Set(prev);
- const allDisabled = cat.items.every(t => next.has(t.name));
- for (const t of cat.items) {
- if (allDisabled) next.delete(t.name);
- else next.add(t.name);
- }
- _saveDisabled(next);
- return next;
- });
- }, [categories]);
-
- const getDisabled = useCallback(() => [...disabled], [disabled]);
-
- return { categories, disabled, toggle, toggleCategory, getDisabled };
-}
-
-/**
- * @param {{
- * open: boolean,
- * onClose: () => void,
- * categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
- * disabled: Set,
- * onToggle: (name: string) => void,
- * onToggleCategory: (catName: string) => void,
- * }} props
- */
-export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
- const panelRef = useRef(null);
- const [expandedCats, setExpandedCats] = useState({});
-
- // Close on Escape or outside click
- useEffect(() => {
- if (!open) return;
- function _onKey(e) {
- if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
- }
- function _onClick(e) {
- if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
- }
- document.addEventListener('keydown', _onKey, true);
- setTimeout(() => document.addEventListener('click', _onClick), 0);
- return () => {
- document.removeEventListener('keydown', _onKey, true);
- document.removeEventListener('click', _onClick);
- };
- }, [open, onClose]);
-
- const _toggleCat = useCallback((name) => {
- setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
- }, []);
-
- if (!open) return null;
-
- const disabledCount = disabled.size;
-
- return html`
- `;
-}
-
-function _loadDisabled() {
- try {
- const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
- return new Set(arr || []);
- } catch (_) { return new Set(); }
-}
-
-function _saveDisabled(set) {
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
-}
diff --git a/src/js/sw/surfaces/chat/use-sidebar.js b/src/js/sw/surfaces/chat/use-sidebar.js
deleted file mode 100644
index cf2c983..0000000
--- a/src/js/sw/surfaces/chat/use-sidebar.js
+++ /dev/null
@@ -1,205 +0,0 @@
-// ==========================================
-// Chat Surface — useSidebar Hook
-// ==========================================
-// Loads and manages sidebar data: all channels (unified)
-// and folders. Subscribes to WS events for live updates.
-
-const { useState, useEffect, useCallback, useMemo, useRef } = window.hooks;
-
-const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
-
-/**
- * @param {{ activeId: string|null }} opts
- */
-export function useSidebar(opts = {}) {
- const { activeId } = opts;
-
- const [allChannels, setAllChannels] = useState([]);
- const [folders, setFolders] = useState([]);
- const [search, setSearch] = useState('');
- const [collapsed, setCollapsed] = useState(_loadCollapsed);
- const [loading, setLoading] = useState(true);
- const activeIdRef = useRef(activeId);
- activeIdRef.current = activeId;
-
- // ── Load data ───────────────────────────
- const reload = useCallback(async () => {
- if (!window.sw?.api?.channels?.list) return;
-
- try {
- const foldersPromise = window.sw.api.folders?.list
- ? window.sw.api.folders.list().catch(() => [])
- : Promise.resolve([]);
-
- const [allResp, fResp] = await Promise.all([
- window.sw.api.channels.list({ page: 1, per_page: 200, types: 'direct,dm,group,channel' }).catch(() => []),
- foldersPromise,
- ]);
-
- const fresh = _extract(allResp);
- // Zero unread for the active channel (mark-read already fired)
- const aid = activeIdRef.current;
- if (aid) {
- const idx = fresh.findIndex(c => c.id === aid);
- if (idx !== -1) fresh[idx] = { ...fresh[idx], unread_count: 0 };
- }
- setAllChannels(fresh);
- setFolders(_extract(fResp));
- } catch (_) {}
-
- setLoading(false);
- }, []);
-
- useEffect(() => { reload(); }, [reload]);
-
- // ── Clear unread badge when channel becomes active ──
- useEffect(() => {
- if (!activeId) return;
- setAllChannels(prev => prev.map(c =>
- c.id === activeId && c.unread_count > 0
- ? { ...c, unread_count: 0 }
- : c
- ));
- }, [activeId]);
-
- // ── WebSocket live updates ──────────────
- useEffect(() => {
- if (!window.sw?.on) return;
-
- const handlers = {
- 'channel.created': reload,
- 'channel.updated': reload,
- 'channel.deleted': reload,
- 'folder.created': reload,
- 'folder.updated': reload,
- 'folder.deleted': reload,
- };
-
- Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
- return () => {
- Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
- };
- }, [reload]);
-
- // ── Search filter ───────────────────────
- const filtered = useMemo(() => {
- if (!search) return allChannels;
- const q = search.toLowerCase();
- return allChannels.filter(c => (c.title || '').toLowerCase().includes(q));
- }, [allChannels, search]);
-
- // Group all channels by folder
- const itemsByFolder = useMemo(() => {
- const map = { '': [] };
- for (const f of (folders || [])) map[f.id] = [];
- for (const c of filtered) {
- const fid = c.folder_id || '';
- if (!map[fid]) map[fid] = [];
- map[fid].push(c);
- }
- return map;
- }, [filtered, folders]);
-
- // Build nested folder tree (max 3 levels)
- const folderTree = useMemo(() => {
- if (!folders || !folders.length) return [];
- const byId = {};
- for (const f of folders) byId[f.id] = { ...f, children: [] };
- const roots = [];
- for (const f of folders) {
- const node = byId[f.id];
- if (f.parent_id && byId[f.parent_id]) {
- byId[f.parent_id].children.push(node);
- } else {
- roots.push(node);
- }
- }
- return roots;
- }, [folders]);
-
- // ── Collapse folders ────────────────────
- const toggleCollapsed = useCallback((key) => {
- setCollapsed(prev => {
- const next = { ...prev, [key]: !prev[key] };
- try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
- return next;
- });
- }, []);
-
- // ── CRUD helpers ────────────────────────
- const createFolder = useCallback(async (name, parentId) => {
- if (!window.sw?.api?.folders?.create) return;
- await window.sw.api.folders.create(name, parentId ? { parent_id: parentId } : undefined);
- reload();
- }, [reload]);
-
- const moveFolderTo = useCallback(async (folderId, parentId) => {
- if (!window.sw?.api?.folders?.update) return;
- await window.sw.api.folders.update(folderId, { parent_id: parentId || null });
- reload();
- }, [reload]);
-
- const renameChat = useCallback(async (id, title) => {
- await window.sw.api.channels.update(id, { title });
- reload();
- }, [reload]);
-
- const deleteChat = useCallback(async (id) => {
- await window.sw.api.channels.del(id);
- reload();
- }, [reload]);
-
- const moveToFolder = useCallback(async (chatId, folderId) => {
- await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
- reload();
- }, [reload]);
-
- const renameFolder = useCallback(async (id, name) => {
- await window.sw.api.folders.update(id, { name });
- reload();
- }, [reload]);
-
- const deleteFolder = useCallback(async (id) => {
- await window.sw.api.folders.del(id);
- reload();
- }, [reload]);
-
- return {
- items: filtered,
- itemsByFolder,
- folders,
- folderTree,
- search,
- setSearch,
- collapsed,
- toggleCollapsed,
- loading,
- reload,
- // CRUD
- createFolder,
- renameChat,
- deleteChat,
- moveToFolder,
- moveFolderTo,
- renameFolder,
- deleteFolder,
- };
-}
-
-function _extract(resp) {
- if (!resp) return [];
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp.data)) return resp.data;
- // Handle wrapped responses like { folders: [], channels: [] }
- const vals = Object.values(resp);
- for (const v of vals) {
- if (Array.isArray(v)) return v;
- }
- return [];
-}
-
-function _loadCollapsed() {
- try {
- return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
- } catch (_) { return {}; }
-}
diff --git a/src/js/sw/surfaces/chat/use-workspace.js b/src/js/sw/surfaces/chat/use-workspace.js
deleted file mode 100644
index a16ec48..0000000
--- a/src/js/sw/surfaces/chat/use-workspace.js
+++ /dev/null
@@ -1,83 +0,0 @@
-// ==========================================
-// Chat Surface — useWorkspace Hook
-// ==========================================
-// Coordinator hook: manages active conversation, sidebar state.
-// Persists active selection to sessionStorage for reload restore.
-
-const { useState, useCallback, useEffect } = window.hooks;
-
-const STORAGE_KEY = 'sw-chat-active';
-
-/**
- * @returns {{
- * activeId: string|null,
- * activeType: 'chat'|'channel'|null,
- * sidebarOpen: boolean,
- * setSidebarOpen: (v: boolean) => void,
- * selectChat: (id: string) => void,
- * selectChannel: (id: string) => void,
- * clearSelection: () => void,
- * }}
- */
-export function useWorkspace() {
- // Restore from sessionStorage
- const stored = _loadStored();
- const [activeId, setActiveId] = useState(stored.id);
- const [activeType, setActiveType] = useState(stored.type);
- const [channelType, setChannelType] = useState(stored.channelType || null);
- const [sidebarOpen, setSidebarOpen] = useState(true);
-
- // Persist on change
- useEffect(() => {
- if (activeId) {
- try {
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType, channelType }));
- } catch (_) {}
- } else {
- sessionStorage.removeItem(STORAGE_KEY);
- }
- }, [activeId, activeType, channelType]);
-
- const select = useCallback((id, chType) => {
- setActiveId(id);
- setChannelType(chType || 'direct');
- // Map channel model type to workspace type
- const wsType = (chType === 'channel' || chType === 'group') ? 'channel' : 'chat';
- setActiveType(wsType);
- // Auto-close sidebar on mobile
- if (window.innerWidth < 768) setSidebarOpen(false);
- }, []);
-
- // Keep legacy aliases for any other callers
- const selectChat = useCallback((id) => select(id, 'direct'), [select]);
- const selectChannel = useCallback((id) => select(id, 'channel'), [select]);
-
- const clearSelection = useCallback(() => {
- setActiveId(null);
- setActiveType(null);
- setChannelType(null);
- }, []);
-
- return {
- activeId,
- activeType,
- channelType,
- sidebarOpen,
- setSidebarOpen,
- select,
- selectChat,
- selectChannel,
- clearSelection,
- };
-}
-
-function _loadStored() {
- try {
- const raw = sessionStorage.getItem(STORAGE_KEY);
- if (raw) {
- const obj = JSON.parse(raw);
- return { id: obj.id || null, type: obj.type || null, channelType: obj.channelType || null };
- }
- } catch (_) {}
- return { id: null, type: null, channelType: null };
-}
diff --git a/src/js/sw/surfaces/notes/index.js b/src/js/sw/surfaces/notes/index.js
deleted file mode 100644
index 6525280..0000000
--- a/src/js/sw/surfaces/notes/index.js
+++ /dev/null
@@ -1,90 +0,0 @@
-// ==========================================
-// Notes Surface — Root Component
-// ==========================================
-// Main notes surface: sidebar + workspace.
-// Follows the chat surface mount pattern.
-//
-// v0.37.11: New Preact surface replacing the legacy template mount.
-
-import { ToastContainer } from '../../primitives/toast.js';
-import { DialogStack } from '../../shell/dialog-stack.js';
-import { useWorkspace } from './use-workspace.js';
-import { useSidebar } from './use-sidebar.js';
-import { Sidebar } from './sidebar.js';
-import { NotesWorkspace } from './notes-workspace.js';
-
-const { render } = window.preact;
-const html = window.html;
-const { useRef, useCallback } = window.hooks;
-
-function NotesSurface() {
- const workspace = useWorkspace();
- const sidebar = useSidebar();
- const notesRef = useRef(null);
-
- const _onNewNote = useCallback(() => {
- if (notesRef.current) notesRef.current.newNote();
- }, []);
-
- const _toggleSidebar = useCallback(() => {
- workspace.setSidebarOpen(!workspace.sidebarOpen);
- }, [workspace.sidebarOpen, workspace.setSidebarOpen]);
-
- const _onFolderSelect = useCallback((path) => {
- if (notesRef.current?.setFolder) notesRef.current.setFolder(path);
- }, []);
-
- const _onTagSelect = useCallback((tag) => {
- if (notesRef.current?.setTagFilter) notesRef.current.setTagFilter(tag);
- }, []);
-
- const _onSearch = useCallback((query) => {
- if (notesRef.current?.setSearchQuery) notesRef.current.setSearchQuery(query);
- }, []);
-
- const _onNoteChange = useCallback((note) => {
- if (note?.id) {
- workspace.selectNote(note.id);
- }
- }, [workspace.selectNote]);
-
- return html`
-
- ${/* Mobile sidebar overlay */''}
- ${workspace.sidebarOpen && html`
- `}
-
- ${/* Sidebar */''}
- ${workspace.sidebarOpen && html`
- <${Sidebar}
- sidebar=${sidebar}
- onNewNote=${_onNewNote}
- onCollapse=${_toggleSidebar}
- onFolderSelect=${_onFolderSelect}
- onTagSelect=${_onTagSelect}
- onSearch=${_onSearch} />`}
-
- ${/* Main workspace */''}
-
- <${NotesWorkspace}
- sidebarOpen=${workspace.sidebarOpen}
- onToggleSidebar=${_toggleSidebar}
- initialNoteId=${workspace.activeNoteId}
- onNoteChange=${_onNoteChange}
- handleRef=${notesRef} />
-
-
- <${ToastContainer} />
- <${DialogStack} />
- `;
-}
-
-// ── Mount ────────────────────────────────────
-const mount = document.getElementById('notes-mount');
-if (mount) {
- render(html`<${NotesSurface} />`, mount);
- console.log('[notes] Surface mounted (v0.37.11)');
-}
-
-export { NotesSurface };
diff --git a/src/js/sw/surfaces/notes/notes-workspace.js b/src/js/sw/surfaces/notes/notes-workspace.js
deleted file mode 100644
index 4a4db96..0000000
--- a/src/js/sw/surfaces/notes/notes-workspace.js
+++ /dev/null
@@ -1,56 +0,0 @@
-// ==========================================
-// Notes Surface — NotesWorkspace Component
-// ==========================================
-// Header bar (sidebar toggle) + NotesPane.
-// NotesPane runs standalone=false — the surface manages filters.
-//
-// v0.37.11
-
-import { NotesPane } from '../../components/notes-pane/index.js';
-import { NotificationBell } from '../../shell/notification-bell.js';
-
-const html = window.html;
-
-const PANEL_SVG = html`
- `;
-
-/**
- * @param {{
- * sidebarOpen: boolean,
- * onToggleSidebar: () => void,
- * initialNoteId?: string,
- * onNoteChange?: (note: object|null) => void,
- * handleRef?: { current: any },
- * }} props
- */
-export function NotesWorkspace({
- sidebarOpen, onToggleSidebar, initialNoteId, onNoteChange, handleRef,
-}) {
- return html`
-
-
- <${NotesPane}
- standalone=${false}
- initialNoteId=${initialNoteId}
- handleRef=${handleRef}
- onNoteChange=${onNoteChange}
- className="sw-notes-surface__notes-pane" />
-
`;
-}
diff --git a/src/js/sw/surfaces/notes/sidebar-folders.js b/src/js/sw/surfaces/notes/sidebar-folders.js
deleted file mode 100644
index e161ed1..0000000
--- a/src/js/sw/surfaces/notes/sidebar-folders.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// ==========================================
-// Notes Surface — SidebarFolders Component
-// ==========================================
-// Collapsible folder tree for the notes sidebar.
-// v0.37.11
-
-const html = window.html;
-
-const CHEVRON_SVG = html`
- `;
-
-const FOLDER_SVG = html`
- `;
-
-/**
- * @param {{
- * folders: Array<{ path: string, count: number }>,
- * activeFolder: string,
- * collapsed: boolean,
- * onToggle: () => void,
- * onSelect: (path: string) => void,
- * }} props
- */
-export function SidebarFolders({ folders, activeFolder, collapsed, onToggle, onSelect }) {
- const totalCount = folders.reduce((sum, f) => sum + (f.count || 0), 0);
-
- return html`
-
-
- ${!collapsed && html`
-
- ${/* "All Notes" clears folder filter */''}
-
- ${folders.map(f => html`
-
`)}
- ${folders.length === 0 && html`
-
No folders
`}
-
`}
-
`;
-}
diff --git a/src/js/sw/surfaces/notes/sidebar-tags.js b/src/js/sw/surfaces/notes/sidebar-tags.js
deleted file mode 100644
index 2c86dc0..0000000
--- a/src/js/sw/surfaces/notes/sidebar-tags.js
+++ /dev/null
@@ -1,52 +0,0 @@
-// ==========================================
-// Notes Surface — SidebarTags Component
-// ==========================================
-// Collapsible tag cloud for the notes sidebar.
-// v0.37.11
-
-const html = window.html;
-
-const CHEVRON_SVG = html`
- `;
-
-/**
- * @param {{
- * tags: Array<[string, number]>,
- * activeTag: string,
- * collapsed: boolean,
- * onToggle: () => void,
- * onSelect: (tag: string) => void,
- * }} props
- */
-export function SidebarTags({ tags, activeTag, collapsed, onToggle, onSelect }) {
- return html`
-
-
- ${!collapsed && html`
-
-
- ${tags.length === 0 && html`
-
No tags
`}
- ${tags.map(([tag, count]) => html`
-
onSelect(activeTag === tag ? '' : tag)}
- title=${tag + ' (' + count + ')'}>
- ${tag}
- `)}
-
-
`}
-
`;
-}
diff --git a/src/js/sw/surfaces/notes/sidebar.js b/src/js/sw/surfaces/notes/sidebar.js
deleted file mode 100644
index 27bd273..0000000
--- a/src/js/sw/surfaces/notes/sidebar.js
+++ /dev/null
@@ -1,121 +0,0 @@
-// ==========================================
-// Notes Surface — Sidebar Component
-// ==========================================
-// Search bar, new note, folder tree, tag cloud.
-// Footer has UserMenu — same pattern as chat sidebar.
-//
-// v0.37.11
-
-import { SidebarFolders } from './sidebar-folders.js';
-import { SidebarTags } from './sidebar-tags.js';
-import { UserMenu } from '../../shell/user-menu.js';
-
-const html = window.html;
-const { useCallback, useRef } = window.hooks;
-
-const PLUS_SVG = html`
- `;
-
-const SEARCH_SVG = html`
- `;
-
-const COLLAPSE_SVG = html`
- `;
-
-/**
- * @param {{
- * sidebar: ReturnType,
- * onNewNote: () => void,
- * onCollapse: () => void,
- * onFolderSelect: (path: string) => void,
- * onTagSelect: (tag: string) => void,
- * onSearch: (query: string) => void,
- * }} props
- */
-export function Sidebar({ sidebar, onNewNote, onCollapse, onFolderSelect, onTagSelect, onSearch }) {
- const searchTimerRef = useRef(null);
-
- const _onSearchInput = useCallback((e) => {
- const val = e.target.value;
- sidebar.setSearch(val);
- clearTimeout(searchTimerRef.current);
- searchTimerRef.current = setTimeout(() => {
- onSearch(val.trim());
- }, 300);
- }, [sidebar.setSearch, onSearch]);
-
- const _onFolderSelect = useCallback((path) => {
- sidebar.setActiveFolder(path);
- onFolderSelect(path);
- }, [sidebar.setActiveFolder, onFolderSelect]);
-
- const _onTagSelect = useCallback((tag) => {
- sidebar.setActiveTag(tag);
- onTagSelect(tag);
- }, [sidebar.setActiveTag, onTagSelect]);
-
- return html`
- `;
-}
diff --git a/src/js/sw/surfaces/notes/use-sidebar.js b/src/js/sw/surfaces/notes/use-sidebar.js
deleted file mode 100644
index 33ae6db..0000000
--- a/src/js/sw/surfaces/notes/use-sidebar.js
+++ /dev/null
@@ -1,122 +0,0 @@
-// ==========================================
-// Notes Surface — useSidebar Hook
-// ==========================================
-// Loads folders and tags for the sidebar.
-// Subscribes to WS events for live updates.
-//
-// v0.37.11: New Preact surface.
-
-const { useState, useEffect, useCallback, useMemo } = window.hooks;
-
-const COLLAPSE_KEY = 'sw-notes-sidebar-collapsed';
-
-/**
- * @returns {{
- * folders: Array<{ path: string, count: number }>,
- * tags: Array<[string, number]>,
- * activeFolder: string,
- * activeTag: string,
- * search: string,
- * setActiveFolder: (f: string) => void,
- * setActiveTag: (t: string) => void,
- * setSearch: (q: string) => void,
- * collapsed: { folders: boolean, tags: boolean },
- * toggleCollapsed: (key: string) => void,
- * loading: boolean,
- * }}
- */
-export function useSidebar() {
- const [folders, setFolders] = useState([]);
- const [allNotes, setAllNotes] = useState([]);
- const [activeFolder, setActiveFolder] = useState('');
- const [activeTag, setActiveTag] = useState('');
- const [search, setSearch] = useState('');
- const [collapsed, setCollapsed] = useState(_loadCollapsed);
- const [loading, setLoading] = useState(true);
-
- // ── Load data ───────────────────────────
- const reload = useCallback(async () => {
- if (!window.sw?.api?.notes) return;
-
- try {
- const [fResp, nResp] = await Promise.all([
- window.sw.api.notes.folders().catch(() => []),
- window.sw.api.notes.list({ per_page: 200 }).catch(() => []),
- ]);
-
- setFolders(_extract(fResp));
- setAllNotes(_extract(nResp));
- } catch (_) {}
-
- setLoading(false);
- }, []);
-
- useEffect(() => { reload(); }, [reload]);
-
- // ── WebSocket live updates ──────────────
- useEffect(() => {
- if (!window.sw?.on) return;
-
- const events = [
- 'note.created', 'note.updated', 'note.deleted',
- ];
-
- events.forEach(ev => window.sw.on(ev, reload));
- return () => {
- events.forEach(ev => window.sw.off?.(ev, reload));
- };
- }, [reload]);
-
- // ── Aggregate tags from notes ───────────
- const tags = useMemo(() => {
- const counts = {};
- for (const n of allNotes) {
- for (const t of (n.tags || [])) {
- counts[t] = (counts[t] || 0) + 1;
- }
- }
- return Object.entries(counts)
- .sort((a, b) => b[1] - a[1])
- .slice(0, 30);
- }, [allNotes]);
-
- // ── Collapse sections ───────────────────
- const toggleCollapsed = useCallback((key) => {
- setCollapsed(prev => {
- const next = { ...prev, [key]: !prev[key] };
- try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
- return next;
- });
- }, []);
-
- return {
- folders,
- tags,
- activeFolder,
- activeTag,
- search,
- setActiveFolder,
- setActiveTag,
- setSearch,
- collapsed,
- toggleCollapsed,
- loading,
- };
-}
-
-function _extract(resp) {
- if (!resp) return [];
- if (Array.isArray(resp)) return resp;
- if (Array.isArray(resp.data)) return resp.data;
- const vals = Object.values(resp);
- for (const v of vals) {
- if (Array.isArray(v)) return v;
- }
- return [];
-}
-
-function _loadCollapsed() {
- try {
- return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
- } catch (_) { return {}; }
-}
diff --git a/src/js/sw/surfaces/notes/use-workspace.js b/src/js/sw/surfaces/notes/use-workspace.js
deleted file mode 100644
index 2c30002..0000000
--- a/src/js/sw/surfaces/notes/use-workspace.js
+++ /dev/null
@@ -1,59 +0,0 @@
-// ==========================================
-// Notes Surface — useWorkspace Hook
-// ==========================================
-// Coordinator hook: manages sidebar state and active note.
-// Persists active selection to sessionStorage for reload restore.
-//
-// v0.37.11: New Preact surface.
-
-const { useState, useCallback, useEffect } = window.hooks;
-
-const STORAGE_KEY = 'sw-notes-active';
-
-/**
- * @returns {{
- * activeNoteId: string|null,
- * sidebarOpen: boolean,
- * setSidebarOpen: (v: boolean) => void,
- * selectNote: (id: string) => void,
- * clearSelection: () => void,
- * }}
- */
-export function useWorkspace() {
- const stored = _loadStored();
- const [activeNoteId, setActiveNoteId] = useState(stored);
- const [sidebarOpen, setSidebarOpen] = useState(true);
-
- // Persist on change
- useEffect(() => {
- if (activeNoteId) {
- try { sessionStorage.setItem(STORAGE_KEY, activeNoteId); } catch (_) {}
- } else {
- sessionStorage.removeItem(STORAGE_KEY);
- }
- }, [activeNoteId]);
-
- const selectNote = useCallback((id) => {
- setActiveNoteId(id);
- // Auto-close sidebar on mobile
- if (window.innerWidth < 768) setSidebarOpen(false);
- }, []);
-
- const clearSelection = useCallback(() => {
- setActiveNoteId(null);
- }, []);
-
- return {
- activeNoteId,
- sidebarOpen,
- setSidebarOpen,
- selectNote,
- clearSelection,
- };
-}
-
-function _loadStored() {
- try {
- return sessionStorage.getItem(STORAGE_KEY) || null;
- } catch (_) { return null; }
-}
diff --git a/src/js/sw/surfaces/projects/detail.js b/src/js/sw/surfaces/projects/detail.js
deleted file mode 100644
index 69141db..0000000
--- a/src/js/sw/surfaces/projects/detail.js
+++ /dev/null
@@ -1,580 +0,0 @@
-/**
- * ProjectDetail — two-column detail view with inline chat + metadata sidebar.
- *
- * Props:
- * project — project object
- * onBack — () => void (return to list)
- * onUpdate — (deleted?: boolean) => void
- *
- * Left panel toggles: conversation list ↔ embedded ChatPane.
- * Right sidebar: collapsible sections (Description, Instructions, KBs, Notes, Files).
- */
-const html = window.html;
-const h = window.preact.h;
-const { useState, useEffect, useCallback, useRef } = window.hooks;
-
-import { ChatPane } from '../../components/chat-pane/index.js';
-import { UserMenu } from '../../shell/user-menu.js';
-
-const COLORS = ['#4f8cff','#34d399','#f59e0b','#ef4444','#a78bfa','#ec4899','#06b6d4','#8b5cf6'];
-
-function fmtDate(d) {
- if (!d) return '\u2014';
- return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
-}
-function formatBytes(b) {
- if (!b) return '0 B';
- if (b < 1024) return b + ' B';
- if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
- return (b / 1048576).toFixed(1) + ' MB';
-}
-function relTime(dateStr) {
- if (!dateStr) return '';
- const DIVS = [[60,'second'],[60,'minute'],[24,'hour'],[30,'day'],[12,'month'],[Infinity,'year']];
- let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
- if (val < 10) return 'just now';
- for (const [div, unit] of DIVS) {
- if (val < div) { const n = Math.floor(val); return `${n} ${unit}${n!==1?'s':''} ago`; }
- val /= div;
- }
- return '';
-}
-
-export function ProjectDetail({ project, onBack, onUpdate }) {
- const BASE = window.__BASE__ || '';
- const chatRef = useRef(null);
-
- // ── State ──
- const [activeChannelId, setActiveChannelId] = useState(null);
- const [assoc, setAssoc] = useState({ channels: [], kbs: [], notes: [], files: [] });
- const [editingName, setEditingName] = useState(false);
- const [nameVal, setNameVal] = useState(project.name || '');
- const [menuOpen, setMenuOpen] = useState(false);
- const [sections, setSections] = useState({ desc: true, instructions: false, kbs: false, notes: false, files: false });
- const [desc, setDesc] = useState(project.description || '');
- const [instructions, setInstructions] = useState(project.settings?.instructions || '');
- const [descDirty, setDescDirty] = useState(false);
- const [instrDirty, setInstrDirty] = useState(false);
- const [starred, setStarred] = useState(!!project.settings?.starred);
-
- // Picker state
- const [pickerOpen, setPickerOpen] = useState('');
- const [pickerItems, setPickerItems] = useState([]);
- const [pickerLoading, setPickerLoading] = useState(false);
-
- // Drag state for files
- const [dragActive, setDragActive] = useState(false);
-
- // ── Load associations ──
- const loadAssoc = useCallback(async () => {
- const id = project.id;
- const [ch, kb, nt, fi] = await Promise.all([
- sw.api.projects.channels(id).then(r => r?.data || r || []).catch(() => []),
- sw.api.projects.kbs(id).then(r => r?.data || r || []).catch(() => []),
- sw.api.projects.notes(id).then(r => r?.data || r || []).catch(() => []),
- sw.api.projects.files(id).then(r => r?.data || r?.files || r || []).catch(() => []),
- ]);
- setAssoc({ channels: ch, kbs: kb, notes: nt, files: fi });
- }, [project.id]);
-
- useEffect(() => { loadAssoc(); }, [loadAssoc]);
-
- // ── Toggle section ──
- function toggleSection(key) {
- setSections(prev => ({ ...prev, [key]: !prev[key] }));
- }
-
- // ── Name editing ──
- async function saveName() {
- const name = nameVal.trim();
- if (!name || name === project.name) { setEditingName(false); return; }
- try {
- await sw.api.projects.update(project.id, { name });
- sw.toast('Renamed', 'success');
- setEditingName(false);
- onUpdate();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── Description / Instructions save ──
- async function saveDesc() {
- try {
- await sw.api.projects.update(project.id, { description: desc });
- sw.toast('Description saved', 'success');
- setDescDirty(false);
- onUpdate();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function saveInstructions() {
- try {
- const settings = { ...(project.settings || {}), instructions };
- await sw.api.projects.update(project.id, { settings });
- sw.toast('Instructions saved', 'success');
- setInstrDirty(false);
- onUpdate();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── Star toggle ──
- async function toggleStar() {
- const next = !starred;
- setStarred(next);
- try {
- const settings = { ...(project.settings || {}), starred: next };
- await sw.api.projects.update(project.id, { settings });
- } catch (e) { sw.toast(e.message, 'error'); setStarred(!next); }
- }
-
- // ── Context menu actions ──
- async function handleColor(c) {
- try {
- await sw.api.projects.update(project.id, { color: c });
- sw.toast('Color updated', 'success');
- setMenuOpen(false);
- onUpdate();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function toggleArchive() {
- try {
- await sw.api.projects.update(project.id, { is_archived: !project.is_archived });
- sw.toast(project.is_archived ? 'Unarchived' : 'Archived', 'success');
- setMenuOpen(false);
- onUpdate();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function deleteProject() {
- setMenuOpen(false);
- if (!await sw.confirm('Delete this project? This cannot be undone.', true)) return;
- try {
- await sw.api.projects.del(project.id);
- sw.toast('Project deleted', 'success');
- onUpdate(true);
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── Picker helpers ──
- async function openPicker(type) {
- if (pickerOpen === type) { setPickerOpen(''); return; }
- setPickerOpen(type);
- setSections(prev => ({ ...prev, [type]: true }));
- setPickerLoading(true);
- setPickerItems([]);
- try {
- let items = [];
- if (type === 'kbs') {
- const r = await sw.api.knowledge.list();
- const all = r?.data || r || [];
- const attached = new Set(assoc.kbs.map(k => k.id || k.kb_id));
- items = all.filter(k => !attached.has(k.id));
- } else if (type === 'notes') {
- const r = await sw.api.notes.list();
- const all = r?.data || r || [];
- const attached = new Set(assoc.notes.map(n => n.id || n.note_id));
- items = all.filter(n => !attached.has(n.id));
- }
- setPickerItems(items);
- } catch (e) { sw.toast(e.message, 'error'); }
- setPickerLoading(false);
- }
-
- async function pickItem(type, item) {
- try {
- if (type === 'kbs') await sw.api.projects.addKb(project.id, item.id, false);
- else if (type === 'notes') await sw.api.projects.addNote(project.id, item.id);
- sw.toast('Added', 'success');
- setPickerOpen('');
- loadAssoc();
- onUpdate();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── Remove handlers ──
- async function removeKb(kbId) {
- try { await sw.api.projects.removeKb(project.id, kbId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
- catch (e) { sw.toast(e.message, 'error'); }
- }
- async function removeNote(noteId) {
- try { await sw.api.projects.removeNote(project.id, noteId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
- catch (e) { sw.toast(e.message, 'error'); }
- }
- async function removeChannel(chId) {
- try { await sw.api.projects.removeChannel(project.id, chId); sw.toast('Removed', 'success'); loadAssoc(); onUpdate(); }
- catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── File state ──
- const [expandedDirs, setExpandedDirs] = useState({});
- const [uploading, setUploading] = useState('');
-
- // ── File upload ──
- const ARCHIVE_EXTS = ['.zip', '.tar.gz', '.tgz'];
-
- async function uploadFiles(files) {
- const total = files.length;
- let done = 0;
- setUploading(`Uploading 0 of ${total}\u2026`);
- for (const file of files) {
- try {
- const isArchive = ARCHIVE_EXTS.some(e => file.name.toLowerCase().endsWith(e));
- if (isArchive && await sw.confirm(`Extract "${file.name}" contents into the project?`)) {
- const r = await sw.api.projects.uploadArchive(project.id, file);
- sw.toast(`Extracted ${r?.files_extracted || 0} files`, 'success');
- } else {
- await sw.api.projects.uploadFile(project.id, file);
- sw.toast(`Uploaded ${file.name}`, 'success');
- }
- } catch (e) {
- const msg = e.status === 413 ? 'Storage quota exceeded' : e.message;
- sw.toast(`${file.name}: ${msg}`, 'error');
- }
- done++;
- setUploading(`Uploading ${done} of ${total}\u2026`);
- }
- setUploading('');
- loadAssoc();
- }
-
- function handleUploadClick() {
- const input = document.createElement('input');
- input.type = 'file';
- input.multiple = true;
- input.onchange = () => { if (input.files.length) uploadFiles(input.files); };
- input.click();
- }
-
- function handleDrop(e) {
- e.preventDefault();
- setDragActive(false);
- if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
- }
-
- // ── File actions ──
- async function downloadFile(path) {
- try {
- const token = sw.auth?.token?.();
- const base = window.__BASE__ || '';
- const url = `${base}/api/v1/projects/${project.id}/files/download?path=${encodeURIComponent(path)}`;
- const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
- if (!res.ok) throw new Error('Download failed');
- const blob = await res.blob();
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = path.split('/').pop() || 'download';
- a.click();
- URL.revokeObjectURL(a.href);
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function deleteFile(path) {
- const name = path.split('/').pop() || path;
- if (!await sw.confirm(`Delete "${name}"?`, true)) return;
- try {
- await sw.api.projects.deleteFile(project.id, path);
- sw.toast('Deleted', 'success');
- loadAssoc();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function createFolder() {
- const name = await sw.prompt('Folder name:');
- if (!name) return;
- try {
- await sw.api.projects.mkdir(project.id, name);
- sw.toast(`Created "${name}"`, 'success');
- loadAssoc();
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- async function downloadAll() {
- try {
- const token = sw.auth?.token?.();
- const base = window.__BASE__ || '';
- const url = `${base}/api/v1/projects/${project.id}/archive/download?format=zip`;
- const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
- if (!res.ok) throw new Error('Download failed');
- const blob = await res.blob();
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `${project.name || 'project'}.zip`;
- a.click();
- URL.revokeObjectURL(a.href);
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── File tree builder ──
- function buildTree(files) {
- if (!files || !files.length) return [];
- // Sort: directories first, then alpha
- const sorted = [...files].sort((a, b) => {
- if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
- return (a.path || '').localeCompare(b.path || '');
- });
- // Group into top-level entries (split by first path segment)
- const root = [];
- const dirMap = {};
- for (const f of sorted) {
- const parts = (f.path || f.filename || '').split('/').filter(Boolean);
- if (parts.length <= 1) {
- root.push({ ...f, name: parts[0] || f.filename, children: f.is_directory ? [] : null, depth: 0 });
- if (f.is_directory) dirMap[f.path] = root[root.length - 1];
- } else {
- // Find parent dir in root
- const parentPath = parts.slice(0, -1).join('/');
- const parent = dirMap[parentPath];
- const entry = { ...f, name: parts[parts.length - 1], children: f.is_directory ? [] : null, depth: parts.length - 1 };
- if (parent) {
- parent.children.push(entry);
- } else {
- root.push(entry);
- }
- if (f.is_directory) dirMap[f.path] = entry;
- }
- }
- return root;
- }
-
- function fileIcon(f) {
- if (f.is_directory) return '\uD83D\uDCC1';
- const ct = f.content_type || '';
- if (ct.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
- if (ct === 'application/pdf') return '\uD83D\uDCC4';
- if (ct.startsWith('text/')) return '\uD83D\uDCC3';
- return '\uD83D\uDCCE';
- }
-
- function renderFileTree(entries) {
- if (!entries || !entries.length) return null;
- return entries.map(f => {
- const isDir = f.is_directory;
- const expanded = expandedDirs[f.path];
- const indent = (f.depth || 0) * 16;
- return html`
-
- ${isDir && html` setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))}>\u25B6`}
- ${fileIcon(f)}
- setExpandedDirs(prev => ({ ...prev, [f.path]: !prev[f.path] }))
- : () => downloadFile(f.path)}
- title=${isDir ? 'Toggle folder' : 'Click to download'}>
- ${f.name}
-
- ${!isDir && html`${formatBytes(f.size_bytes)}`}
- ${isDir && f.children && html`${f.children.length} items`}
-
-
- ${isDir && expanded && f.children && renderFileTree(f.children)}
-
`;
- });
- }
-
- // ── New conversation ──
- async function newConversation() {
- try {
- const ch = await sw.api.channels.create({ type: 'direct', title: 'New Chat' });
- const chId = ch?.id || ch?.data?.id;
- if (chId) {
- await sw.api.projects.addChannel(project.id, chId);
- await loadAssoc();
- setActiveChannelId(chId);
- }
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- // ── Render: Picker dropdown ──
- function renderPicker(type, emptyHint) {
- if (pickerOpen !== type) return null;
- return html`
- ${pickerLoading && html`
Loading\u2026
`}
- ${!pickerLoading && pickerItems.length === 0 && html`
${emptyHint}
`}
- ${!pickerLoading && pickerItems.map(item => html`
-
pickItem(type, item)}>
- ${item.title || item.name || item.id}
-
- `)}
-
`;
- }
-
- // ── Render: Collapsible section ──
- function renderSection(key, title, count, content, addAction) {
- const open = sections[key];
- return html`
-
- ${open && html`
${content}
`}
-
`;
- }
-
- // ── Render: Left panel ──
- function renderLeftPanel() {
- if (activeChannelId) {
- return html`
-
setActiveChannelId(null)}>
- \u2190 Back to conversations
-
- <${ChatPane}
- channelId=${activeChannelId}
- standalone=${false}
- handleRef=${chatRef}
- className="sw-projects__chat-pane" />
-
`;
- }
-
- return html`
-
-
- ${assoc.channels.length === 0 && html`
-
- No conversations yet.
-
-
- `}
- ${assoc.channels.map(ch => {
- const chId = ch.id || ch.channel_id;
- return html`
setActiveChannelId(chId)}>
-
${ch.title || ch.name || chId}
-
Last message ${relTime(ch.updated_at)}
-
`;
- })}
-
-
`;
- }
-
- // ── Render: Right sidebar ──
- function renderSidebar() {
- return html``;
- }
-
- // ── Main render ──
- return html`
-
-
- ${renderLeftPanel()}
- ${renderSidebar()}
-
-
`;
-}
diff --git a/src/js/sw/surfaces/projects/index.js b/src/js/sw/surfaces/projects/index.js
deleted file mode 100644
index 76584ce..0000000
--- a/src/js/sw/surfaces/projects/index.js
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * ProjectsSurface — root view-switching component.
- *
- * No selectedId → card grid list (ProjectsList)
- * With selectedId → detail view (ProjectDetail)
- *
- * Globals: __PROJECT_ID__ (deep-link), __BASE__ (base path)
- */
-const html = window.html;
-const { useState, useEffect, useCallback } = window.hooks;
-const { render } = window.preact;
-
-import { ToastContainer } from '../../primitives/toast.js';
-import { DialogStack } from '../../shell/dialog-stack.js';
-import { ProjectsList } from './list.js';
-import { ProjectDetail } from './detail.js';
-
-function ProjectsSurface() {
- const BASE = window.__BASE__ || '';
- const deepLinkId = window.__PROJECT_ID__ || '';
-
- const [projects, setProjects] = useState([]);
- const [selectedId, setSelectedId] = useState(deepLinkId || '');
- const [loading, setLoading] = useState(true);
-
- const load = useCallback(async () => {
- try {
- const resp = await sw.api.projects.list();
- const list = resp?.data || resp || [];
- setProjects(list);
- if (deepLinkId && !list.find(p => p.id === deepLinkId)) {
- setSelectedId('');
- }
- } catch (e) { sw.toast(e.message, 'error'); }
- finally { setLoading(false); }
- }, [deepLinkId]);
-
- useEffect(() => { load(); }, [load]);
-
- const selected = projects.find(p => p.id === selectedId) || null;
-
- function selectProject(id) {
- setSelectedId(id);
- history.replaceState(null, '', BASE + '/projects/' + id);
- }
-
- function goBack() {
- setSelectedId('');
- history.replaceState(null, '', BASE + '/projects');
- }
-
- async function createProject(name) {
- try {
- const proj = await sw.api.projects.create({ name });
- sw.toast('Project created', 'success');
- await load();
- if (proj?.id) selectProject(proj.id);
- } catch (e) { sw.toast(e.message, 'error'); }
- }
-
- const handleUpdate = useCallback((deleted) => {
- if (deleted) { setSelectedId(''); history.replaceState(null, '', BASE + '/projects'); }
- load();
- }, [load, BASE]);
-
- return html`
-
- ${selected
- ? html`<${ProjectDetail}
- key=${selectedId}
- project=${selected}
- onBack=${goBack}
- onUpdate=${handleUpdate} />`
- : html`<${ProjectsList}
- projects=${projects}
- loading=${loading}
- onSelect=${selectProject}
- onCreate=${createProject} />`
- }
-
- <${ToastContainer} />
- <${DialogStack} />
- `;
-}
-
-// ── Mount ────────────────────────────────
-const mount = document.getElementById('projects-mount');
-if (mount) {
- render(html`<${ProjectsSurface} />`, mount);
- console.log('[projects] Surface mounted');
-}
-
-export { ProjectsSurface };
diff --git a/src/js/sw/surfaces/projects/list.js b/src/js/sw/surfaces/projects/list.js
deleted file mode 100644
index 97f054c..0000000
--- a/src/js/sw/surfaces/projects/list.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * ProjectsList — card grid view for all projects.
- *
- * Props:
- * projects — array of project objects
- * loading — boolean
- * onSelect — (id: string) => void
- * onCreate — (name: string) => void
- */
-const html = window.html;
-const { useState, useMemo } = window.hooks;
-
-import { UserMenu } from '../../shell/user-menu.js';
-
-// ── Relative time helper ────────────────────
-const DIVISIONS = [
- [60, 'second'], [60, 'minute'], [24, 'hour'],
- [30, 'day'], [12, 'month'], [Infinity, 'year'],
-];
-function relativeTime(dateStr) {
- if (!dateStr) return '';
- let val = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
- if (val < 10) return 'just now';
- for (const [div, unit] of DIVISIONS) {
- if (val < div) {
- const n = Math.floor(val);
- return `Updated ${n} ${unit}${n !== 1 ? 's' : ''} ago`;
- }
- val /= div;
- }
- return '';
-}
-
-export function ProjectsList({ projects, loading, onSelect, onCreate }) {
- const [search, setSearch] = useState('');
- const [sort, setSort] = useState('activity');
-
- const filtered = useMemo(() => {
- const q = search.toLowerCase().trim();
- let list = projects;
- if (q) {
- list = list.filter(p =>
- (p.name || '').toLowerCase().includes(q) ||
- (p.description || '').toLowerCase().includes(q)
- );
- }
- if (sort === 'activity') {
- list = [...list].sort((a, b) =>
- new Date(b.updated_at || 0) - new Date(a.updated_at || 0)
- );
- } else {
- list = [...list].sort((a, b) =>
- (a.name || '').localeCompare(b.name || '')
- );
- }
- return list;
- }, [projects, search, sort]);
-
- async function handleCreate() {
- const name = await sw.prompt('Project name:');
- if (name && name.trim()) onCreate(name.trim());
- }
-
- if (loading) {
- return html`
-
Loading projects\u2026
-
`;
- }
-
- return html`
-
-
-
-
- \u{1F50D}
- setSearch(e.target.value)} />
-
-
-
- Sort by
-
-
-
- ${filtered.length === 0 && !loading && html`
-
- ${search ? 'No projects match your search.' : 'No projects yet.'}
- ${!search && html`
-
-
`}
-
- `}
-
-
- ${filtered.map(p => html`
-
onSelect(p.id)}>
-
- ${p.icon ? html`${p.icon}` : ''}
- ${p.name}
- ${p.is_archived && html`archived`}
-
- ${p.description && html`
-
${p.description}
- `}
-
${relativeTime(p.updated_at)}
-
- `)}
-
-
- `;
-}