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>
436 lines
13 KiB
Go
436 lines
13 KiB
Go
package settingscontract
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
)
|
|
|
|
// Validate checks every invariant the manifest schema cannot express: that
|
|
// resolution orders are consistent with allowed scopes, that defaults satisfy
|
|
// their own value schemas, that revision tags are internally ordered, and that
|
|
// policy constraints are applicable to the type they are declared on.
|
|
//
|
|
// A failure here is a defect in the checked-in contract. It is surfaced by the
|
|
// contract tests, and by startup if it somehow ships.
|
|
func (m *Manifest) Validate(objectSchemas map[string]*jsonschema.Schema) error {
|
|
var errs []error
|
|
|
|
if m.APIVersion < 1 {
|
|
errs = append(errs, fmt.Errorf("api_version must be at least 1, got %d", m.APIVersion))
|
|
}
|
|
if m.Revision < 1 {
|
|
errs = append(errs, fmt.Errorf("revision must be at least 1, got %d", m.Revision))
|
|
}
|
|
if len(m.Definitions) == 0 {
|
|
errs = append(errs, errors.New("manifest declares no definitions"))
|
|
}
|
|
|
|
for i := range m.Definitions {
|
|
def := &m.Definitions[i]
|
|
if err := def.validate(m.Revision, objectSchemas); err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", def.Key, err))
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (d *Definition) validate(manifestRevision int, objectSchemas map[string]*jsonschema.Schema) error {
|
|
var errs []error
|
|
|
|
if d.IntroducedIn < 1 || d.IntroducedIn > manifestRevision {
|
|
errs = append(errs, fmt.Errorf(
|
|
"introduced_in %d is outside 1..%d (the manifest revision)", d.IntroducedIn, manifestRevision))
|
|
}
|
|
|
|
errs = append(errs, d.validateScopes(manifestRevision)...)
|
|
errs = append(errs, d.validateResolutionOrder()...)
|
|
errs = append(errs, d.ValueSchema.validate(manifestRevision, objectSchemas)...)
|
|
errs = append(errs, d.validateDefault(objectSchemas)...)
|
|
errs = append(errs, d.validateConstraint()...)
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (d *Definition) validateScopes(manifestRevision int) []error {
|
|
var errs []error
|
|
|
|
if len(d.AllowedScopes) == 0 {
|
|
return []error{errors.New("allowed_scopes is empty")}
|
|
}
|
|
|
|
seen := make(map[Scope]struct{}, len(d.AllowedScopes))
|
|
for _, entry := range d.AllowedScopes {
|
|
if _, dup := seen[entry.Scope]; dup {
|
|
errs = append(errs, fmt.Errorf("allowed_scopes repeats %q", entry.Scope))
|
|
}
|
|
seen[entry.Scope] = struct{}{}
|
|
|
|
if entry.IntroducedIn != 0 {
|
|
if entry.IntroducedIn < d.IntroducedIn {
|
|
errs = append(errs, fmt.Errorf(
|
|
"scope %q claims introduced_in %d, before the definition's own %d",
|
|
entry.Scope, entry.IntroducedIn, d.IntroducedIn))
|
|
}
|
|
if entry.IntroducedIn > manifestRevision {
|
|
errs = append(errs, fmt.Errorf(
|
|
"scope %q claims introduced_in %d, after the manifest revision %d",
|
|
entry.Scope, entry.IntroducedIn, manifestRevision))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persistence and scope have to agree, or a client cannot tell where a value
|
|
// lives from the definition alone.
|
|
switch d.Persistence {
|
|
case PersistenceRemote:
|
|
for _, entry := range d.AllowedScopes {
|
|
if !entry.Scope.IsRemote() {
|
|
errs = append(errs, fmt.Errorf(
|
|
"remote setting allows non-remote scope %q", entry.Scope))
|
|
}
|
|
}
|
|
case PersistenceClientLocal:
|
|
if len(d.AllowedScopes) != 1 || d.AllowedScopes[0].Scope != ScopeClientLocal {
|
|
errs = append(errs, errors.New(
|
|
`client_local setting must declare exactly one scope, "client_local"`))
|
|
}
|
|
if d.ConstrainedBy != nil {
|
|
errs = append(errs, errors.New(
|
|
"client_local setting declares constrained_by, but the server never resolves it"))
|
|
}
|
|
default:
|
|
errs = append(errs, fmt.Errorf("unknown persistence %q", d.Persistence))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (d *Definition) validateResolutionOrder() []error {
|
|
var errs []error
|
|
|
|
order := d.ResolutionOrder
|
|
if len(order) == 0 {
|
|
return []error{errors.New("resolution_order is empty")}
|
|
}
|
|
if order[len(order)-1] != ScopeDefault {
|
|
return []error{fmt.Errorf(
|
|
"resolution_order must end with %q, got %q", ScopeDefault, order[len(order)-1])}
|
|
}
|
|
|
|
seen := make(map[Scope]struct{}, len(order))
|
|
for _, scope := range order[:len(order)-1] {
|
|
if _, dup := seen[scope]; dup {
|
|
errs = append(errs, fmt.Errorf("resolution_order repeats %q", scope))
|
|
}
|
|
seen[scope] = struct{}{}
|
|
|
|
if scope == ScopeDefault {
|
|
errs = append(errs, errors.New(`resolution_order lists "default" before the end`))
|
|
continue
|
|
}
|
|
if !d.AllowsScope(scope) {
|
|
errs = append(errs, fmt.Errorf(
|
|
"resolution_order resolves %q, which is not in allowed_scopes", scope))
|
|
}
|
|
}
|
|
|
|
// Every scope a value can be written at must be reachable when reading it,
|
|
// or the setting accepts writes it will never honor.
|
|
for _, entry := range d.AllowedScopes {
|
|
if _, ok := seen[entry.Scope]; !ok {
|
|
errs = append(errs, fmt.Errorf(
|
|
"scope %q is writable but never read: missing from resolution_order", entry.Scope))
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (v *ValueSchema) validate(manifestRevision int, objectSchemas map[string]*jsonschema.Schema) []error {
|
|
var errs []error
|
|
|
|
switch v.Type {
|
|
case TypeBoolean, TypeLanguageTag:
|
|
// No constraints beyond nullability.
|
|
|
|
case TypeInteger, TypeNumber:
|
|
if v.Minimum == nil || v.Maximum == nil {
|
|
errs = append(errs, fmt.Errorf("%s requires minimum and maximum", v.Type))
|
|
break
|
|
}
|
|
if *v.Minimum > *v.Maximum {
|
|
errs = append(errs, fmt.Errorf(
|
|
"minimum %g exceeds maximum %g", *v.Minimum, *v.Maximum))
|
|
}
|
|
if v.Step != nil && *v.Step <= 0 {
|
|
errs = append(errs, fmt.Errorf("step must be positive, got %g", *v.Step))
|
|
}
|
|
for label, rev := range map[string]int{
|
|
"minimum_introduced_in": v.MinimumIntroducedIn,
|
|
"maximum_introduced_in": v.MaximumIntroducedIn,
|
|
} {
|
|
if rev != 0 && rev > manifestRevision {
|
|
errs = append(errs, fmt.Errorf(
|
|
"%s is %d, after the manifest revision %d", label, rev, manifestRevision))
|
|
}
|
|
}
|
|
|
|
case TypeString:
|
|
if v.MaxLength == nil {
|
|
errs = append(errs, errors.New("string requires max_length"))
|
|
} else if *v.MaxLength < 1 {
|
|
errs = append(errs, fmt.Errorf("max_length must be positive, got %d", *v.MaxLength))
|
|
}
|
|
if v.MinLength != nil && v.MaxLength != nil && *v.MinLength > *v.MaxLength {
|
|
errs = append(errs, fmt.Errorf(
|
|
"min_length %d exceeds max_length %d", *v.MinLength, *v.MaxLength))
|
|
}
|
|
if v.Pattern != "" {
|
|
if _, err := regexp.Compile(v.Pattern); err != nil {
|
|
errs = append(errs, fmt.Errorf("pattern does not compile: %w", err))
|
|
}
|
|
}
|
|
|
|
case TypeEnum:
|
|
if len(v.Values) == 0 {
|
|
errs = append(errs, errors.New("enum requires at least one member"))
|
|
}
|
|
seen := make(map[string]struct{}, len(v.Values))
|
|
for _, member := range v.Values {
|
|
token := fmt.Sprintf("%v", member.Value)
|
|
if _, dup := seen[token]; dup {
|
|
errs = append(errs, fmt.Errorf("enum repeats value %q", token))
|
|
}
|
|
seen[token] = struct{}{}
|
|
if member.IntroducedIn != 0 && member.IntroducedIn > manifestRevision {
|
|
errs = append(errs, fmt.Errorf(
|
|
"enum member %q claims introduced_in %d, after the manifest revision %d",
|
|
token, member.IntroducedIn, manifestRevision))
|
|
}
|
|
}
|
|
|
|
case TypeObject:
|
|
if v.SchemaRef == "" {
|
|
errs = append(errs, errors.New("object requires schema_ref"))
|
|
break
|
|
}
|
|
if _, ok := objectSchemas[v.SchemaRef]; !ok {
|
|
errs = append(errs, fmt.Errorf(
|
|
"schema_ref %q has no file under contracts/settings/v1/schemas", v.SchemaRef))
|
|
}
|
|
|
|
default:
|
|
errs = append(errs, fmt.Errorf("unknown value type %q", v.Type))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (d *Definition) validateDefault(objectSchemas map[string]*jsonschema.Schema) []error {
|
|
raw := bytes.TrimSpace(d.DefaultValue)
|
|
if len(raw) == 0 {
|
|
return []error{errors.New("default_value is required; use null for a nullable setting")}
|
|
}
|
|
|
|
if bytes.Equal(raw, []byte("null")) {
|
|
if !d.ValueSchema.Nullable {
|
|
return []error{errors.New("default_value is null but the value schema is not nullable")}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := d.ValueSchema.ValidateValue(raw, objectSchemas); err != nil {
|
|
return []error{fmt.Errorf("default_value is invalid: %w", err)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Definition) validateConstraint() []error {
|
|
if d.ConstrainedBy == nil {
|
|
return nil
|
|
}
|
|
|
|
var errs []error
|
|
switch d.ConstrainedBy.Constraint {
|
|
case ConstraintCeiling, ConstraintFloor:
|
|
// Capping a value only means something where values are comparable.
|
|
// Declaring a ceiling on an unordered enum silently does nothing, which
|
|
// is worse than refusing it.
|
|
ordered := d.ValueSchema.Type == TypeInteger ||
|
|
d.ValueSchema.Type == TypeNumber ||
|
|
(d.ValueSchema.Type == TypeEnum && d.ValueSchema.Ordered)
|
|
if !ordered {
|
|
errs = append(errs, fmt.Errorf(
|
|
"%s constraint requires a numeric type or an ordered enum, got %s",
|
|
d.ConstrainedBy.Constraint, d.ValueSchema.Type))
|
|
}
|
|
case ConstraintAllowlist, ConstraintLocked:
|
|
// Applicable to any type.
|
|
default:
|
|
errs = append(errs, fmt.Errorf("unknown constraint %q", d.ConstrainedBy.Constraint))
|
|
}
|
|
|
|
if strings.TrimSpace(d.ConstrainedBy.PolicyInput) == "" {
|
|
errs = append(errs, errors.New("constrained_by requires a policy_input"))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// ValidateValue checks a JSON value against this schema. It is the single
|
|
// validation path: the mutation endpoint, the migration, and the manifest's own
|
|
// default checks all use it, so a value that validates in one place validates
|
|
// everywhere.
|
|
func (v *ValueSchema) ValidateValue(raw json.RawMessage, objectSchemas map[string]*jsonschema.Schema) error {
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if bytes.Equal(trimmed, []byte("null")) {
|
|
if v.Nullable {
|
|
return nil
|
|
}
|
|
return errors.New("null is not allowed for this setting")
|
|
}
|
|
|
|
switch v.Type {
|
|
case TypeBoolean:
|
|
var value bool
|
|
if err := strictUnmarshal(trimmed, &value); err != nil {
|
|
return fmt.Errorf("expected a boolean: %w", err)
|
|
}
|
|
|
|
case TypeInteger:
|
|
var value json.Number
|
|
if err := strictUnmarshal(trimmed, &value); err != nil {
|
|
return fmt.Errorf("expected an integer: %w", err)
|
|
}
|
|
parsed, err := value.Int64()
|
|
if err != nil {
|
|
return fmt.Errorf("expected an integer, got %s", value)
|
|
}
|
|
return v.checkRange(float64(parsed))
|
|
|
|
case TypeNumber:
|
|
var value json.Number
|
|
if err := strictUnmarshal(trimmed, &value); err != nil {
|
|
return fmt.Errorf("expected a number: %w", err)
|
|
}
|
|
parsed, err := value.Float64()
|
|
if err != nil {
|
|
return fmt.Errorf("expected a number, got %s", value)
|
|
}
|
|
return v.checkRange(parsed)
|
|
|
|
case TypeString:
|
|
var value string
|
|
if err := strictUnmarshal(trimmed, &value); err != nil {
|
|
return fmt.Errorf("expected a string: %w", err)
|
|
}
|
|
return v.checkString(value)
|
|
|
|
case TypeEnum:
|
|
var value any
|
|
if err := strictUnmarshal(trimmed, &value); err != nil {
|
|
return fmt.Errorf("expected an enum value: %w", err)
|
|
}
|
|
token := fmt.Sprintf("%v", value)
|
|
for _, member := range v.Values {
|
|
if fmt.Sprintf("%v", member.Value) == token {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("%q is not one of %s", token, v.enumTokens())
|
|
|
|
case TypeLanguageTag:
|
|
var value string
|
|
if err := strictUnmarshal(trimmed, &value); err != nil {
|
|
return fmt.Errorf("expected a language tag: %w", err)
|
|
}
|
|
if !languageTagPattern.MatchString(value) {
|
|
return fmt.Errorf("%q is not a well-formed BCP 47 language tag", value)
|
|
}
|
|
|
|
case TypeObject:
|
|
schema, ok := objectSchemas[v.SchemaRef]
|
|
if !ok {
|
|
return fmt.Errorf("no compiled schema for %q", v.SchemaRef)
|
|
}
|
|
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(trimmed))
|
|
if err != nil {
|
|
return fmt.Errorf("expected an object: %w", err)
|
|
}
|
|
if err := schema.Validate(doc); err != nil {
|
|
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
|
|
}
|
|
|
|
default:
|
|
return fmt.Errorf("unknown value type %q", v.Type)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *ValueSchema) checkRange(value float64) error {
|
|
if v.Minimum != nil && value < *v.Minimum {
|
|
return fmt.Errorf("%g is below the minimum %g", value, *v.Minimum)
|
|
}
|
|
if v.Maximum != nil && value > *v.Maximum {
|
|
return fmt.Errorf("%g is above the maximum %g", value, *v.Maximum)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *ValueSchema) checkString(value string) error {
|
|
length := len([]rune(value))
|
|
if v.MinLength != nil && length < *v.MinLength {
|
|
return fmt.Errorf("is shorter than the minimum %d characters", *v.MinLength)
|
|
}
|
|
if v.MaxLength != nil && length > *v.MaxLength {
|
|
return fmt.Errorf("is longer than the maximum %d characters", *v.MaxLength)
|
|
}
|
|
if v.Pattern != "" {
|
|
matcher, err := regexp.Compile(v.Pattern)
|
|
if err != nil {
|
|
return fmt.Errorf("pattern does not compile: %w", err)
|
|
}
|
|
if !matcher.MatchString(value) {
|
|
return fmt.Errorf("does not match %s", v.Pattern)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *ValueSchema) enumTokens() string {
|
|
tokens := make([]string, 0, len(v.Values))
|
|
for _, member := range v.Values {
|
|
tokens = append(tokens, fmt.Sprintf("%v", member.Value))
|
|
}
|
|
return strings.Join(tokens, ", ")
|
|
}
|
|
|
|
// strictUnmarshal rejects trailing content and, for numbers, preserves the
|
|
// literal so an integer field cannot silently accept 1.5.
|
|
func strictUnmarshal(raw []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
if decoder.More() {
|
|
return errors.New("unexpected trailing content")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// languageTagPattern accepts well-formed BCP 47 tags of the shapes Silo
|
|
// actually stores: language, language-region, and language-script-region.
|
|
// Full RFC 5646 grammar is deliberately not implemented; anything this rejects
|
|
// is a value no client currently produces.
|
|
var languageTagPattern = regexp.MustCompile(
|
|
`^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?$`)
|