Feat settings cascade (#3)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
13
server/database/migrations/postgres/010_settings_cascade.sql
Normal file
13
server/database/migrations/postgres/010_settings_cascade.sql
Normal file
@@ -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);
|
||||
13
server/database/migrations/sqlite/010_settings_cascade.sql
Normal file
13
server/database/migrations/sqlite/010_settings_cascade.sql
Normal file
@@ -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);
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
147
server/store/settings_cascade.go
Normal file
147
server/store/settings_cascade.go
Normal file
@@ -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
|
||||
}
|
||||
200
server/store/settings_cascade_test.go
Normal file
200
server/store/settings_cascade_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user