Files
silo-server/internal/settingscontract/load.go
T
QuickandClaude Opus 5 d8faf83c6e fix(settings): make the settings contract enforceable and fix the appearance cache
The contract manifest landed as a document nothing checked. This makes it a
mechanism, and fixes the one defect in the change set that hurt users on merge
rather than at cutover.

Web appearance cache. useTheme cleared the cache for any account whose stamp
did not match and never repopulated it — the only writers were the four
user-action setters — so every upgrading user lost their warm start on every
load, not once, and x-large-text and high-contrast users lost theirs too. The
owner-stamp protocol is replaced with per-account key namespacing
(`silo-theme:7`): a foreign value is absent rather than present-and-distrusted,
so nothing has to be deleted, the first account keeps its warm start, and there
is no shared stamp for a second tab, a stale debounce timer, or an out-of-order
effect to race on. Widening ownership to profile scope, which this manifest
requires, is now a change to appearanceCacheOwner alone. Adds the API-to-cache
mirror useTheme was missing, cancels pending debounced writes across an account
change, and re-seeds provider state during render so no frame paints the
previous account's look.

Canonicalization. writeCanonical used json.Marshal, which HTML-escapes < > and
&, and canonicalNumber used Go's 'g' format — both diverge from RFC 8785, so
the first label containing an ampersand or bound below 1e-4 would have forked
the server's ETag from every conforming client. Output is now byte-identical to
ECMAScript String() across the edge cases, verified against node. The ETag also
covers the value schemas, which decide what the server accepts and previously
could change while the tag stood still. All four derived representations are
memoized; a conditional GET no longer costs a full parse and re-serialize.

Validation. strictUnmarshal's decoder.More() answered false for a stray ] or },
so `true]` validated as a boolean. Enum matching compared fmt.Sprintf tokens, so
the string "3" satisfied an integer member. Declared steps were never enforced.
The language pattern rejected tags both mobile platforms emit unprompted
(en_US, ca-ES-valencia, ar-EG-u-nu-latn) and never normalized case, so en-US and
en-us were two rows for one preference; NormalizeValue now canonicalizes on the
shared path.

Manifest. show_forced_subtitles defaulted false where the server column is NOT
NULL DEFAULT true, which would have turned forced subtitles off for every
profile that never touched it. preferred_quality declared 13 members where the
planner speaks 6 and collapses the rest to auto. metadata_language's allowlist
was bound to the very column it migrates from. subtitle-appearance pinned
fontFamily to three families while Apple stores any installed system font.
Registers five user-facing settings the clients already ship, and corrects three
notes that described Android behaviour that was not true.

Enforcement. The package had no non-test callers, so MustLoad never ran; it now
loads and logs at startup. The inventory test compared the manifest against a
hand-copied map and could not see the drift it named; it now iterates
settingsRegistry and checks defaults too — both verified to fail on injected
drift. Adds .github/workflows/ci.yml, the repo's first CI that runs go test,
go vet, gofmt, and the frontend suite. Known pre-existing failures are named
individually in the Makefile so everything else stays gated and the list can
only shrink.

Part of #135

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:24:33 +00:00

216 lines
6.2 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
loadedSchemas map[string][]byte
objSchemas map[string]*jsonschema.Schema
)
// loaded contract, as returned by load(). Keeping this a value rather than
// having load() assign the package globals means load() stays pure and can be
// called with a test filesystem without clobbering the process-wide contract.
type contract struct {
manifest *Manifest
raw []byte
schemaRaw map[string][]byte
compiled 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() {
result, err := load(contractFS)
if err != nil {
loadedErr = err
return
}
loaded = result.manifest
loadedRaw = result.raw
loadedSchemas = result.schemaRaw
objSchemas = result.compiled
})
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
}
// SchemaBytes returns every value schema file exactly as checked in, keyed by
// file name. These decide which object-typed values the contract accepts, so
// they are part of its identity — see ETag.
func SchemaBytes() (map[string][]byte, error) {
if _, err := Load(); err != nil {
return nil, err
}
out := make(map[string][]byte, len(loadedSchemas))
for name, body := range loadedSchemas {
out[name] = append([]byte(nil), body...)
}
return out, 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) (contract, error) {
raw, err := fs.ReadFile(fsys, manifestPath)
if err != nil {
return contract{}, fmt.Errorf("reading manifest: %w", err)
}
if err := validateAgainstManifestSchema(fsys, raw); err != nil {
return contract{}, err
}
var manifest Manifest
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return contract{}, fmt.Errorf("parsing manifest: %w", err)
}
if err := manifest.index(); err != nil {
return contract{}, err
}
schemaRaw, compiled, err := compileObjectSchemas(fsys)
if err != nil {
return contract{}, err
}
if err := manifest.Validate(compiled); err != nil {
return contract{}, err
}
return contract{manifest: &manifest, raw: raw, schemaRaw: schemaRaw, compiled: compiled}, 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][]byte, map[string]*jsonschema.Schema, error) {
entries, err := fs.ReadDir(fsys, schemasDir)
if err != nil {
return nil, nil, fmt.Errorf("reading value schema directory: %w", err)
}
compiler := jsonschema.NewCompiler()
raw := make(map[string][]byte, len(entries))
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, nil, fmt.Errorf("reading value schema %s: %w", name, err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(body))
if err != nil {
return nil, nil, fmt.Errorf("parsing value schema %s: %w", name, err)
}
if err := compiler.AddResource(name, doc); err != nil {
return nil, nil, fmt.Errorf("registering value schema %s: %w", name, err)
}
raw[name] = body
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, nil, fmt.Errorf("compiling value schema %s: %w", name, err)
}
compiled[name] = schema
}
return raw, compiled, nil
}