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:
2026-03-26 17:57:14 +00:00
parent a830ae3684
commit 45448698d6
13 changed files with 664 additions and 30 deletions

View 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);

View 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);

View File

@@ -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{

View 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})
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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 {

View 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
}

View 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)
}
}

View File

@@ -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{}