Files
silo-server/internal/settingscontract/load.go
T
QuickandClaude Opus 5 5933ac0bf2 feat(settings): add the canonical settings contract manifest
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>
2026-07-26 15:24:32 +00:00

182 lines
5.0 KiB
Go

package settingscontract
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"path"
"sync"
"github.com/santhosh-tekuri/jsonschema/v6"
settingsv1 "github.com/Silo-Server/silo-server/contracts/settings/v1"
)
// contractFS is the embedded canonical contract. Clients vendor a pinned copy
// of the same files.
var contractFS = settingsv1.FS
const (
manifestPath = "manifest.json"
manifestSchemaPath = "manifest.schema.json"
schemasDir = "schemas"
)
var (
loadOnce sync.Once
loaded *Manifest
loadedErr error
loadedRaw []byte
objSchemas map[string]*jsonschema.Schema
)
// Load returns the embedded canonical manifest, parsed and fully validated.
//
// It is loaded once per process. A malformed or self-inconsistent manifest is a
// build-time defect, not a runtime condition: the contract tests fail on it, and
// callers that reach this at runtime should treat the error as fatal at startup
// rather than degrading.
func Load() (*Manifest, error) {
loadOnce.Do(func() {
loaded, loadedRaw, loadedErr = load(contractFS)
})
return loaded, loadedErr
}
// MustLoad returns the embedded manifest or panics. For use in server startup
// where a broken embedded contract cannot be recovered from.
func MustLoad() *Manifest {
m, err := Load()
if err != nil {
panic(fmt.Sprintf("settingscontract: embedded manifest is invalid: %v", err))
}
return m
}
// RawBytes returns the embedded manifest file exactly as checked in.
func RawBytes() ([]byte, error) {
if _, err := Load(); err != nil {
return nil, err
}
return append([]byte(nil), loadedRaw...), nil
}
// ObjectSchema returns the compiled JSON Schema for an object-typed value.
func ObjectSchema(ref string) (*jsonschema.Schema, bool) {
if _, err := Load(); err != nil {
return nil, false
}
schema, ok := objSchemas[ref]
return schema, ok
}
func load(fsys fs.FS) (*Manifest, []byte, error) {
raw, err := fs.ReadFile(fsys, manifestPath)
if err != nil {
return nil, nil, fmt.Errorf("reading manifest: %w", err)
}
if err := validateAgainstManifestSchema(fsys, raw); err != nil {
return nil, nil, err
}
var manifest Manifest
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return nil, nil, fmt.Errorf("parsing manifest: %w", err)
}
if err := manifest.index(); err != nil {
return nil, nil, err
}
schemas, err := compileObjectSchemas(fsys)
if err != nil {
return nil, nil, err
}
objSchemas = schemas
if err := manifest.Validate(schemas); err != nil {
return nil, nil, err
}
return &manifest, raw, nil
}
// validateAgainstManifestSchema checks the manifest file against its own JSON
// Schema before Go decoding, so shape errors report as schema violations with a
// location rather than as opaque unmarshal failures.
func validateAgainstManifestSchema(fsys fs.FS, raw []byte) error {
schemaBytes, err := fs.ReadFile(fsys, manifestSchemaPath)
if err != nil {
return fmt.Errorf("reading manifest schema: %w", err)
}
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaBytes))
if err != nil {
return fmt.Errorf("parsing manifest schema: %w", err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource(manifestSchemaPath, schemaDoc); err != nil {
return fmt.Errorf("registering manifest schema: %w", err)
}
schema, err := compiler.Compile(manifestSchemaPath)
if err != nil {
return fmt.Errorf("compiling manifest schema: %w", err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("parsing manifest: %w", err)
}
if err := schema.Validate(doc); err != nil {
return fmt.Errorf("manifest does not satisfy manifest.schema.json: %w", err)
}
return nil
}
// compileObjectSchemas compiles every schema under schemas/ so object-typed
// values and their defaults can be validated. Compiling all of them up front
// also catches a malformed schema file that no definition happens to reference
// yet.
func compileObjectSchemas(fsys fs.FS) (map[string]*jsonschema.Schema, error) {
entries, err := fs.ReadDir(fsys, schemasDir)
if err != nil {
return nil, fmt.Errorf("reading value schema directory: %w", err)
}
compiler := jsonschema.NewCompiler()
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
body, err := fs.ReadFile(fsys, path.Join(schemasDir, name))
if err != nil {
return nil, fmt.Errorf("reading value schema %s: %w", name, err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("parsing value schema %s: %w", name, err)
}
if err := compiler.AddResource(name, doc); err != nil {
return nil, fmt.Errorf("registering value schema %s: %w", name, err)
}
names = append(names, name)
}
compiled := make(map[string]*jsonschema.Schema, len(names))
for _, name := range names {
schema, err := compiler.Compile(name)
if err != nil {
return nil, fmt.Errorf("compiling value schema %s: %w", name, err)
}
compiled[name] = schema
}
return compiled, nil
}