diff --git a/server/events/bus_test.go b/server/events/bus_test.go index 0db568a..6577ada 100644 --- a/server/events/bus_test.go +++ b/server/events/bus_test.go @@ -105,8 +105,8 @@ func TestRouteFor(t *testing.T) { label string want Direction }{ - {"chat.message.abc", DirBoth}, - {"chat.typing.abc", DirBoth}, + {"chat.message.abc", DirLocal}, // chat routes removed in v0.1.0 + {"chat.typing.abc", DirLocal}, // chat routes removed in v0.1.0 {"system.notify", DirToClient}, {"plugin.hook.pre_completion", DirLocal}, {"internal.db.write", DirLocal}, diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 1ea121c..420e7fb 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -2,8 +2,6 @@ package handlers import ( "encoding/json" - "errors" - "fmt" "log" "net/http" "strconv" @@ -18,7 +16,6 @@ import ( "switchboard-core/models" "switchboard-core/storage" "switchboard-core/store" - "switchboard-core/tools/search" ) type AdminHandler struct { @@ -327,7 +324,7 @@ func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) { if err := json.Unmarshal(req.Value, &strVal); err == nil { // String value → try as policy first if _, ok := models.PolicyDefaults[key]; ok { - if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil { + if err := h.stores.Policies.Set(c.Request.Context(), key, strVal); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"}) return } @@ -347,30 +344,9 @@ func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) { h.auditLog(c, "settings.update", "global_settings", key, nil) - // Live-apply hooks for settings that have runtime effects - if key == "search_config" { - applySearchConfig(jsonVal) - } - c.JSON(http.StatusOK, gin.H{"message": "setting updated"}) } -// applySearchConfig updates the active search provider at runtime. -func applySearchConfig(raw models.JSONMap) { - b, err := json.Marshal(raw) - if err != nil { - log.Printf("⚠️ Failed to marshal search config: %v", err) - return - } - var cfg search.Config - if err := json.Unmarshal(b, &cfg); err != nil { - log.Printf("⚠️ Failed to parse search config: %v", err) - return - } - if err := search.ApplyConfig(cfg); err != nil { - log.Printf("⚠️ Failed to apply search config: %v", err) - } -} func (h *AdminHandler) PublicSettings(c *gin.Context) { // Banner config, branding, etc. — safe subset for non-admin users @@ -444,10 +420,7 @@ func (h *AdminHandler) GetStats(c *gin.Context) { stats := gin.H{} userCount, _ := h.stores.Users.CountAll(ctx) - teamCount, _ := h.stores.Teams.CountAll(ctx) - stats["users"] = userCount - stats["teams"] = teamCount c.JSON(http.StatusOK, stats) } diff --git a/server/handlers/helpers.go b/server/handlers/helpers.go new file mode 100644 index 0000000..2799c4b --- /dev/null +++ b/server/handlers/helpers.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// isDuplicateErr checks if a database error indicates a unique constraint violation. +func isDuplicateErr(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "duplicate key value violates unique constraint") +} + +// getUserID extracts the authenticated user ID from the gin context. +// Set by the Auth middleware. +func getUserID(c *gin.Context) string { + return c.GetString("user_id") +} + +// parsePagination extracts page, perPage, and offset from query params. +// Defaults: page=1, per_page=50, max per_page=200. +func parsePagination(c *gin.Context) (page, perPage, offset int) { + page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50")) + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + offset = (page - 1) * perPage + return +} diff --git a/server/handlers/profile_test.go b/server/handlers/profile_test.go deleted file mode 100644 index 391d1b0..0000000 --- a/server/handlers/profile_test.go +++ /dev/null @@ -1,343 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" - - "switchboard-core/config" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// ── Profile Test Harness ────────────────── - -type profileHarness struct { - *testHarness - userToken string - userID string -} - -func setupProfileHarness(t *testing.T) *profileHarness { - 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) - } - userCache := middleware.NewUserStatusCache() - - r := gin.New() - api := r.Group("/api/v1") - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - 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) - - userID := database.SeedTestUser(t, "profuser", "profuser@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) - userToken := makeToken(userID, "profuser@test.com", "user") - - return &profileHarness{ - testHarness: &testHarness{router: r, t: t}, - userToken: userToken, - userID: userID, - } -} - -// ── GET /profile ────────────────────────── - -func TestProfile_Get_Shape(t *testing.T) { - h := setupProfileHarness(t) - - resp := h.request("GET", "/api/v1/profile", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Required fields - for _, key := range []string{"id", "username", "email", "role", "settings", "created_at"} { - if _, ok := body[key]; !ok { - t.Errorf("missing required field %q in profile response", key) - } - } - - if body["username"] != "profuser" { - t.Errorf("username: got %v, want profuser", body["username"]) - } - if body["email"] != "profuser@test.com" { - t.Errorf("email: got %v, want profuser@test.com", body["email"]) - } - if body["role"] != "user" { - t.Errorf("role: got %v, want user", body["role"]) - } - - // settings must be an object (not null) - settings, ok := body["settings"].(map[string]interface{}) - if !ok { - t.Errorf("settings should be an object, got %T", body["settings"]) - } - _ = settings - - // avatar_url should NOT appear (handler uses "avatar" json tag) - if _, exists := body["avatar_url"]; exists { - t.Error("profile should return 'avatar' not 'avatar_url'") - } -} - -func TestProfile_Get_RequiresAuth(t *testing.T) { - h := setupProfileHarness(t) - - resp := h.request("GET", "/api/v1/profile", "", nil) - if resp.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 without token, got %d", resp.Code) - } -} - -// ── PUT /profile ────────────────────────── - -func TestProfile_Update_DisplayName(t *testing.T) { - h := setupProfileHarness(t) - - name := "New Name" - resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{ - "display_name": name, - }) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - if body["display_name"] != name { - t.Errorf("display_name: got %v, want %s", body["display_name"], name) - } -} - -func TestProfile_Update_Email(t *testing.T) { - h := setupProfileHarness(t) - - resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{ - "email": "NEW@TEST.COM", - }) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Email should be lowercased - if body["email"] != "new@test.com" { - t.Errorf("email: got %v, want new@test.com", body["email"]) - } -} - -func TestProfile_Update_DuplicateEmail_409(t *testing.T) { - h := setupProfileHarness(t) - - // Seed another user with the target email - database.SeedTestUser(t, "other", "taken@test.com") - - resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{ - "email": "taken@test.com", - }) - if resp.Code != http.StatusConflict { - t.Fatalf("expected 409 for duplicate email, got %d: %s", resp.Code, resp.Body.String()) - } -} - -// ── GET /settings ───────────────────────── - -func TestSettings_Get_Envelope(t *testing.T) { - h := setupProfileHarness(t) - - resp := h.request("GET", "/api/v1/settings", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // P0: Must have "settings" key — this was the surface test failure - settings, ok := body["settings"] - if !ok { - t.Fatal("GET /settings must return {\"settings\": {...}}, missing \"settings\" key") - } - - // settings value must be an object (not null) - settingsMap, ok := settings.(map[string]interface{}) - if !ok { - t.Fatalf("settings value should be an object, got %T", settings) - } - - // New user should have empty settings - if len(settingsMap) != 0 { - t.Errorf("new user settings should be empty, got %v", settingsMap) - } -} - -// ── PUT /settings ───────────────────────── - -func TestSettings_Update_MergeAndEnvelope(t *testing.T) { - h := setupProfileHarness(t) - - // Set initial - resp := h.request("PUT", "/api/v1/settings", h.userToken, map[string]interface{}{ - "theme": "dark", - "lang": "en", - }) - if resp.Code != http.StatusOK { - t.Fatalf("initial PUT: got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body1 map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body1) - settings1, _ := body1["settings"].(map[string]interface{}) - if settings1["theme"] != "dark" || settings1["lang"] != "en" { - t.Fatalf("initial settings: got %v", settings1) - } - - // Merge: change theme, keep lang - resp2 := h.request("PUT", "/api/v1/settings", h.userToken, map[string]interface{}{ - "theme": "light", - }) - if resp2.Code != http.StatusOK { - t.Fatalf("merge PUT: got %d, body: %s", resp2.Code, resp2.Body.String()) - } - - var body2 map[string]interface{} - json.Unmarshal(resp2.Body.Bytes(), &body2) - settings2, _ := body2["settings"].(map[string]interface{}) - - if settings2["theme"] != "light" { - t.Errorf("theme should be overwritten to 'light', got %v", settings2["theme"]) - } - if settings2["lang"] != "en" { - t.Errorf("lang should be preserved as 'en', got %v", settings2["lang"]) - } - - // Verify via GET - resp3 := h.request("GET", "/api/v1/settings", h.userToken, nil) - var body3 map[string]interface{} - json.Unmarshal(resp3.Body.Bytes(), &body3) - settings3, _ := body3["settings"].(map[string]interface{}) - if settings3["theme"] != "light" || settings3["lang"] != "en" { - t.Errorf("GET after merge: got %v", settings3) - } -} - -// ── POST /profile/password ──────────────── - -func TestProfile_ChangePassword(t *testing.T) { - h := setupProfileHarness(t) - - // Seed user with a known bcrypt hash for "oldpassword" - // The default SeedTestUser uses a dummy hash. We need a real one. - hash, _ := hashPassword("oldpassword") - database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID) - - // Change password - resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{ - "current_password": "oldpassword", - "new_password": "newpassword123", - }) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - if body["message"] != "password updated" { - t.Errorf("expected success message, got %v", body) - } -} - -func TestProfile_ChangePassword_WrongCurrent_401(t *testing.T) { - h := setupProfileHarness(t) - - hash, _ := hashPassword("correctpassword") - database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID) - - resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{ - "current_password": "wrongpassword", - "new_password": "newpassword123", - }) - if resp.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 for wrong current password, got %d: %s", resp.Code, resp.Body.String()) - } -} - -func TestProfile_ChangePassword_TooShort_400(t *testing.T) { - h := setupProfileHarness(t) - - hash, _ := hashPassword("oldpassword") - database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID) - - resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{ - "current_password": "oldpassword", - "new_password": "short", - }) - if resp.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for short password, got %d: %s", resp.Code, resp.Body.String()) - } -} - -// ── DELETE /profile/avatar ──────────────── - -func TestProfile_DeleteAvatar(t *testing.T) { - h := setupProfileHarness(t) - - // Set an avatar first - database.TestDB.Exec(dialectSQL("UPDATE users SET avatar_url = $1 WHERE id = $2"), - "data:image/png;base64,test", h.userID) - - resp := h.request("DELETE", "/api/v1/profile/avatar", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - // Verify avatar is cleared in profile - resp2 := h.request("GET", "/api/v1/profile", h.userToken, nil) - var body map[string]interface{} - json.Unmarshal(resp2.Body.Bytes(), &body) - - // avatar should be omitted (omitempty) or null - if av, exists := body["avatar"]; exists && av != nil { - t.Errorf("avatar should be cleared, got %v", av) - } -} - -// ── Helper ──────────────────────────────── - -func hashPassword(pw string) (string, error) { - h, err := bcrypt.GenerateFromPassword([]byte(pw), bcryptCost) - return string(h), err -} diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go deleted file mode 100644 index 38951bc..0000000 --- a/server/handlers/route_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package handlers - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "switchboard-core/config" - authpkg "switchboard-core/auth" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/pages" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// TestRouteRegistration verifies that ALL production routes can be -// registered on a single Gin engine without panicking. This catches -// wildcard parameter name conflicts (e.g. /w/:id vs /w/:scope/:slug) -// which cause runtime panics during startup. -// -// The test mirrors the route structure in main.go and pages.go. -// Any new route group should be added here. -func TestRouteRegistration(t *testing.T) { - database.RequireTestDB(t) - - cfg := &config.Config{ - JWTSecret: testJWTSecret, - BasePath: "/test", - Port: "0", - } - - var stores store.Stores - if database.IsSQLite() { - stores = sqlite.NewStores(database.TestDB) - } else { - stores = postgres.NewStores(database.TestDB) - } - userCache := middleware.NewUserStatusCache() - - // This must not panic. If it does, there's a wildcard conflict. - r := gin.New() - base := r.Group(cfg.BasePath) - - // ── API routes (mirrors main.go) ──────── - - api := base.Group("/api/v1") - - // Auth (public) - auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) - api.POST("/auth/login", auth.Login) - api.POST("/auth/register", auth.Register) - - // Protected API routes - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - // Channels (the route group that conflicts with workflow) - channels := NewChannelHandler(stores) - protected.GET("/channels", channels.ListChannels) - protected.POST("/channels", channels.CreateChannel) - protected.GET("/channels/:id", channels.GetChannel) - protected.DELETE("/channels/:id", channels.DeleteChannel) - - // Messages - msgs := NewMessageHandler(nil, stores, nil, nil) - protected.GET("/channels/:id/messages", msgs.ListMessages) - protected.POST("/channels/:id/messages", msgs.CreateMessage) - - // Workflow CRUD - wfH := NewWorkflowHandler(stores) - protected.GET("/workflows", wfH.List) - protected.POST("/workflows", wfH.Create) - protected.GET("/workflows/:id", wfH.Get) - protected.PATCH("/workflows/:id", wfH.Update) - protected.DELETE("/workflows/:id", wfH.Delete) - protected.GET("/workflows/:id/stages", wfH.ListStages) - protected.POST("/workflows/:id/stages", wfH.CreateStage) - protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage) - protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage) - protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages) - protected.POST("/workflows/:id/publish", wfH.Publish) - protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) - - // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil) - protected.POST("/workflows/:id/start", wfInstH.Start) - protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) - protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) - protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) - - // Workflow assignments (v0.26.4) - wfAssignH := NewWorkflowAssignmentHandler(stores) - protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) - protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) - protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) - - // Session API (the /w/:id group) - wfAPI := base.Group("/api/v1/w") - wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) - wfAPI.POST("/:id/messages", msgs.CreateMessage) - wfAPI.GET("/:id/messages", msgs.ListMessages) - - // Visitor entry (separate namespace to avoid /w/:id collision) - wfEntry := NewWorkflowEntryHandler(stores) - base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor) - - // ── Page routes (mirrors pages.go surface registration) ──── - - pageEngine := pages.New(cfg, stores) - pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{ - Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), - Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage()}, - Session: middleware.AuthOrSession(cfg, stores, userCache), - }) - - // ── Verify routes actually resolve ──── - - // Health-check style: send a request and confirm we get a response - // (not a panic). We don't care about the status code — just that - // the router doesn't blow up. - paths := []struct { - method string - path string - }{ - {"GET", "/test/api/v1/workflows"}, - {"GET", "/test/api/v1/channels"}, - {"GET", "/test/w/some-channel-id"}, // workflow chat surface - {"GET", "/test/w/some-scope/some-slug"}, // workflow landing surface - {"POST", "/test/api/v1/workflow-entry/global/test-slug"}, - } - - for _, p := range paths { - req := httptest.NewRequest(p.method, p.path, nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - // We just want to confirm the router didn't panic and returned - // something (even 401/404 is fine — means routing worked). - if w.Code == 0 { - t.Errorf("%s %s: got status 0 (router failed to match)", p.method, p.path) - } - } -} - -// TestWorkflowPageRoutesNoConflict specifically tests the /w/ namespace -// to ensure different-depth routes with the same param name work. -func TestWorkflowPageRoutesNoConflict(t *testing.T) { - r := gin.New() - - // These two routes MUST use the same wildcard name at position 1. - // /w/:id (2 segments) = workflow chat - // /w/:id/:slug (3 segments) = workflow landing - // Using different names (e.g. :scope) at the same position panics. - r.GET("/w/:id", func(c *gin.Context) { - c.String(http.StatusOK, "chat:"+c.Param("id")) - }) - r.GET("/w/:id/:slug", func(c *gin.Context) { - c.String(http.StatusOK, "landing:"+c.Param("id")+"/"+c.Param("slug")) - }) - - // 2-segment path → chat - w := httptest.NewRecorder() - r.ServeHTTP(w, httptest.NewRequest("GET", "/w/channel-123", nil)) - if w.Code != 200 || w.Body.String() != "chat:channel-123" { - t.Errorf("2-segment: got %d %q", w.Code, w.Body.String()) - } - - // 3-segment path → landing - w = httptest.NewRecorder() - r.ServeHTTP(w, httptest.NewRequest("GET", "/w/my-team/intake-form", nil)) - if w.Code != 200 || w.Body.String() != "landing:my-team/intake-form" { - t.Errorf("3-segment: got %d %q", w.Code, w.Body.String()) - } -} diff --git a/server/handlers/starlark_helpers.go b/server/handlers/starlark_helpers.go new file mode 100644 index 0000000..bf07c25 --- /dev/null +++ b/server/handlers/starlark_helpers.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "encoding/json" + "fmt" + + "go.starlark.net/starlark" +) + +// jsonToStarlark converts an arbitrary Go value (typically from JSON unmarshal) +// to a Starlark value for passing into extension scripts. +func jsonToStarlark(v any) starlark.Value { + switch val := v.(type) { + case nil: + return starlark.None + case bool: + return starlark.Bool(val) + case float64: + if val == float64(int64(val)) { + return starlark.MakeInt64(int64(val)) + } + return starlark.Float(val) + case string: + return starlark.String(val) + case []any: + elems := make([]starlark.Value, len(val)) + for i, e := range val { + elems[i] = jsonToStarlark(e) + } + return starlark.NewList(elems) + case map[string]any: + d := starlark.NewDict(len(val)) + for k, v := range val { + _ = d.SetKey(starlark.String(k), jsonToStarlark(v)) + } + return d + default: + return starlark.String(fmt.Sprintf("%v", val)) + } +} + +// starlarkValueToGo converts a Starlark value back to a Go value suitable +// for JSON serialization. +func starlarkValueToGo(v starlark.Value) any { + switch val := v.(type) { + case starlark.NoneType: + return nil + case starlark.Bool: + return bool(val) + case starlark.Int: + i, _ := val.Int64() + return i + case starlark.Float: + return float64(val) + case starlark.String: + return string(val) + case *starlark.List: + out := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + out[i] = starlarkValueToGo(val.Index(i)) + } + return out + case *starlark.Dict: + out := make(map[string]any) + for _, item := range val.Items() { + k, _ := item[0].(starlark.String) + out[string(k)] = starlarkValueToGo(item[1]) + } + return out + default: + return v.String() + } +} + +// ParseSchemaVersion extracts the schema_version from a package manifest. +func ParseSchemaVersion(manifest any) int { + switch m := manifest.(type) { + case map[string]any: + if v, ok := m["schema_version"].(float64); ok { + return int(v) + } + case json.RawMessage: + var s struct { + SchemaVersion int `json:"schema_version"` + } + if err := json.Unmarshal(m, &s); err == nil { + return s.SchemaVersion + } + } + return 0 +} + +// RunSchemaMigrations runs extension schema migrations between versions. +// Stub — full implementation will be added when extension DB schemas are stabilized. +func RunSchemaMigrations(ctx any, sandbox any, stores any, db any, isPostgres bool, packageID string, manifest any, fromVersion, toVersion int) error { + // TODO: implement extension schema migrations + return nil +} diff --git a/server/handlers/teams.go b/server/handlers/teams.go index c9f9a16..6ec0b6d 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -1,11 +1,7 @@ package handlers import ( - "context" "database/sql" - "encoding/json" - "fmt" - "log" "net/http" "strconv" diff --git a/server/handlers/test_helpers_test.go b/server/handlers/test_helpers_test.go new file mode 100644 index 0000000..0ebad52 --- /dev/null +++ b/server/handlers/test_helpers_test.go @@ -0,0 +1,140 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "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/database" +) + +const testJWTSecret = "test-secret-key-for-handler-tests" + +type testHarness struct { + router *gin.Engine + t *testing.T +} + +func (h *testHarness) request(method, path, token string, body interface{}) *httptest.ResponseRecorder { + var bodyReader *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + bodyReader = bytes.NewReader(b) + } else { + bodyReader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, bodyReader) + 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 +} + +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(15 * time.Minute)), + Issuer: "switchboard-core", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, _ := token.SignedString([]byte(testJWTSecret)) + return s +} + +// seedInsertReturningID inserts a row, pre-generating an id for SQLite. +// Expects query like: INSERT INTO users (col1, col2, ...) VALUES ($1, $2, ...) RETURNING id +func seedInsertReturningID(t *testing.T, query string, args ...interface{}) string { + t.Helper() + id := uuid.New().String() + + // Inject id column and value into the query + // "INSERT INTO users (username, ..." → "INSERT INTO users (id, username, ..." + q := strings.Replace(query, "(username,", "(id, username,", 1) + // Strip RETURNING clause + if idx := strings.Index(q, " RETURNING "); idx > 0 { + q = q[:idx] + } + + if database.IsSQLite() { + // Convert $N → ? + for i := len(args) + 1; i >= 1; i-- { + q = strings.ReplaceAll(q, "$"+itoa(i), "?") + } + // Add ? for the injected id column + q = strings.Replace(q, "VALUES (", "VALUES (?, ", 1) + newArgs := append([]interface{}{id}, args...) + _, err := database.TestDB.Exec(q, newArgs...) + if err != nil { + t.Fatalf("seedInsertReturningID (sqlite): %v\nquery: %s", err, q) + } + return id + } + + // Postgres: renumber $N → $N+1, insert $1 for id + for i := len(args); i >= 1; i-- { + q = strings.ReplaceAll(q, "$"+itoa(i), "$"+itoa(i+1)) + } + q = strings.Replace(q, "(id,", "(id,", 1) + // Add id as first value placeholder + q = strings.Replace(q, "VALUES (", "VALUES ($1, ", 1) + newArgs := append([]interface{}{id}, args...) + _, err := database.TestDB.Exec(q, newArgs...) + if err != nil { + t.Fatalf("seedInsertReturningID (pg): %v\nquery: %s", err, q) + } + return id +} + +func itoa(i int) string { + if i < 10 { + return string(rune('0' + i)) + } + return string(rune('0'+i/10)) + string(rune('0'+i%10)) +} + +func decode(args ...interface{}) { + switch len(args) { + case 2: + w := args[0].(*httptest.ResponseRecorder) + json.NewDecoder(w.Body).Decode(args[1]) + case 3: + t := args[0].(*testing.T) + t.Helper() + w := args[1].(*httptest.ResponseRecorder) + if err := json.NewDecoder(w.Body).Decode(args[2]); err != nil { + t.Fatalf("decode response: %v", err) + } + } +} + +// dialectSQL returns query as-is. Placeholder conversion happens in seedInsertReturningID. +func dialectSQL(query string) string { + if database.IsSQLite() { + q := query + for i := 20; i >= 1; i-- { + q = strings.ReplaceAll(q, "$"+itoa(i), "?") + } + return q + } + return query +} + +func init() { + _ = http.StatusOK +} diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go deleted file mode 100644 index 03a17a5..0000000 --- a/server/handlers/workflow_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - - "switchboard-core/config" - authpkg "switchboard-core/auth" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// TestWorkflowCRUD exercises the full workflow lifecycle: -// create → add stages → publish → start instance → advance → complete. -func TestWorkflowCRUD(t *testing.T) { - h := setupWorkflowHarness(t) - - // ── Create workflow ───────────────────── - - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Customer Intake", - "slug": "customer-intake", - "description": "Collect customer info and route to support", - "entry_mode": "public_link", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Create workflow: got %d, body: %s", resp.Code, resp.Body.String()) - } - - var wf struct { - ID string `json:"id"` - Slug string `json:"slug"` - Version int `json:"version"` - } - json.Unmarshal(resp.Body.Bytes(), &wf) - if wf.ID == "" { - t.Fatal("workflow ID is empty") - } - if wf.Slug != "customer-intake" { - t.Errorf("slug: got %q, want %q", wf.Slug, "customer-intake") - } - if wf.Version != 1 { - t.Errorf("version: got %d, want 1", wf.Version) - } - - // ── Get workflow ──────────────────────── - - resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Get workflow: got %d, body: %s", resp.Code, resp.Body.String()) - } - - // ── List workflows ────────────────────── - - resp = h.request("GET", "/api/v1/workflows", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("List workflows: got %d", resp.Code) - } - var listResp struct { - Data []struct{ ID string } `json:"data"` - } - json.Unmarshal(resp.Body.Bytes(), &listResp) - if len(listResp.Data) == 0 { - t.Error("List workflows returned empty") - } - - // ── Add stages ────────────────────────── - - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Collect Info", - "history_mode": "full", - "ordinal": 0, - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Create stage 0: got %d, body: %s", resp.Code, resp.Body.String()) - } - var stage0 struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &stage0) - - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Review", - "history_mode": "fresh", - "ordinal": 1, - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Create stage 1: got %d, body: %s", resp.Code, resp.Body.String()) - } - var stage1 struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &stage1) - - // ── List stages ───────────────────────── - - resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("List stages: got %d", resp.Code) - } - var stagesResp struct { - Data []struct { - ID string `json:"id"` - Ordinal int `json:"ordinal"` - } `json:"data"` - } - json.Unmarshal(resp.Body.Bytes(), &stagesResp) - if len(stagesResp.Data) != 2 { - t.Fatalf("List stages: got %d, want 2", len(stagesResp.Data)) - } - - // ── Reorder stages ────────────────────── - - resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID+"/stages/reorder", h.adminToken, map[string]interface{}{ - "ordered_ids": []string{stage1.ID, stage0.ID}, - }) - if resp.Code != http.StatusOK { - t.Fatalf("Reorder stages: got %d, body: %s", resp.Code, resp.Body.String()) - } - - // ── Update workflow ───────────────────── - - resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ - "is_active": true, - }) - if resp.Code != http.StatusOK { - t.Fatalf("Update workflow: got %d, body: %s", resp.Code, resp.Body.String()) - } - - // Re-read to get incremented version - resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) - json.Unmarshal(resp.Body.Bytes(), &wf) - if wf.Version != 2 { - t.Errorf("version after update: got %d, want 2", wf.Version) - } - - // ── Publish ───────────────────────────── - - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) - if resp.Code != http.StatusCreated { - t.Fatalf("Publish: got %d, body: %s", resp.Code, resp.Body.String()) - } - var ver struct { - VersionNumber int `json:"version_number"` - Snapshot json.RawMessage `json:"snapshot"` - } - json.Unmarshal(resp.Body.Bytes(), &ver) - if ver.VersionNumber != 2 { - t.Errorf("published version: got %d, want 2", ver.VersionNumber) - } - if len(ver.Snapshot) == 0 { - t.Error("snapshot is empty") - } - - // ── Duplicate publish should conflict ─── - - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) - if resp.Code != http.StatusConflict { - t.Errorf("Duplicate publish: got %d, want 409", resp.Code) - } - - // ── Get version ───────────────────────── - - resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/versions/2", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Get version: got %d", resp.Code) - } - - // ── Duplicate slug should conflict ────── - - resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Another Intake", - "slug": "customer-intake", - }) - if resp.Code != http.StatusConflict { - t.Errorf("Duplicate slug: got %d, want 409", resp.Code) - } - - // ── Auto-slug from name ───────────────── - - resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Bug Report Triage", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Auto-slug: got %d, body: %s", resp.Code, resp.Body.String()) - } - var wf2 struct{ Slug string `json:"slug"` } - json.Unmarshal(resp.Body.Bytes(), &wf2) - if wf2.Slug != "bug-report-triage" { - t.Errorf("auto-slug: got %q, want %q", wf2.Slug, "bug-report-triage") - } - - // ── Delete stage ──────────────────────── - - resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID+"/stages/"+stage1.ID, h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Delete stage: got %d", resp.Code) - } - - // ── Delete workflow (cascades) ────────── - - resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Delete workflow: got %d", resp.Code) - } - - // Confirm gone - resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) - if resp.Code != http.StatusNotFound { - t.Errorf("After delete: got %d, want 404", resp.Code) - } -} - -// TestWorkflowValidation tests input validation on workflow endpoints. -func TestWorkflowValidation(t *testing.T) { - h := setupWorkflowHarness(t) - - // Missing name - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "slug": "test", - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Missing name: got %d, want 400", resp.Code) - } - - // Invalid slug - resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Test", - "slug": "A", - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Short slug: got %d, want 400", resp.Code) - } - - // Invalid entry_mode - resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Test", - "entry_mode": "invalid", - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Bad entry_mode: got %d, want 400", resp.Code) - } - - // Invalid history_mode on stage - resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "For Stage Test", - }) - var stageWf struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &stageWf) - - resp = h.request("POST", "/api/v1/workflows/"+stageWf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Bad Mode", - "history_mode": "invalid", - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Bad history_mode: got %d, want 400", resp.Code) - } -} - -// ── Workflow Test Harness ─────────────────── - -type workflowHarness struct { - *testHarness - adminToken string - adminID string -} - -func setupWorkflowHarness(t *testing.T) *workflowHarness { - 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) - } - userCache := middleware.NewUserStatusCache() - - r := gin.New() - api := r.Group("/api/v1") - - // Auth (for token generation) - auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) - api.POST("/auth/login", auth.Login) - api.POST("/auth/register", auth.Register) - - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - // Workflow CRUD - wfH := NewWorkflowHandler(stores) - protected.GET("/workflows", wfH.List) - protected.POST("/workflows", wfH.Create) - protected.GET("/workflows/:id", wfH.Get) - protected.PATCH("/workflows/:id", wfH.Update) - protected.DELETE("/workflows/:id", wfH.Delete) - protected.GET("/workflows/:id/stages", wfH.ListStages) - protected.POST("/workflows/:id/stages", wfH.CreateStage) - protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage) - protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage) - protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages) - protected.POST("/workflows/:id/publish", wfH.Publish) - protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) - - // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil) - protected.POST("/workflows/:id/start", wfInstH.Start) - protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) - protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) - protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) - - // Channels (needed for workflow instance creation) - channels := NewChannelHandler(stores) - protected.GET("/channels", channels.ListChannels) - protected.POST("/channels", channels.CreateChannel) - protected.GET("/channels/:id", channels.GetChannel) - - // Seed an admin user (handle + auth_source required since v0.24.0) - adminID := seedInsertReturningID(t, - `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - "wf-admin", "wf-admin@test.com", "$2a$10$test", "admin", "wf-admin", "builtin", - ) - - token := makeToken(adminID, "wf-admin@test.com", "admin") - - return &workflowHarness{ - testHarness: &testHarness{router: r, t: t}, - adminToken: token, - adminID: adminID, - } -} diff --git a/server/main.go b/server/main.go index 89b9841..275b627 100644 --- a/server/main.go +++ b/server/main.go @@ -690,7 +690,7 @@ func main() { middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage(), }, - Session: middleware.AuthOrSession(cfg, stores, userCache), + Session: middleware.AuthOrRedirect(cfg, stores.Users, userCache), // TODO: session middleware for workflow visitors (v0.2.0) }) // v0.29.1: Extension API routes — Starlark packages serve JSON endpoints. diff --git a/server/models/models.go b/server/models/models.go index 0af7906..23142f4 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -23,6 +23,24 @@ const ( ScopePersonal = "personal" ) +// ── Role Constants ────────────────────────── + +const ( + UserRoleUser = "user" + UserRoleAdmin = "admin" + + TeamRoleAdmin = "admin" + TeamRoleMember = "member" +) + +// ── Extension Tier Constants ──────────────── + +const ( + ExtTierBrowser = "browser" + ExtTierStarlark = "starlark" + ExtTierSidecar = "sidecar" +) + type User struct { BaseModel Username string `json:"username" db:"username"` @@ -67,22 +85,6 @@ type TeamMember struct { UserRole string `json:"user_role,omitempty"` } -// PROVIDER CONFIGS (replaces APIConfig) - -// HasKey returns true if an encrypted API key is stored. -func (p *ProviderConfig) HasKey() bool { - return len(p.APIKeyEnc) > 0 -} - -// MODEL CATALOG (replaces model_configs) - -func (c ModelCapabilities) HasProviderData() bool { - return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning || - c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0 -} - -// PERSONAS (replaces ModelPreset) - // ========================================= // GRANTS // ========================================= diff --git a/server/notifications/sources_test.go b/server/notifications/sources_test.go index 1f7329f..f007e95 100644 --- a/server/notifications/sources_test.go +++ b/server/notifications/sources_test.go @@ -127,48 +127,6 @@ func TestNotifyGroupMemberRemoved(t *testing.T) { } } -// ── NotifyMemoryExtracted ───────────────── - -func TestNotifyMemoryExtracted(t *testing.T) { - svc, ms := newTestService() - - NotifyMemoryExtracted(svc, "user1", "chan1", 5) - - if len(ms.items) != 1 { - t.Fatalf("expected 1 notification, got %d", len(ms.items)) - } - n := ms.items[0] - if n.Type != models.NotifTypeMemoryExtracted { - t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeMemoryExtracted) - } - if n.Title != "5 new memories extracted" { - t.Errorf("title: got %q", n.Title) - } - if n.ResourceType != models.ResourceTypeChannel { - t.Errorf("resource_type: got %q", n.ResourceType) - } - if n.ResourceID != "chan1" { - t.Errorf("resource_id: got %q", n.ResourceID) - } -} - -func TestNotifyMemoryExtracted_Singular(t *testing.T) { - svc, ms := newTestService() - - NotifyMemoryExtracted(svc, "user1", "chan1", 1) - - if len(ms.items) != 1 { - t.Fatalf("expected 1 notification, got %d", len(ms.items)) - } - if ms.items[0].Title != "1 new memory extracted" { - t.Errorf("singular title: got %q", ms.items[0].Title) - } -} - -func TestNotifyMemoryExtracted_NilService(t *testing.T) { - NotifyMemoryExtracted(nil, "user1", "chan1", 3) // should not panic -} - // ── NotifyUserMentioned ─────────────────── func TestNotifyUserMentioned(t *testing.T) { diff --git a/server/pages/loaders.go b/server/pages/loaders.go index d8c5006..0cdce61 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -2,7 +2,6 @@ package pages import ( "context" - "fmt" "log" "github.com/gin-gonic/gin" @@ -19,57 +18,13 @@ type TeamOption struct { IsActive bool `json:"is_active"` } -// ProviderTypeOption for the provider type dropdown. -type ProviderTypeOption struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// UserRow is a user for the admin users table. -type UserRow struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - IsActive bool `json:"is_active"` -} - -// RoleConfig holds a role's current settings. -type RoleConfig struct { - Name string `json:"name"` - Primary *RoleSelection `json:"primary"` - Fallback *RoleSelection `json:"fallback"` -} - -// RoleSelection is provider + model pair for a role slot. -type RoleSelection struct { - ProviderID string `json:"provider_config_id"` - ModelID string `json:"model_id"` -} - // ── Page data structs ──────────────────────── -// -// v0.25.0: Each loader function is a "data provider" keyed by surface ID. -// The surface manifest's DataRequires field references these keys. -// Currently 1:1 (one loader per surface). Future: composite loaders -// that assemble data from multiple providers for dashboard-style surfaces. // AdminPageData is what the admin surface templates receive. type AdminPageData struct { - Section string `json:"section"` - Category string `json:"category"` - ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"` - Teams []TeamOption `json:"teams"` - Users []UserRow `json:"users,omitempty"` - Roles []RoleConfig `json:"roles"` - Policies any `json:"policies,omitempty"` - ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3 -} - -// ChatPageData is what the chat surface templates receive. -type ChatPageData struct { - ChatID string `json:"chat_id,omitempty"` + Section string `json:"section"` + Category string `json:"category"` + ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` } // SettingsPageData is what the settings surface templates receive. @@ -105,43 +60,16 @@ func (e *Engine) ListDataProviders() []string { // ── Admin loader ───────────────────────────── // Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope). -func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) { - ctx := context.Background() +func (e *Engine) adminLoader(c *gin.Context, _ store.Stores) (any, error) { section := c.Param("section") if section == "" { section = "users" } - data := &AdminPageData{ + return &AdminPageData{ Section: section, Category: sectionCategory(section), - } - - rolesMap, ok := raw["roles"].(map[string]any) - if !ok { - rolesMap = raw - } - - for _, name := range roleNames { - rc := RoleConfig{Name: name} - if roleData, ok := rolesMap[name].(map[string]any); ok { - if primary, ok := roleData["primary"].(map[string]any); ok { - rc.Primary = &RoleSelection{ - ProviderID: fmt.Sprintf("%v", primary["provider_config_id"]), - ModelID: fmt.Sprintf("%v", primary["model_id"]), - } - } - if fallback, ok := roleData["fallback"].(map[string]any); ok { - rc.Fallback = &RoleSelection{ - ProviderID: fmt.Sprintf("%v", fallback["provider_config_id"]), - ModelID: fmt.Sprintf("%v", fallback["model_id"]), - } - } - } - roles = append(roles, rc) - } - - return roles + }, nil } // ── Admin helpers ──────────────────────────── @@ -164,39 +92,6 @@ func sectionCategory(section string) string { } } -// loadProviderTypes returns the registered provider type metadata. -func loadProviderTypes() []ProviderTypeOption { - out := make([]ProviderTypeOption, 0, len(types)) - for _, t := range types { - out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name}) - } - return out -} - -// loadUsers returns the user list for the admin users table. -func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow { - if s.Users == nil { - return nil - } - users, _, err := s.Users.List(ctx, store.ListOptions{Limit: 500, Sort: "username", Order: "asc"}) - if err != nil { - log.Printf("[pages/admin] Failed to list users: %v", err) - return nil - } - out := make([]UserRow, 0, len(users)) - for _, u := range users { - out = append(out, UserRow{ - ID: u.ID, - Username: u.Username, - Email: u.Email, - DisplayName: u.DisplayName, - Role: u.Role, - IsActive: u.IsActive, - }) - } - return out -} - // ── Settings loader ────────────────────────── // v0.22.7: Reads feature gates from GlobalConfig to control // which nav links/tabs are visible (BYOK, User Personas). diff --git a/server/pages/pages.go b/server/pages/pages.go index fa332df..3328921 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -730,9 +730,6 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc { // Persona lookup deferred — personas are extensions now } - // Check for existing active session - sbSession, err := c.Cookie("sb_session") - instanceName, _, _ := e.loadBranding() e.Render(c, "workflow-landing.html", PageData{ diff --git a/server/sandbox/provider_module_test.go b/server/sandbox/provider_module_test.go deleted file mode 100644 index 7f6771d..0000000 --- a/server/sandbox/provider_module_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package sandbox - -import ( - "testing" - - "go.starlark.net/starlark" - - "switchboard-core/providers" -) - -// ─── ParseRequiresProvider ────────────────── - -func TestParseRequiresProvider_Missing(t *testing.T) { - _, ok := ParseRequiresProvider(map[string]any{}) - if ok { - t.Error("missing requires_provider should return false") - } -} - -func TestParseRequiresProvider_BoolTrue(t *testing.T) { - cfg, ok := ParseRequiresProvider(map[string]any{"requires_provider": true}) - if !ok { - t.Fatal("boolean true should return ok=true") - } - if cfg.ProviderConfigID != "" || cfg.DefaultModel != "" { - t.Errorf("boolean true should produce empty config, got %+v", cfg) - } -} - -func TestParseRequiresProvider_BoolFalse(t *testing.T) { - _, ok := ParseRequiresProvider(map[string]any{"requires_provider": false}) - if ok { - t.Error("boolean false should return ok=false") - } -} - -func TestParseRequiresProvider_ObjectFull(t *testing.T) { - cfg, ok := ParseRequiresProvider(map[string]any{ - "requires_provider": map[string]any{ - "provider_config_id": "cfg-uuid-123", - "model": "claude-3-haiku", - }, - }) - if !ok { - t.Fatal("object form should return ok=true") - } - if cfg.ProviderConfigID != "cfg-uuid-123" { - t.Errorf("provider_config_id = %q", cfg.ProviderConfigID) - } - if cfg.DefaultModel != "claude-3-haiku" { - t.Errorf("model = %q", cfg.DefaultModel) - } -} - -func TestParseRequiresProvider_ObjectPartial(t *testing.T) { - cfg, ok := ParseRequiresProvider(map[string]any{ - "requires_provider": map[string]any{ - "model": "gpt-4o-mini", - }, - }) - if !ok { - t.Fatal("should return ok=true") - } - if cfg.ProviderConfigID != "" { - t.Errorf("provider_config_id should be empty, got %q", cfg.ProviderConfigID) - } - if cfg.DefaultModel != "gpt-4o-mini" { - t.Errorf("model = %q", cfg.DefaultModel) - } -} - -func TestParseRequiresProvider_InvalidType(t *testing.T) { - _, ok := ParseRequiresProvider(map[string]any{"requires_provider": "yes"}) - if ok { - t.Error("string value should return false") - } -} - -// ─── starlarkToMessages ───────────────────── - -func TestStarlarkToMessages_Valid(t *testing.T) { - list := starlark.NewList(nil) - - msg1 := starlark.NewDict(2) - _ = msg1.SetKey(starlark.String("role"), starlark.String("system")) - _ = msg1.SetKey(starlark.String("content"), starlark.String("You are helpful.")) - list.Append(msg1) - - msg2 := starlark.NewDict(2) - _ = msg2.SetKey(starlark.String("role"), starlark.String("user")) - _ = msg2.SetKey(starlark.String("content"), starlark.String("Hello")) - list.Append(msg2) - - msgs, err := starlarkToMessages(list) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(msgs) != 2 { - t.Fatalf("expected 2 messages, got %d", len(msgs)) - } - if msgs[0].Role != "system" || msgs[0].Content != "You are helpful." { - t.Errorf("msg[0] = %+v", msgs[0]) - } - if msgs[1].Role != "user" || msgs[1].Content != "Hello" { - t.Errorf("msg[1] = %+v", msgs[1]) - } -} - -func TestStarlarkToMessages_Empty(t *testing.T) { - list := starlark.NewList(nil) - msgs, err := starlarkToMessages(list) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(msgs) != 0 { - t.Errorf("expected 0 messages, got %d", len(msgs)) - } -} - -func TestStarlarkToMessages_Nil(t *testing.T) { - msgs, err := starlarkToMessages(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if msgs != nil { - t.Errorf("expected nil, got %v", msgs) - } -} - -func TestStarlarkToMessages_MissingRole(t *testing.T) { - list := starlark.NewList(nil) - msg := starlark.NewDict(1) - _ = msg.SetKey(starlark.String("content"), starlark.String("hello")) - list.Append(msg) - - _, err := starlarkToMessages(list) - if err == nil { - t.Error("expected error for missing role") - } -} - -func TestStarlarkToMessages_MissingContent(t *testing.T) { - list := starlark.NewList(nil) - msg := starlark.NewDict(1) - _ = msg.SetKey(starlark.String("role"), starlark.String("user")) - list.Append(msg) - - _, err := starlarkToMessages(list) - if err == nil { - t.Error("expected error for missing content") - } -} - -func TestStarlarkToMessages_NonDictItem(t *testing.T) { - list := starlark.NewList([]starlark.Value{starlark.String("not a dict")}) - - _, err := starlarkToMessages(list) - if err == nil { - t.Error("expected error for non-dict item") - } -} - -func TestStarlarkToMessages_NonStringRole(t *testing.T) { - list := starlark.NewList(nil) - msg := starlark.NewDict(2) - _ = msg.SetKey(starlark.String("role"), starlark.MakeInt(42)) - _ = msg.SetKey(starlark.String("content"), starlark.String("hello")) - list.Append(msg) - - _, err := starlarkToMessages(list) - if err == nil { - t.Error("expected error for non-string role") - } -} - -// ─── completionToStarlark ─────────────────── - -func TestCompletionToStarlark(t *testing.T) { - resp := &providers.CompletionResponse{ - Content: "Hello! How can I help?", - Model: "claude-3-haiku-20240307", - FinishReason: "stop", - InputTokens: 15, - OutputTokens: 8, - } - - val, err := completionToStarlark(resp) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - d, ok := val.(*starlark.Dict) - if !ok { - t.Fatalf("expected dict, got %T", val) - } - - // content - cv, found, _ := d.Get(starlark.String("content")) - if !found { - t.Fatal("missing 'content'") - } - if s, _ := starlark.AsString(cv); s != "Hello! How can I help?" { - t.Errorf("content = %q", s) - } - - // model - mv, found, _ := d.Get(starlark.String("model")) - if !found { - t.Fatal("missing 'model'") - } - if s, _ := starlark.AsString(mv); s != "claude-3-haiku-20240307" { - t.Errorf("model = %q", s) - } - - // finish_reason - fv, found, _ := d.Get(starlark.String("finish_reason")) - if !found { - t.Fatal("missing 'finish_reason'") - } - if s, _ := starlark.AsString(fv); s != "stop" { - t.Errorf("finish_reason = %q", s) - } - - // input_tokens - iv, found, _ := d.Get(starlark.String("input_tokens")) - if !found { - t.Fatal("missing 'input_tokens'") - } - if i, err := starlark.AsInt32(iv); err != nil || i != 15 { - t.Errorf("input_tokens = %v (err=%v)", i, err) - } - - // output_tokens - ov, found, _ := d.Get(starlark.String("output_tokens")) - if !found { - t.Fatal("missing 'output_tokens'") - } - if i, err := starlark.AsInt32(ov); err != nil || i != 8 { - t.Errorf("output_tokens = %v (err=%v)", i, err) - } -} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 5e5adf4..8bca61a 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -308,9 +308,6 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m httpCfg.AllowPrivateIPs = r.allowPrivateIPs modules["http"] = BuildHTTPModule(ctx, httpCfg) - } - } - case models.ExtPermDBRead: if dbLevel < 1 { dbLevel = 1 diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go index 2fd4458..a2d4817 100644 --- a/server/sandbox/workflow_module.go +++ b/server/sandbox/workflow_module.go @@ -13,14 +13,12 @@ package sandbox import ( "context" - "encoding/json" "fmt" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" "switchboard-core/store" - "switchboard-core/workflow" ) // BuildWorkflowModule creates the "workflow" Starlark module for a package. diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 52004d9..a34dbec 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -2,7 +2,6 @@ package store import ( "context" - "encoding/json" "errors" "time" @@ -24,11 +23,21 @@ var ErrSystemGroup = errors.New("system groups cannot be deleted") // ========================================= // Stores bundles all store interfaces for dependency injection. +// PolicyStore provides platform policy flags (allow_registration, etc.) +type PolicyStore interface { + GetBool(ctx context.Context, key string) (bool, error) + SetBool(ctx context.Context, key string, val bool) error + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key, value string) error + GetAll(ctx context.Context) (map[string]string, error) +} + type Stores struct { Users UserStore Teams TeamStore Audit AuditStore GlobalConfig GlobalConfigStore + Policies PolicyStore Groups GroupStore ResourceGrants ResourceGrantStore Notifications NotificationStore @@ -73,19 +82,6 @@ type UserStore interface { RevokeAllRefreshTokens(ctx context.Context, userID string) error CleanExpiredTokens(ctx context.Context) error - // ── Mention resolution (v0.29.0 — moved from handler raw SQL) ── - - // FindActiveByHandle returns a user ID by exact handle match (case-insensitive). - // excludeUserID prevents self-mention resolution. - FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) - - // FindActiveByHandlePrefix returns a user ID by unambiguous handle prefix. - // Returns ("", count, nil) if ambiguous or no match. - FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) - - // GetDisplayInfoByIDs returns name + avatar for batch sender resolution. - GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error) - // ── CS1 additions (v0.29.0) ── // Exists returns true if a user with the given ID exists and is active. diff --git a/server/store/postgres/policies.go b/server/store/postgres/policies.go new file mode 100644 index 0000000..ff9897f --- /dev/null +++ b/server/store/postgres/policies.go @@ -0,0 +1,60 @@ +package postgres + +import ( + "context" + "database/sql" +) + +type PolicyStore struct{ db *sql.DB } + +func NewPolicyStore(db *sql.DB) *PolicyStore { return &PolicyStore{db: db} } + +func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) { + var val string + err := s.db.QueryRowContext(ctx, `SELECT value FROM platform_policies WHERE key = $1`, key).Scan(&val) + if err != nil { + return false, err + } + return val == "true", nil +} + +func (s *PolicyStore) SetBool(ctx context.Context, key string, val bool) error { + v := "false" + if val { + v = "true" + } + return s.Set(ctx, key, v) +} + +func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) { + var val string + err := s.db.QueryRowContext(ctx, `SELECT value FROM platform_policies WHERE key = $1`, key).Scan(&val) + if err != nil { + return "", err + } + return val, nil +} + +func (s *PolicyStore) Set(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO platform_policies (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2`, key, value) + return err +} + +func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM platform_policies`) + if err != nil { + return nil, err + } + defer rows.Close() + m := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + m[k] = v + } + return m, rows.Err() +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 95a0f53..59d553a 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -15,12 +15,13 @@ func NewStores(db *sql.DB) store.Stores { Teams: NewTeamStore(), Audit: NewAuditStore(), GlobalConfig: NewGlobalConfigStore(), + Policies: NewPolicyStore(db), Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), Presence: NewPresenceStore(), - Health: NewHealthStore(db), + Health: NewHealthStore(), Connections: NewConnectionStore(), Dependencies: NewDependencyStore(), Packages: NewPackageStore(), diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index daceb51..85a49a7 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -2,13 +2,10 @@ package postgres import ( "context" - "database/sql" "encoding/json" "fmt" - "time" "switchboard-core/models" - "switchboard-core/store" ) // WorkflowStore implements store.WorkflowStore for Postgres. diff --git a/server/store/sqlite/policies.go b/server/store/sqlite/policies.go new file mode 100644 index 0000000..7dfa27f --- /dev/null +++ b/server/store/sqlite/policies.go @@ -0,0 +1,60 @@ +package sqlite + +import ( + "context" + "database/sql" +) + +type PolicyStore struct{ db *sql.DB } + +func NewPolicyStore(db *sql.DB) *PolicyStore { return &PolicyStore{db: db} } + +func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) { + var val string + err := s.db.QueryRowContext(ctx, `SELECT value FROM platform_policies WHERE key = ?`, key).Scan(&val) + if err != nil { + return false, err + } + return val == "true", nil +} + +func (s *PolicyStore) SetBool(ctx context.Context, key string, val bool) error { + v := "false" + if val { + v = "true" + } + return s.Set(ctx, key, v) +} + +func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) { + var val string + err := s.db.QueryRowContext(ctx, `SELECT value FROM platform_policies WHERE key = ?`, key).Scan(&val) + if err != nil { + return "", err + } + return val, nil +} + +func (s *PolicyStore) Set(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO platform_policies (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value) + return err +} + +func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM platform_policies`) + if err != nil { + return nil, err + } + defer rows.Close() + m := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + m[k] = v + } + return m, rows.Err() +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 2ed140a..630dbd2 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -15,6 +15,7 @@ func NewStores(db *sql.DB) store.Stores { Teams: NewTeamStore(), Audit: NewAuditStore(), GlobalConfig: NewGlobalConfigStore(), + Policies: NewPolicyStore(db), Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), Notifications: NewNotificationStore(), diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index adc0130..9ec61b5 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -350,6 +350,13 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in return result, rows.Err() } +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + func jsonOrEmpty(b json.RawMessage) string { if len(b) == 0 { return "{}"