Feat settings cascade with user_overridable flag
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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{
|
||||
|
||||
122
server/handlers/team_package_settings.go
Normal file
122
server/handlers/team_package_settings.go
Normal file
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user