First implementation step for the cross-platform settings contract (#376). Adds the artifact everything else depends on: the manifest, its JSON Schema, the object value schemas, and a Go loader that validates the whole thing at load time. No routes, no storage, no behavior change — nothing reads this yet. contracts/settings/v1/ holds the artifact at a stable path because clients vendor it and generate bindings from it. The embed directive has to sit beside it (go:embed cannot reach outside its own directory), so that directory is a tiny Go package containing nothing else; loading and validation live in internal/settingscontract. 38 definitions: 35 remote, 3 contract-known client_local. That covers every key the legacy registry accepts, every unregistered key the extension bag was silently accepting from the web client, every unregistered device key Android writes, and the profile preference columns that become settings. Registering the previously-unregistered keys is where the drift shows up, and the manifest records each case in a notes field: - ui_theme, ui_text_scale, ui_text_weight, ui_high_contrast, ui_custom_theme_vars, and ui_custom_css reached the server only because keyUsesUserScope returns true for any unregistered key. They are now typed, renamed to the dotted convention every other key uses, and moved to profile scope per the design. - player.match_frame_rate and player.sleep_timer_default_minutes are written by Android against a server that does not register them, so every write and reset is currently rejected. Registered. - player.next_up_prompt_seconds is Android's alias for playback.next_up_prompt_seconds and does not become a definition; the test matrix pins it as a migration alias. - player.playback_speed is capped at 3.0, matching the server rather than Android's 4.0. - subtitle_appearance becomes playback.subtitle_appearance. Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. Validation is deliberately stricter than the schema can express. Beyond shape, it enforces that a resolution order ends in "default", that it only resolves scopes the definition allows, and — the one most likely to bite — that every writable scope is actually read, so a setting cannot accept writes at a scope it will never honor. Defaults are validated against their own value schema, so a default that violates its own range or enum fails at load. Revision tags are checked to never run ahead of the manifest revision, which is what makes revision-aware client filtering trustworthy. Ceiling and floor policy constraints are rejected on unordered types, where capping would silently do nothing; playback.preferred_quality's enum is therefore ordered ascending. ValidateValue is the single validation path, so the mutation endpoint, the migration, and the manifest's own default checks cannot diverge later. Numbers decode through json.Number so an integer setting rejects 30.5 rather than truncating, and object values validate against their referenced JSON Schema instead of accepting arbitrary JSON the way validateJSONSetting does today. Canonicalization implements RFC 8785 over the value domain the contract uses: sorted keys, no insignificant whitespace, ECMAScript number formatting. The digest is the ETag, and PublicBytes strips maintainer notes so the served manifest never carries internal commentary. Promotes santhosh-tekuri/jsonschema/v6 from indirect to direct. Verification: 124 tests pass across 16 cases; golangci-lint clean; make verify-local-paths passes. Two failures in internal/api/handlers (TestRemoveJellyfinCompatWebDisablesWebSetting, the playback v3 seek recovery test) reproduce unchanged on main and are unrelated. Part of #376. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
293 lines
9.7 KiB
Go
293 lines
9.7 KiB
Go
// Package settingscontract loads and validates the canonical cross-platform
|
|
// user settings manifest.
|
|
//
|
|
// The manifest in contracts/settings/v1 is the single source of truth for every
|
|
// production, user-facing setting: its key, value type, constraints, storage
|
|
// scopes, resolution order, default, and UX copy. The server embeds it, clients
|
|
// vendor a pinned copy and generate bindings from it, and no production setting
|
|
// may exist without an entry here.
|
|
//
|
|
// See docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md.
|
|
package settingscontract
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// Scope identifies the storage identity a value is attached to.
|
|
type Scope string
|
|
|
|
const (
|
|
ScopeAccount Scope = "account"
|
|
ScopeProfile Scope = "profile"
|
|
ScopeProfileDevice Scope = "profile_device"
|
|
ScopeProfileLibrary Scope = "profile_library"
|
|
ScopeProfileSeries Scope = "profile_series"
|
|
// ScopeClientLocal is not addressable by the server values API. It exists so
|
|
// client_local definitions can express a resolution order in the same shape
|
|
// as remote ones.
|
|
ScopeClientLocal Scope = "client_local"
|
|
// ScopeDefault terminates every resolution order.
|
|
ScopeDefault Scope = "default"
|
|
)
|
|
|
|
// remoteScopes are the scopes the server stores and authorizes.
|
|
var remoteScopes = map[Scope]struct{}{
|
|
ScopeAccount: {},
|
|
ScopeProfile: {},
|
|
ScopeProfileDevice: {},
|
|
ScopeProfileLibrary: {},
|
|
ScopeProfileSeries: {},
|
|
}
|
|
|
|
// IsRemote reports whether the scope is stored and authorized by the server.
|
|
func (s Scope) IsRemote() bool {
|
|
_, ok := remoteScopes[s]
|
|
return ok
|
|
}
|
|
|
|
// Persistence declares who stores a setting's value.
|
|
type Persistence string
|
|
|
|
const (
|
|
// PersistenceRemote values are stored by the server and roam per their scope.
|
|
PersistenceRemote Persistence = "remote"
|
|
// PersistenceClientLocal values are defined by the contract but stored only
|
|
// by the client. They are never sent to the API.
|
|
PersistenceClientLocal Persistence = "client_local"
|
|
)
|
|
|
|
// ValueType tags a value schema.
|
|
type ValueType string
|
|
|
|
const (
|
|
TypeBoolean ValueType = "boolean"
|
|
TypeInteger ValueType = "integer"
|
|
TypeNumber ValueType = "number"
|
|
TypeString ValueType = "string"
|
|
TypeEnum ValueType = "enum"
|
|
TypeLanguageTag ValueType = "language_tag"
|
|
TypeObject ValueType = "object"
|
|
)
|
|
|
|
// ConstraintKind describes how a policy input narrows a setting.
|
|
type ConstraintKind string
|
|
|
|
const (
|
|
// ConstraintCeiling caps the value at the policy input. Valid only on an
|
|
// ordered enum or a numeric type.
|
|
ConstraintCeiling ConstraintKind = "ceiling"
|
|
// ConstraintFloor raises the value to the policy input. Same restriction.
|
|
ConstraintFloor ConstraintKind = "floor"
|
|
// ConstraintAllowlist restricts the value to a policy-supplied set.
|
|
ConstraintAllowlist ConstraintKind = "allowlist"
|
|
// ConstraintLocked prevents the user from authoring the setting at all.
|
|
ConstraintLocked ConstraintKind = "locked"
|
|
)
|
|
|
|
// Manifest is the canonical settings contract.
|
|
type Manifest struct {
|
|
// APIVersion identifies the settings protocol. It changes only for a change
|
|
// no revision rule can express.
|
|
APIVersion int `json:"api_version"`
|
|
// Revision is bumped by every manifest PR and is what clients filter
|
|
// definitions, scopes, enum members, and bounds against.
|
|
Revision int `json:"revision"`
|
|
// Definitions is ordered as authored; use Lookup for access by key.
|
|
Definitions []Definition `json:"definitions"`
|
|
|
|
byKey map[string]*Definition
|
|
}
|
|
|
|
// Definition is the canonical contract for one setting.
|
|
type Definition struct {
|
|
Key string `json:"key"`
|
|
IntroducedIn int `json:"introduced_in"`
|
|
Persistence Persistence `json:"persistence"`
|
|
AllowedScopes []ScopeEntry `json:"allowed_scopes"`
|
|
ResolutionOrder []Scope `json:"resolution_order"`
|
|
ValueSchema ValueSchema `json:"value_schema"`
|
|
DefaultValue json.RawMessage `json:"default_value"`
|
|
ConstrainedBy *Constraint `json:"constrained_by,omitempty"`
|
|
Platforms []string `json:"platforms,omitempty"`
|
|
Category string `json:"category"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
Unit string `json:"unit,omitempty"`
|
|
Control string `json:"recommended_control,omitempty"`
|
|
Deprecated bool `json:"deprecated,omitempty"`
|
|
// Notes is maintainer commentary. It is stripped from the public manifest.
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// ScopeEntry is a scope, optionally tagged with the revision that added it to
|
|
// its definition. It unmarshals from either a bare string or an object so the
|
|
// common case stays readable in the manifest.
|
|
type ScopeEntry struct {
|
|
Scope Scope `json:"scope"`
|
|
IntroducedIn int `json:"introduced_in,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON accepts "profile" or {"scope":"profile","introduced_in":14}.
|
|
func (s *ScopeEntry) UnmarshalJSON(data []byte) error {
|
|
var bare string
|
|
if err := json.Unmarshal(data, &bare); err == nil {
|
|
s.Scope = Scope(bare)
|
|
s.IntroducedIn = 0
|
|
return nil
|
|
}
|
|
var tagged struct {
|
|
Scope Scope `json:"scope"`
|
|
IntroducedIn int `json:"introduced_in"`
|
|
}
|
|
if err := json.Unmarshal(data, &tagged); err != nil {
|
|
return fmt.Errorf("scope entry must be a string or an object: %w", err)
|
|
}
|
|
s.Scope = tagged.Scope
|
|
s.IntroducedIn = tagged.IntroducedIn
|
|
return nil
|
|
}
|
|
|
|
// MarshalJSON emits the bare string form when no revision tag is present, so
|
|
// round-tripping the manifest does not rewrite unrelated entries.
|
|
func (s ScopeEntry) MarshalJSON() ([]byte, error) {
|
|
if s.IntroducedIn == 0 {
|
|
return json.Marshal(string(s.Scope))
|
|
}
|
|
return json.Marshal(struct {
|
|
Scope Scope `json:"scope"`
|
|
IntroducedIn int `json:"introduced_in"`
|
|
}{s.Scope, s.IntroducedIn})
|
|
}
|
|
|
|
// ValueSchema is the tagged type system for setting values. Only the fields
|
|
// belonging to Type are meaningful; Validate enforces that.
|
|
type ValueSchema struct {
|
|
Type ValueType `json:"type"`
|
|
Nullable bool `json:"nullable,omitempty"`
|
|
|
|
// Numeric (integer, number).
|
|
Minimum *float64 `json:"minimum,omitempty"`
|
|
Maximum *float64 `json:"maximum,omitempty"`
|
|
Step *float64 `json:"step,omitempty"`
|
|
// Revision tags for additively widened bounds.
|
|
MinimumIntroducedIn int `json:"minimum_introduced_in,omitempty"`
|
|
MaximumIntroducedIn int `json:"maximum_introduced_in,omitempty"`
|
|
|
|
// String.
|
|
MinLength *int `json:"min_length,omitempty"`
|
|
MaxLength *int `json:"max_length,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
|
|
// Enum.
|
|
Values []EnumMember `json:"values,omitempty"`
|
|
Ordered bool `json:"ordered,omitempty"`
|
|
|
|
// Object.
|
|
SchemaRef string `json:"schema_ref,omitempty"`
|
|
}
|
|
|
|
// EnumMember is one allowed enum value. Members are objects rather than bare
|
|
// strings so a member added after the definition can carry its own revision.
|
|
type EnumMember struct {
|
|
Value any `json:"value"`
|
|
Label string `json:"label,omitempty"`
|
|
IntroducedIn int `json:"introduced_in,omitempty"`
|
|
Deprecated bool `json:"deprecated,omitempty"`
|
|
}
|
|
|
|
// Constraint binds a definition to a policy input that narrows it at
|
|
// resolution time. A constraint filters what a preference does; it never
|
|
// validates what a preference is, so a mutation that exceeds a restriction is
|
|
// still stored.
|
|
type Constraint struct {
|
|
PolicyInput string `json:"policy_input"`
|
|
Constraint ConstraintKind `json:"constraint"`
|
|
}
|
|
|
|
// Lookup returns the definition for key.
|
|
func (m *Manifest) Lookup(key string) (*Definition, bool) {
|
|
if m == nil || m.byKey == nil {
|
|
return nil, false
|
|
}
|
|
def, ok := m.byKey[key]
|
|
return def, ok
|
|
}
|
|
|
|
// Keys returns every definition key in manifest order.
|
|
func (m *Manifest) Keys() []string {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(m.Definitions))
|
|
for i := range m.Definitions {
|
|
keys = append(keys, m.Definitions[i].Key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// index builds the by-key lookup. It reports duplicate keys as an error rather
|
|
// than letting the last one silently win.
|
|
func (m *Manifest) index() error {
|
|
m.byKey = make(map[string]*Definition, len(m.Definitions))
|
|
for i := range m.Definitions {
|
|
def := &m.Definitions[i]
|
|
if _, exists := m.byKey[def.Key]; exists {
|
|
return fmt.Errorf("duplicate setting key %q", def.Key)
|
|
}
|
|
m.byKey[def.Key] = def
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AllowsScope reports whether the definition may store a value at scope.
|
|
func (d *Definition) AllowsScope(scope Scope) bool {
|
|
for _, entry := range d.AllowedScopes {
|
|
if entry.Scope == scope {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsRemote reports whether the server stores this setting's values.
|
|
func (d *Definition) IsRemote() bool {
|
|
return d.Persistence == PersistenceRemote
|
|
}
|
|
|
|
// VisibleAtRevision reports whether a client pinned to revision may surface this
|
|
// definition. A client hides definitions the connected server does not know.
|
|
func (d *Definition) VisibleAtRevision(revision int) bool {
|
|
return d.IntroducedIn <= revision
|
|
}
|
|
|
|
// ScopesAtRevision returns the scopes valid against a peer at revision.
|
|
func (d *Definition) ScopesAtRevision(revision int) []Scope {
|
|
scopes := make([]Scope, 0, len(d.AllowedScopes))
|
|
for _, entry := range d.AllowedScopes {
|
|
if entry.IntroducedIn > revision {
|
|
continue
|
|
}
|
|
scopes = append(scopes, entry.Scope)
|
|
}
|
|
return scopes
|
|
}
|
|
|
|
// EnumValuesAtRevision returns the enum members valid against a peer at
|
|
// revision. Clients render from this so a newer client never offers a choice an
|
|
// older server will reject.
|
|
func (d *Definition) EnumValuesAtRevision(revision int) []EnumMember {
|
|
if d.ValueSchema.Type != TypeEnum {
|
|
return nil
|
|
}
|
|
members := make([]EnumMember, 0, len(d.ValueSchema.Values))
|
|
for _, member := range d.ValueSchema.Values {
|
|
if member.IntroducedIn > revision {
|
|
continue
|
|
}
|
|
members = append(members, member)
|
|
}
|
|
return members
|
|
}
|