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 ────────────────────────── */}} -
-
- Loading... -
-
- -{{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 ────────────────────────── */}} -
-
- Loading... -
-
- -{{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` -
-
- - _set('title', e.target.value)} /> -
-
- -