Feat settings cascade (#3)
Some checks failed
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Failing after 2m24s
CI/CD / test-sqlite (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Has been skipped

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:
2026-03-26 18:03:44 +00:00
committed by xcaliber
parent a830ae3684
commit a36be83aaf
15 changed files with 690 additions and 49 deletions

View File

@@ -15,6 +15,14 @@ All notable changes to Switchboard Core are documented here.
- `SeedAdminsGroupMember()`, `SeedEveryoneGroupMember()` test helpers - `SeedAdminsGroupMember()`, `SeedEveryoneGroupMember()` test helpers
- System groups re-seeded after `TruncateAll` in test helper - System groups re-seeded after `TruncateAll` in test helper
- OIDC `isIdPAdmin()` — maps IdP role claims to Admins group membership - 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 ### Changed
@@ -35,6 +43,8 @@ All notable changes to Switchboard Core are documented here.
- OIDC syncs Admins group on login (no role column writes) - OIDC syncs Admins group on login (no role column writes)
- Kernel permissions: 6 → 7 (added `surface.admin.access`) - Kernel permissions: 6 → 7 (added `surface.admin.access`)
- Admin users UI: role dropdown removed, admin managed through groups - 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 ### Removed

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap # 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 Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
features removed from the kernel. What remains is the minimum viable 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 - **Realtime**: WebSocket hub, presence, multi-replica HA
- **Ops**: audit log, notifications, maintenance goroutine - **Ops**: audit log, notifications, maintenance goroutine
### Phase 0 (in progress) ### Phase 0 (complete)
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
@@ -31,32 +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) | | 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 | | 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, The contract that extensions build against. Three trigger primitives,
SDK stabilization, and the first rebuilt extension (tasks). SDK stabilization, and the first rebuilt extension (tasks).
### v0.2.0 — RBAC + Settings Cascade (complete)
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. | | 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 |
| ~~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 | | 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) | | 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 | | SDK stabilization | 🔲 | `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`, theme tokens, primitive UI |
| Task extension | 🔲 | First proof-of-concept — full task system rebuilt as Starlark extension using triggers + ext_data + notifications |
### 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**: 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.
- **Default surface routing**: `/` redirects to configurable default surface.
No surfaces installed → admin surface is default. First surface installed
becomes default. Changeable in admin settings.
## v0.3.0 — Editor Surface ## v0.3.0 — Editor Surface

View File

@@ -1 +1 @@
0.1.0 0.2.0

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" "github.com/gin-gonic/gin"
"strings"
"switchboard-core/database" "switchboard-core/database"
"switchboard-core/models" "switchboard-core/models"
"switchboard-core/store" "switchboard-core/store"
@@ -91,7 +93,27 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
settings := json.RawMessage("{}") settings := json.RawMessage("{}")
if body.Settings != nil { 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{ 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.POST("/packages/install", teamPkgH.InstallTeamPackage)
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage) 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) // Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog) teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)

View File

@@ -46,6 +46,10 @@ type RunContext struct {
// ChannelID is the context identifier, if any. // ChannelID is the context identifier, if any.
ChannelID string 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. // 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. // 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 := "" userID := ""
teamID := ""
if rc != nil { if rc != nil {
userID = rc.UserID 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. // v0.38.2: lib module — always injected, no permission required.
// Allows any starlark package to load declared library dependencies. // Allows any starlark package to load declared library dependencies.

View File

@@ -1,10 +1,12 @@
package sandbox 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 // The settings module lets extensions read their resolved settings via
// per-user settings. Always injected (no permission required — a package // the three-tier cascade: global → team → user, respecting the
// reads its own settings). // user_overridable flag per setting key.
//
// Always injected (no permission required — a package reads its own settings).
// //
// Starlark API: // Starlark API:
// val = settings.get("key") # returns string, number, bool, or None // val = settings.get("key") # returns string, number, bool, or None
@@ -12,7 +14,6 @@ package sandbox
import ( import (
"context" "context"
"encoding/json"
"go.starlark.net/starlark" "go.starlark.net/starlark"
"go.starlark.net/starlarkstruct" "go.starlark.net/starlarkstruct"
@@ -21,16 +22,15 @@ import (
) )
// BuildSettingsModule creates the "settings" Starlark module for a package. // BuildSettingsModule creates the "settings" Starlark module for a package.
// It reads from package_settings (admin-configured) merged with per-user // It resolves the three-tier cascade (global → team → user) respecting
// settings when a userID is provided. Package-level settings are the base; // the user_overridable flag from the package manifest.
// user-level settings override. func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID, userID, teamID string) *starlarkstruct.Module {
func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID string, userID string) *starlarkstruct.Module {
return MakeModule("settings", starlark.StringDict{ 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) { return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var key string var key string
var defaultVal starlark.Value = starlark.None var defaultVal starlark.Value = starlark.None
@@ -38,28 +38,44 @@ func settingsGet(ctx context.Context, stores store.Stores, packageID string, use
return nil, err return nil, err
} }
// Load package-level settings if stores.Packages == nil {
merged := make(map[string]any) return defaultVal, nil
if stores.Packages != nil { }
pkgSettings, err := stores.Packages.GetPackageSettings(ctx, packageID)
if err == nil && len(pkgSettings) > 0 { // Load global (admin) settings
json.Unmarshal([]byte(pkgSettings), &merged) 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 // Load user settings if user context is available
if userID != "" && stores.Packages != nil { var userSettings []byte
userSettings, err := stores.Packages.GetUserSettings(ctx, packageID, userID) if userID != "" {
if err == nil && userSettings != nil && len(userSettings.Settings) > 0 { pus, err := stores.Packages.GetUserSettings(ctx, packageID, userID)
var userMap map[string]any if err == nil && pus != nil {
if json.Unmarshal([]byte(userSettings.Settings), &userMap) == nil { userSettings = pus.Settings
for k, v := range userMap {
merged[k] = v
}
}
} }
} }
// 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] val, ok := merged[key]
if !ok { if !ok {
return defaultVal, nil return defaultVal, nil

View File

@@ -83,6 +83,17 @@ type PackageStore interface {
// SetPackageSettings stores admin-configured package-level settings. // SetPackageSettings stores admin-configured package-level settings.
SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error 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. // PackageRegistration is a row from the packages table.
@@ -123,3 +134,11 @@ type PackageUserSettings struct {
Settings json.RawMessage `json:"settings" db:"settings"` Settings json.RawMessage `json:"settings" db:"settings"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"` 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 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. // nullStrPtr converts *string to sql.NullString for nullable FK columns.
func nullStrPtr(s *string) sql.NullString { func nullStrPtr(s *string) sql.NullString {
if s == nil { 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 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 { func nullStrPtr(s *string) sql.NullString {
if s == nil { if s == nil {
return sql.NullString{} return sql.NullString{}