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>
182 lines
5.0 KiB
Go
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
|
|
}
|