From 45448698d60d4e58d22d0cfebfd3a5e8e3f39ffe Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 17:57:14 +0000 Subject: [PATCH 1/3] Feat settings cascade with user_overridable flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-tier settings resolution (global → team → user) with per-key user_overridable flag in package manifests. Admins can lock settings that team admins and users cannot override. - Add package_team_settings table (migration 010) - Add GetTeamSettings/SetTeamSettings/DeleteTeamSettings to PackageStore - Add ResolveSettings, ParseSettingsSchema, FilterOverridableKeys - Rewrite Starlark settings.get() to use cascade resolver - Add TeamID to RunContext for team-aware resolution - Enforce user_overridable on user settings save - Add team package settings API (GET/PUT/DELETE) - 12 unit tests for cascade resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 10 + .../postgres/010_settings_cascade.sql | 13 ++ .../sqlite/010_settings_cascade.sql | 13 ++ server/handlers/extensions.go | 24 ++- server/handlers/team_package_settings.go | 122 +++++++++++ server/main.go | 6 + server/sandbox/runner.go | 10 +- server/sandbox/settings_module.go | 70 +++--- server/store/package_iface.go | 19 ++ server/store/postgres/packages.go | 30 +++ server/store/settings_cascade.go | 147 +++++++++++++ server/store/settings_cascade_test.go | 200 ++++++++++++++++++ server/store/sqlite/packages.go | 30 +++ 13 files changed, 664 insertions(+), 30 deletions(-) create mode 100644 server/database/migrations/postgres/010_settings_cascade.sql create mode 100644 server/database/migrations/sqlite/010_settings_cascade.sql create mode 100644 server/handlers/team_package_settings.go create mode 100644 server/store/settings_cascade.go create mode 100644 server/store/settings_cascade_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b74f8..35a778e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ All notable changes to Switchboard Core are documented here. - `SeedAdminsGroupMember()`, `SeedEveryoneGroupMember()` test helpers - System groups re-seeded after `TruncateAll` in test helper - OIDC `isIdPAdmin()` — maps IdP role claims to Admins group membership +- **Settings cascade**: three-tier resolution (global → team → user) with + `user_overridable` flag per manifest setting key. Admins can lock settings + that team admins and users cannot override. +- `package_team_settings` table for team-scoped package setting overrides +- Team admin API: `GET/PUT/DELETE /api/v1/teams/:teamId/packages/:pkgId/settings` +- `RunContext.TeamID` for team-aware Starlark settings resolution +- `store.ResolveSettings()` / `store.FilterOverridableKeys()` pure functions +- `store.ParseSettingsSchema()` extracts `user_overridable` from manifests ### Changed @@ -35,6 +43,8 @@ All notable changes to Switchboard Core are documented here. - OIDC syncs Admins group on login (no role column writes) - Kernel permissions: 6 → 7 (added `surface.admin.access`) - Admin users UI: role dropdown removed, admin managed through groups +- Starlark `settings.get()` uses cascade resolver instead of naive merge +- User settings save (`POST /extensions/:id/settings`) strips non-overridable keys ### Removed diff --git a/server/database/migrations/postgres/010_settings_cascade.sql b/server/database/migrations/postgres/010_settings_cascade.sql new file mode 100644 index 0000000..cc93432 --- /dev/null +++ b/server/database/migrations/postgres/010_settings_cascade.sql @@ -0,0 +1,13 @@ +-- 010_settings_cascade.sql — v0.2.0 +-- Adds team-scoped package settings for the three-tier cascade: +-- global (package_settings) → team (package_team_settings) → user (package_user_settings) + +CREATE TABLE IF NOT EXISTS package_team_settings ( + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + settings JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (package_id, team_id) +); + +CREATE INDEX IF NOT EXISTS idx_pkg_team_settings_team ON package_team_settings(team_id); diff --git a/server/database/migrations/sqlite/010_settings_cascade.sql b/server/database/migrations/sqlite/010_settings_cascade.sql new file mode 100644 index 0000000..186f09e --- /dev/null +++ b/server/database/migrations/sqlite/010_settings_cascade.sql @@ -0,0 +1,13 @@ +-- 010_settings_cascade.sql — v0.2.0 +-- Adds team-scoped package settings for the three-tier cascade: +-- global (package_settings) → team (package_team_settings) → user (package_user_settings) + +CREATE TABLE IF NOT EXISTS package_team_settings ( + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + settings TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (package_id, team_id) +); + +CREATE INDEX IF NOT EXISTS idx_pkg_team_settings_team ON package_team_settings(team_id); diff --git a/server/handlers/extensions.go b/server/handlers/extensions.go index 4cecdba..3c32dce 100644 --- a/server/handlers/extensions.go +++ b/server/handlers/extensions.go @@ -7,6 +7,8 @@ import ( "github.com/gin-gonic/gin" + "strings" + "switchboard-core/database" "switchboard-core/models" "switchboard-core/store" @@ -91,7 +93,27 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) { settings := json.RawMessage("{}") if body.Settings != nil { - settings = body.Settings + // v0.2.0: enforce user_overridable — strip locked keys before saving + schema := store.ParseSettingsSchema(pkg.Manifest) + if len(schema) > 0 { + var incoming map[string]any + if json.Unmarshal(body.Settings, &incoming) == nil && len(incoming) > 0 { + filtered, rejected := store.FilterOverridableKeys(incoming, schema) + if len(filtered) == 0 && len(rejected) > 0 { + c.JSON(400, gin.H{"error": "these settings cannot be overridden: " + strings.Join(rejected, ", ")}) + return + } + if b, err := json.Marshal(filtered); err == nil { + settings = json.RawMessage(b) + } else { + settings = body.Settings + } + } else { + settings = body.Settings + } + } else { + settings = body.Settings + } } pus := &store.PackageUserSettings{ diff --git a/server/handlers/team_package_settings.go b/server/handlers/team_package_settings.go new file mode 100644 index 0000000..cfccd09 --- /dev/null +++ b/server/handlers/team_package_settings.go @@ -0,0 +1,122 @@ +package handlers + +// team_package_settings.go — v0.2.0 +// +// Team-admin endpoints for managing team-scoped package settings. +// These sit in the settings cascade between global (admin) and user. +// Only overridable keys (user_overridable != false) can be set at team scope. + +import ( + "encoding/json" + "strings" + + "github.com/gin-gonic/gin" + + "switchboard-core/store" +) + +// TeamPackageSettingsHandler serves team-scoped package settings endpoints. +type TeamPackageSettingsHandler struct { + stores store.Stores +} + +func NewTeamPackageSettingsHandler(s store.Stores) *TeamPackageSettingsHandler { + return &TeamPackageSettingsHandler{stores: s} +} + +// GetTeamPackageSettings returns the team-scoped settings for a package, +// along with the overridable schema keys for the UI. +// GET /api/v1/teams/:teamId/packages/:pkgId/settings +func (h *TeamPackageSettingsHandler) GetTeamPackageSettings(c *gin.Context) { + teamID := c.Param("teamId") + pkgID := c.Param("pkgId") + + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil || pkg == nil { + c.JSON(404, gin.H{"error": "package not found"}) + return + } + + // Parse schema and filter to overridable keys only + schema := store.ParseSettingsSchema(pkg.Manifest) + var overridableSchema []store.SettingSchema + for _, s := range schema { + if s.IsUserOverridable() { + overridableSchema = append(overridableSchema, s) + } + } + + // Load current team values + settings, err := h.stores.Packages.GetTeamSettings(c.Request.Context(), pkgID, teamID) + if err != nil { + settings = json.RawMessage("{}") + } + + c.JSON(200, gin.H{ + "schema": overridableSchema, + "values": json.RawMessage(settings), + }) +} + +// UpdateTeamPackageSettings saves team-scoped overrides for a package. +// Non-overridable keys are stripped before saving. +// PUT /api/v1/teams/:teamId/packages/:pkgId/settings +func (h *TeamPackageSettingsHandler) UpdateTeamPackageSettings(c *gin.Context) { + teamID := c.Param("teamId") + pkgID := c.Param("pkgId") + + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil || pkg == nil { + c.JSON(404, gin.H{"error": "package not found"}) + return + } + + var body struct { + Settings json.RawMessage `json:"settings" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(400, gin.H{"error": "invalid request body"}) + return + } + + // Parse and enforce user_overridable + schema := store.ParseSettingsSchema(pkg.Manifest) + var incoming map[string]any + if err := json.Unmarshal(body.Settings, &incoming); err != nil { + c.JSON(400, gin.H{"error": "settings must be a JSON object"}) + return + } + + filtered, rejected := store.FilterOverridableKeys(incoming, schema) + if len(filtered) == 0 && len(rejected) > 0 { + c.JSON(400, gin.H{"error": "these settings cannot be overridden: " + strings.Join(rejected, ", ")}) + return + } + + settingsJSON, err := json.Marshal(filtered) + if err != nil { + c.JSON(500, gin.H{"error": "failed to marshal settings"}) + return + } + + if err := h.stores.Packages.SetTeamSettings(c.Request.Context(), pkgID, teamID, json.RawMessage(settingsJSON)); err != nil { + c.JSON(500, gin.H{"error": "failed to save team settings"}) + return + } + + c.JSON(200, gin.H{"ok": true, "rejected_keys": rejected}) +} + +// DeleteTeamPackageSettings removes team-scoped overrides for a package. +// DELETE /api/v1/teams/:teamId/packages/:pkgId/settings +func (h *TeamPackageSettingsHandler) DeleteTeamPackageSettings(c *gin.Context) { + teamID := c.Param("teamId") + pkgID := c.Param("pkgId") + + if err := h.stores.Packages.DeleteTeamSettings(c.Request.Context(), pkgID, teamID); err != nil { + c.JSON(500, gin.H{"error": "failed to delete team settings"}) + return + } + + c.JSON(200, gin.H{"ok": true}) +} diff --git a/server/main.go b/server/main.go index 220b1e7..a21621a 100644 --- a/server/main.go +++ b/server/main.go @@ -476,6 +476,12 @@ func main() { teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage) teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage) + // Team package settings — cascade overrides (v0.2.0) + teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores) + teamScoped.GET("/packages/:pkgId/settings", teamPkgSettingsH.GetTeamPackageSettings) + teamScoped.PUT("/packages/:pkgId/settings", teamPkgSettingsH.UpdateTeamPackageSettings) + teamScoped.DELETE("/packages/:pkgId/settings", teamPkgSettingsH.DeleteTeamPackageSettings) + // Team audit log (team admins only — RequireTeamAdmin on group) teamScoped.GET("/audit", teams.ListTeamAuditLog) teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 8bca61a..d7ad0c8 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -46,6 +46,10 @@ type RunContext struct { // ChannelID is the context identifier, if any. ChannelID string + + // TeamID is the team context for settings cascade resolution (v0.2.0). + // When set, team-scoped package settings are included in the cascade. + TeamID string } // Runner executes Starlark package scripts with permission-gated modules. @@ -337,12 +341,14 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m } // v0.30.0: settings module — always injected, no permission required. - // A package reads its own admin + user settings. + // A package reads its own admin + team + user settings (v0.2.0 cascade). userID := "" + teamID := "" if rc != nil { userID = rc.UserID + teamID = rc.TeamID } - modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID) + modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID, teamID) // v0.38.2: lib module — always injected, no permission required. // Allows any starlark package to load declared library dependencies. diff --git a/server/sandbox/settings_module.go b/server/sandbox/settings_module.go index e485593..fe3c970 100644 --- a/server/sandbox/settings_module.go +++ b/server/sandbox/settings_module.go @@ -1,10 +1,12 @@ package sandbox -// settings_module.go — v0.30.0 CS2 +// settings_module.go — v0.2.0 // -// The settings module lets extensions read their admin-configured and -// per-user settings. Always injected (no permission required — a package -// reads its own settings). +// The settings module lets extensions read their resolved settings via +// the three-tier cascade: global → team → user, respecting the +// user_overridable flag per setting key. +// +// Always injected (no permission required — a package reads its own settings). // // Starlark API: // val = settings.get("key") # returns string, number, bool, or None @@ -12,7 +14,6 @@ package sandbox import ( "context" - "encoding/json" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" @@ -21,16 +22,15 @@ import ( ) // BuildSettingsModule creates the "settings" Starlark module for a package. -// It reads from package_settings (admin-configured) merged with per-user -// settings when a userID is provided. Package-level settings are the base; -// user-level settings override. -func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID string, userID string) *starlarkstruct.Module { +// It resolves the three-tier cascade (global → team → user) respecting +// the user_overridable flag from the package manifest. +func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID, userID, teamID string) *starlarkstruct.Module { return MakeModule("settings", starlark.StringDict{ - "get": starlark.NewBuiltin("settings.get", settingsGet(ctx, stores, packageID, userID)), + "get": starlark.NewBuiltin("settings.get", settingsGet(ctx, stores, packageID, userID, teamID)), }) } -func settingsGet(ctx context.Context, stores store.Stores, packageID string, userID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { +func settingsGet(ctx context.Context, stores store.Stores, packageID, userID, teamID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key string var defaultVal starlark.Value = starlark.None @@ -38,28 +38,44 @@ func settingsGet(ctx context.Context, stores store.Stores, packageID string, use return nil, err } - // Load package-level settings - merged := make(map[string]any) - if stores.Packages != nil { - pkgSettings, err := stores.Packages.GetPackageSettings(ctx, packageID) - if err == nil && len(pkgSettings) > 0 { - json.Unmarshal([]byte(pkgSettings), &merged) + if stores.Packages == nil { + return defaultVal, nil + } + + // Load global (admin) settings + globalSettings, err := stores.Packages.GetPackageSettings(ctx, packageID) + if err != nil { + globalSettings = nil + } + + // Load team settings if team context is available + var teamSettings []byte + if teamID != "" { + ts, err := stores.Packages.GetTeamSettings(ctx, packageID, teamID) + if err == nil { + teamSettings = ts } } - // Overlay user-level settings if a user context is available - if userID != "" && stores.Packages != nil { - userSettings, err := stores.Packages.GetUserSettings(ctx, packageID, userID) - if err == nil && userSettings != nil && len(userSettings.Settings) > 0 { - var userMap map[string]any - if json.Unmarshal([]byte(userSettings.Settings), &userMap) == nil { - for k, v := range userMap { - merged[k] = v - } - } + // Load user settings if user context is available + var userSettings []byte + if userID != "" { + pus, err := stores.Packages.GetUserSettings(ctx, packageID, userID) + if err == nil && pus != nil { + userSettings = pus.Settings } } + // Parse schema from manifest for user_overridable flags + var schema []store.SettingSchema + pkg, err := stores.Packages.Get(ctx, packageID) + if err == nil && pkg != nil && pkg.Manifest != nil { + schema = store.ParseSettingsSchema(pkg.Manifest) + } + + // Resolve cascade + merged := store.ResolveSettings(globalSettings, teamSettings, userSettings, schema) + val, ok := merged[key] if !ok { return defaultVal, nil diff --git a/server/store/package_iface.go b/server/store/package_iface.go index bc7f744..361ee2b 100644 --- a/server/store/package_iface.go +++ b/server/store/package_iface.go @@ -83,6 +83,17 @@ type PackageStore interface { // SetPackageSettings stores admin-configured package-level settings. SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error + + // ── Team-level settings (v0.2.0) ───────────────── + + // GetTeamSettings returns team-scoped overrides for a package. + GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error) + + // SetTeamSettings upserts team-scoped overrides for a package. + SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error + + // DeleteTeamSettings removes team-scoped overrides, reverting to global defaults. + DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error } // PackageRegistration is a row from the packages table. @@ -123,3 +134,11 @@ type PackageUserSettings struct { Settings json.RawMessage `json:"settings" db:"settings"` IsEnabled bool `json:"is_enabled" db:"is_enabled"` } + +// PackageTeamSettings stores team-scoped overrides for a package (v0.2.0). +type PackageTeamSettings struct { + PackageID string `json:"package_id" db:"package_id"` + TeamID string `json:"team_id" db:"team_id"` + Settings json.RawMessage `json:"settings" db:"settings"` + UpdatedAt string `json:"updated_at" db:"updated_at"` +} diff --git a/server/store/postgres/packages.go b/server/store/postgres/packages.go index 0eee3fd..008f56f 100644 --- a/server/store/postgres/packages.go +++ b/server/store/postgres/packages.go @@ -367,6 +367,36 @@ func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settin return nil } +// ── Team-level settings (v0.2.0) ───────────────── + +func (s *PackageStore) GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error) { + var settings []byte + err := DB.QueryRowContext(ctx, + `SELECT settings FROM package_team_settings WHERE package_id = $1 AND team_id = $2`, + pkgID, teamID).Scan(&settings) + if err != nil { + return nil, err + } + return json.RawMessage(settings), nil +} + +func (s *PackageStore) SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO package_team_settings (package_id, team_id, settings, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (package_id, team_id) + DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW()`, + pkgID, teamID, []byte(settings)) + return err +} + +func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM package_team_settings WHERE package_id = $1 AND team_id = $2`, + pkgID, teamID) + return err +} + // nullStrPtr converts *string to sql.NullString for nullable FK columns. func nullStrPtr(s *string) sql.NullString { if s == nil { diff --git a/server/store/settings_cascade.go b/server/store/settings_cascade.go new file mode 100644 index 0000000..077b46f --- /dev/null +++ b/server/store/settings_cascade.go @@ -0,0 +1,147 @@ +package store + +// settings_cascade.go — v0.2.0 +// +// Pure functions for three-tier settings resolution: +// global (admin) → team → user +// +// Two orthogonal axes: +// - RBAC controls who can write at each scope +// - user_overridable (per setting key) controls whether lower scopes +// can override higher scope values +// +// The kernel enforces the cascade without knowing setting semantics. + +import "encoding/json" + +// SettingSchema represents one entry from the manifest "settings" array. +type SettingSchema struct { + Key string `json:"key"` + UserOverridable *bool `json:"user_overridable,omitempty"` +} + +// IsUserOverridable returns whether a setting key can be overridden at +// lower scopes (team, user). Defaults to true when the field is absent +// for backward compatibility with existing manifests. +func (s SettingSchema) IsUserOverridable() bool { + return s.UserOverridable == nil || *s.UserOverridable +} + +// ParseSettingsSchema extracts []SettingSchema from a package manifest. +func ParseSettingsSchema(manifest map[string]any) []SettingSchema { + arr, ok := manifest["settings"].([]any) + if !ok { + return nil + } + result := make([]SettingSchema, 0, len(arr)) + for _, item := range arr { + m, ok := item.(map[string]any) + if !ok { + continue + } + s := SettingSchema{} + s.Key, _ = m["key"].(string) + if s.Key == "" { + continue + } + if uo, ok := m["user_overridable"].(bool); ok { + s.UserOverridable = &uo + } + result = append(result, s) + } + return result +} + +// ResolveSettings implements the settings cascade: +// +// user override → team override → global default +// +// For each key declared in the schema with user_overridable: false, +// team and user values are ignored — only the global value is used. +// Keys not declared in schema default to overridable. +// +// Parameters: +// - globalSettings: admin-set values from packages.package_settings +// - teamSettings: team-scoped overrides (nil if no team context) +// - userSettings: per-user overrides (nil if no user context) +// - schema: parsed manifest settings array +func ResolveSettings( + globalSettings json.RawMessage, + teamSettings json.RawMessage, + userSettings json.RawMessage, + schema []SettingSchema, +) map[string]any { + global := jsonToMap(globalSettings) + team := jsonToMap(teamSettings) + user := jsonToMap(userSettings) + + // Build overridable lookup from schema + overridable := make(map[string]bool, len(schema)) + for _, s := range schema { + overridable[s.Key] = s.IsUserOverridable() + } + + // Start with global as base + merged := make(map[string]any, len(global)) + for k, v := range global { + merged[k] = v + } + + // Overlay team values (respecting user_overridable) + for k, v := range team { + canOverride, declared := overridable[k] + if !declared { + canOverride = true // unknown keys default to overridable + } + if canOverride { + merged[k] = v + } + } + + // Overlay user values (respecting user_overridable) + for k, v := range user { + canOverride, declared := overridable[k] + if !declared { + canOverride = true + } + if canOverride { + merged[k] = v + } + } + + return merged +} + +// FilterOverridableKeys strips non-overridable keys from a settings map. +// Used by handlers to enforce user_overridable before saving. +// Returns the filtered map and the list of rejected keys. +func FilterOverridableKeys(settings map[string]any, schema []SettingSchema) (map[string]any, []string) { + overridable := make(map[string]bool, len(schema)) + for _, s := range schema { + overridable[s.Key] = s.IsUserOverridable() + } + + filtered := make(map[string]any, len(settings)) + var rejected []string + for k, v := range settings { + canOverride, declared := overridable[k] + if !declared { + canOverride = true + } + if canOverride { + filtered[k] = v + } else { + rejected = append(rejected, k) + } + } + return filtered, rejected +} + +func jsonToMap(raw json.RawMessage) map[string]any { + if len(raw) == 0 { + return nil + } + var m map[string]any + json.Unmarshal(raw, &m) + return m +} diff --git a/server/store/settings_cascade_test.go b/server/store/settings_cascade_test.go new file mode 100644 index 0000000..5f0d85d --- /dev/null +++ b/server/store/settings_cascade_test.go @@ -0,0 +1,200 @@ +package store + +import ( + "encoding/json" + "testing" +) + +func boolPtr(b bool) *bool { return &b } + +func TestResolveSettings_GlobalOnly(t *testing.T) { + global := json.RawMessage(`{"font_size": 14, "theme": "dark"}`) + schema := []SettingSchema{ + {Key: "font_size", UserOverridable: boolPtr(true)}, + {Key: "theme", UserOverridable: boolPtr(true)}, + } + + merged := ResolveSettings(global, nil, nil, schema) + + if merged["font_size"] != float64(14) { + t.Errorf("font_size: got %v, want 14", merged["font_size"]) + } + if merged["theme"] != "dark" { + t.Errorf("theme: got %v, want dark", merged["theme"]) + } +} + +func TestResolveSettings_UserOverridesGlobal(t *testing.T) { + global := json.RawMessage(`{"font_size": 14, "theme": "dark"}`) + user := json.RawMessage(`{"font_size": 18}`) + schema := []SettingSchema{ + {Key: "font_size", UserOverridable: boolPtr(true)}, + {Key: "theme", UserOverridable: boolPtr(true)}, + } + + merged := ResolveSettings(global, nil, user, schema) + + if merged["font_size"] != float64(18) { + t.Errorf("font_size: got %v, want 18", merged["font_size"]) + } + if merged["theme"] != "dark" { + t.Errorf("theme: got %v, want dark", merged["theme"]) + } +} + +func TestResolveSettings_LockedKeyBlocksUserAndTeam(t *testing.T) { + global := json.RawMessage(`{"api_url": "https://prod.example.com", "font_size": 14}`) + team := json.RawMessage(`{"api_url": "https://team.example.com", "font_size": 16}`) + user := json.RawMessage(`{"api_url": "https://user.example.com", "font_size": 20}`) + schema := []SettingSchema{ + {Key: "api_url", UserOverridable: boolPtr(false)}, + {Key: "font_size", UserOverridable: boolPtr(true)}, + } + + merged := ResolveSettings(global, team, user, schema) + + if merged["api_url"] != "https://prod.example.com" { + t.Errorf("api_url: got %v, want https://prod.example.com (locked)", merged["api_url"]) + } + if merged["font_size"] != float64(20) { + t.Errorf("font_size: got %v, want 20 (user wins)", merged["font_size"]) + } +} + +func TestResolveSettings_TeamOverridesGlobal(t *testing.T) { + global := json.RawMessage(`{"font_size": 14}`) + team := json.RawMessage(`{"font_size": 16}`) + schema := []SettingSchema{ + {Key: "font_size", UserOverridable: boolPtr(true)}, + } + + merged := ResolveSettings(global, team, nil, schema) + + if merged["font_size"] != float64(16) { + t.Errorf("font_size: got %v, want 16", merged["font_size"]) + } +} + +func TestResolveSettings_UserOverridesTeam(t *testing.T) { + global := json.RawMessage(`{"font_size": 14}`) + team := json.RawMessage(`{"font_size": 16}`) + user := json.RawMessage(`{"font_size": 20}`) + schema := []SettingSchema{ + {Key: "font_size", UserOverridable: boolPtr(true)}, + } + + merged := ResolveSettings(global, team, user, schema) + + if merged["font_size"] != float64(20) { + t.Errorf("font_size: got %v, want 20", merged["font_size"]) + } +} + +func TestResolveSettings_AbsentFlagDefaultsTrue(t *testing.T) { + global := json.RawMessage(`{"font_size": 14}`) + user := json.RawMessage(`{"font_size": 20}`) + // UserOverridable is nil — should default to true + schema := []SettingSchema{ + {Key: "font_size"}, + } + + merged := ResolveSettings(global, nil, user, schema) + + if merged["font_size"] != float64(20) { + t.Errorf("font_size: got %v, want 20 (nil flag = overridable)", merged["font_size"]) + } +} + +func TestResolveSettings_UnknownKeyDefaultsOverridable(t *testing.T) { + global := json.RawMessage(`{"known": "a", "unknown": "b"}`) + user := json.RawMessage(`{"unknown": "c"}`) + schema := []SettingSchema{ + {Key: "known", UserOverridable: boolPtr(false)}, + // "unknown" is not in schema + } + + merged := ResolveSettings(global, nil, user, schema) + + if merged["unknown"] != "c" { + t.Errorf("unknown: got %v, want c (undeclared keys are overridable)", merged["unknown"]) + } +} + +func TestResolveSettings_NilLayers(t *testing.T) { + merged := ResolveSettings(nil, nil, nil, nil) + if merged == nil { + t.Error("expected non-nil empty map") + } + if len(merged) != 0 { + t.Errorf("expected empty map, got %v", merged) + } +} + +func TestResolveSettings_EmptyJSON(t *testing.T) { + merged := ResolveSettings(json.RawMessage(`{}`), json.RawMessage(`{}`), json.RawMessage(`{}`), nil) + if len(merged) != 0 { + t.Errorf("expected empty map, got %v", merged) + } +} + +func TestParseSettingsSchema(t *testing.T) { + manifest := map[string]any{ + "settings": []any{ + map[string]any{"key": "font_size", "type": "number", "default": 14, "user_overridable": true}, + map[string]any{"key": "api_url", "type": "string", "user_overridable": false}, + map[string]any{"key": "theme", "type": "string"}, + map[string]any{"no_key": true}, // should be skipped + }, + } + + schema := ParseSettingsSchema(manifest) + + if len(schema) != 3 { + t.Fatalf("expected 3 entries, got %d", len(schema)) + } + + if schema[0].Key != "font_size" || !schema[0].IsUserOverridable() { + t.Errorf("font_size: expected overridable=true, got %v", schema[0].UserOverridable) + } + if schema[1].Key != "api_url" || schema[1].IsUserOverridable() { + t.Errorf("api_url: expected overridable=false, got %v", schema[1].UserOverridable) + } + if schema[2].Key != "theme" || !schema[2].IsUserOverridable() { + t.Errorf("theme: nil flag should default to overridable=true") + } +} + +func TestParseSettingsSchema_NoSettings(t *testing.T) { + schema := ParseSettingsSchema(map[string]any{}) + if schema != nil { + t.Errorf("expected nil, got %v", schema) + } +} + +func TestFilterOverridableKeys(t *testing.T) { + settings := map[string]any{ + "font_size": 20, + "api_url": "https://hack.example.com", + "theme": "light", + } + schema := []SettingSchema{ + {Key: "font_size", UserOverridable: boolPtr(true)}, + {Key: "api_url", UserOverridable: boolPtr(false)}, + {Key: "theme"}, // nil = overridable + } + + filtered, rejected := FilterOverridableKeys(settings, schema) + + if _, ok := filtered["api_url"]; ok { + t.Error("api_url should have been stripped") + } + if filtered["font_size"] != 20 { + t.Errorf("font_size: got %v, want 20", filtered["font_size"]) + } + if filtered["theme"] != "light" { + t.Errorf("theme: got %v, want light", filtered["theme"]) + } + if len(rejected) != 1 || rejected[0] != "api_url" { + t.Errorf("rejected: got %v, want [api_url]", rejected) + } +} diff --git a/server/store/sqlite/packages.go b/server/store/sqlite/packages.go index 7787751..cbe0ae0 100644 --- a/server/store/sqlite/packages.go +++ b/server/store/sqlite/packages.go @@ -388,6 +388,36 @@ func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settin return nil } +// ── Team-level settings (v0.2.0) ───────────────── + +func (s *PackageStore) GetTeamSettings(ctx context.Context, pkgID, teamID string) (json.RawMessage, error) { + var settings string + err := DB.QueryRowContext(ctx, + `SELECT settings FROM package_team_settings WHERE package_id = ? AND team_id = ?`, + pkgID, teamID).Scan(&settings) + if err != nil { + return nil, err + } + return json.RawMessage(settings), nil +} + +func (s *PackageStore) SetTeamSettings(ctx context.Context, pkgID, teamID string, settings json.RawMessage) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO package_team_settings (package_id, team_id, settings, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT (package_id, team_id) + DO UPDATE SET settings = EXCLUDED.settings, updated_at = datetime('now')`, + pkgID, teamID, string(settings)) + return err +} + +func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM package_team_settings WHERE package_id = ? AND team_id = ?`, + pkgID, teamID) + return err +} + func nullStrPtr(s *string) sql.NullString { if s == nil { return sql.NullString{} -- 2.49.1 From 75a66dc6b9fa8df847d41911cb8f3b2de3ace2a6 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 17:58:20 +0000 Subject: [PATCH 2/3] Update VERSION to 0.2.0 and mark settings cascade complete in roadmap Co-Authored-By: Claude Opus 4.6 (1M context) --- ROADMAP.md | 12 ++++-------- VERSION | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d2d7341..ad7e4e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Switchboard Core — Roadmap -## Current: v0.1.0 — The Kernel +## Current: v0.2.0 — SDK & Triggers Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat features removed from the kernel. What remains is the minimum viable @@ -16,7 +16,7 @@ platform that extensions build on. - **Realtime**: WebSocket hub, presence, multi-replica HA - **Ops**: audit log, notifications, maintenance goroutine -### Phase 0 (in progress) +### Phase 0 (complete) | Step | Status | Description | |------|--------|-------------| @@ -39,7 +39,7 @@ SDK stabilization, and the first rebuilt extension (tasks). | Step | Status | Description | |------|--------|-------------| | Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. | -| Settings cascade | 🔲 | `user_overridable` flag, RBAC scope auth, resolution chain | +| Settings cascade | ✅ | `user_overridable` flag, three-tier resolution (global → team → user), team settings API | | ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs | | Trigger system | 🔲 | Time (cron), webhook (inbound HTTP), event (bus subscription) | | SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI | @@ -49,11 +49,7 @@ SDK stabilization, and the first rebuilt extension (tasks). - **Event bus subscriptions**: extensions register match expressions at install time - **Task extension**: first proof-of-concept — full task system rebuilt as a Starlark extension using triggers + ext_data + notifications -- **Settings override model**: RBAC controls scope auth (admin → global, - team-admin → team, user → personal). `user_overridable` flag on each setting - controls whether lower scopes can override higher. Resolution order: - user → team → global (first non-null wins, walking up if overridable). - Extension manifests declare `user_overridable` per setting key. +- ~~**Settings override model**~~: ✅ shipped with settings cascade above - **Default surface routing**: `/` redirects to configurable default surface. No surfaces installed → admin surface is default. First surface installed becomes default. Changeable in admin settings. diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 -- 2.49.1 From e2196d89b268bf9a174d703ff98c5c17b9f5598f Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 18:02:28 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Break=20v0.2.0=20roadmap=20into=20subversio?= =?UTF-8?q?ns=20(0.2.1=E2=80=930.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.2.0: RBAC + settings cascade (done) v0.2.1: default surface routing + ICD v0.2.2: event bus + trigger system v0.2.3: SDK stabilization + task extension Co-Authored-By: Claude Opus 4.6 (1M context) --- ROADMAP.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad7e4e5..a46a1dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,28 +31,39 @@ platform that extensions build on. | 9. CI/CD + Dockerfile | ✅ | Single unified image, FE/BE split removed, DB names updated, k8s var alignment fixes (resource quantities, image name, rollout deployment name) | | 10. Smoke test | ✅ | K8s deploy live at switchboard.gobha.ai/test, nginx BASE_PATH fixed, login→admin flow verified, branding updated | -## v0.2.0 — SDK & Triggers +## v0.2.x — SDK & Triggers The contract that extensions build against. Three trigger primitives, SDK stabilization, and the first rebuilt extension (tasks). +### v0.2.0 — RBAC + Settings Cascade (complete) + | Step | Status | Description | |------|--------|-------------| | Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. | | Settings cascade | ✅ | `user_overridable` flag, three-tier resolution (global → team → user), team settings API | +| ~~Settings override model~~ | ✅ | Shipped with settings cascade above | + +### v0.2.1 — Default Surface + ICD + +| Step | Status | Description | +|------|--------|-------------| +| Default surface routing | 🔲 | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. | | ICD (API contract) | 🔲 | OpenAPI spec from registered routes, kernel-only endpoint docs | + +### v0.2.2 — Event Bus + Triggers + +| Step | Status | Description | +|------|--------|-------------| +| Event bus subscriptions | 🔲 | Extensions register match expressions at install time | | Trigger system | 🔲 | Time (cron), webhook (inbound HTTP), event (bus subscription) | + +### v0.2.3 — SDK + Task Extension + +| Step | Status | Description | +|------|--------|-------------| | SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI | - -### Remaining v0.2.0 features - -- **Event bus subscriptions**: extensions register match expressions at install time -- **Task extension**: first proof-of-concept — full task system rebuilt as a - Starlark extension using triggers + ext_data + notifications -- ~~**Settings override model**~~: ✅ shipped with settings cascade above -- **Default surface routing**: `/` redirects to configurable default surface. - No surfaces installed → admin surface is default. First surface installed - becomes default. Changeable in admin settings. +| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications | ## v0.3.0 — Editor Surface -- 2.49.1