feat(settings): add the canonical settings API

The routes that make the typed storage reachable. Until now the manifest,
the resolver and the migration all existed with nothing able to call them.

GET /settings/contract serves the public manifest behind an ETag — clients
vendor a pinned copy and generate bindings from it, so the common request
asks "still the same contract?" rather than transferring it. Its
capabilities sibling reports revision and supported scopes for feature
detection instead of version sniffing.

/settings/values/{key} reads, writes and clears an explicit value at one
named scope, which is what a reset affordance needs: "did I set this here"
is a different question from "what applies", and the old endpoint could
only answer a blurred version of both. Scope comes from the query while
profile and device come from session headers, so one profile cannot address
another's settings by naming it.

/settings/values/effective resolves any number of keys in one request, with
the resolution ladder and the source of each answer reported so a client can
offer "reset this device's override" against the exact row holding it.
Asking for no keys returns every remote setting, which is what a settings
screen wants.

Writes are idempotent when a client sends X-Silo-Mutation-Id: a retry after
a dropped response replays the receipt, and reusing an id with different
content is a conflict rather than a silent overwrite of the wrong thing.

Three things the string-only endpoint could not do, each pinned by a test:
an unknown key is refused rather than stored in the extension bag, values
are checked against their declared type and range, and a write to a scope
the definition does not allow is rejected.

Registered before the catch-all /{key} routes, which would otherwise
swallow "contract" and "values" as setting names. The legacy endpoints stay
live for now; deleting them is the next commit, once their consumers move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-28 00:02:09 +00:00
co-authored by Claude Opus 5
parent cfbd69b240
commit cdbb2997fd
3 changed files with 1007 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// SettingValuesHandler serves the canonical settings API: the contract itself,
// explicit values at one scope, batched effective values, and idempotent
// mutations.
//
// It replaces the string-only registry in settings.go. The differences that
// matter: values are typed JSON rather than strings, a value carries the scope
// it lives at rather than being one of two hardcoded scopes, and an unknown key
// is refused rather than stored in an open extension bag.
type SettingValuesHandler struct {
storeProvider userstore.UserStoreProvider
contract *settingscontract.Manifest
resolver *settingsresolve.Resolver
}
// NewSettingValuesHandler builds the handler over the embedded contract.
func NewSettingValuesHandler(
provider userstore.UserStoreProvider,
contract *settingscontract.Manifest,
) *SettingValuesHandler {
return &SettingValuesHandler{
storeProvider: provider,
contract: contract,
resolver: settingsresolve.New(contract),
}
}
// mutationIDHeader carries the client's idempotency key.
const mutationIDHeader = "X-Silo-Mutation-Id"
// fieldRevision is the response field carrying the contract revision. Clients
// filter definitions, scopes and enum members against it, so every response
// that could be acted on names the revision it was computed at.
const fieldRevision = "revision"
// settingValueResponse is one explicit stored value.
type settingValueResponse struct {
Key string `json:"key"`
Scope string `json:"scope"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
Value json.RawMessage `json:"value"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// effectiveSettingValueResponse is one resolved value plus where it came from.
type effectiveSettingValueResponse struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
Source string `json:"source"`
// StoredValue and Constrained are present only when policy narrowed the
// answer. The authored value is reported rather than discarded so a client
// can say "your choice is capped" instead of silently showing the cap.
StoredValue json.RawMessage `json:"stored_value,omitempty"`
Constrained bool `json:"constrained,omitempty"`
ConstraintKind string `json:"constraint_kind,omitempty"`
// Scope locates the row the value came from, so a client can offer a reset
// against exactly that scope. Empty for a contract default.
Scope string `json:"scope,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
LibraryID int `json:"library_id,omitempty"`
SeriesID string `json:"series_id,omitempty"`
}
// HandleGetContract serves the public manifest.
//
// ETag-gated: clients vendor a pinned copy and generate bindings from it, so
// the common request is a conditional GET that answers "still the same
// contract?" without transferring it.
func (h *SettingValuesHandler) HandleGetContract(w http.ResponseWriter, r *http.Request) {
etag, err := settingscontract.PublicETag()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the settings contract")
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
body, err := settingscontract.PublicBytes()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the settings contract")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// HandleGetCapabilities reports what this server supports, for feature
// detection rather than version sniffing.
func (h *SettingValuesHandler) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
etag, err := settingscontract.PublicETag()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the settings contract")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"api_version": h.contract.APIVersion,
fieldRevision: h.contract.Revision,
"contract_etag": etag,
"definition_count": len(h.contract.Definitions),
"scopes": []string{
string(settingscontract.ScopeAccount),
string(settingscontract.ScopeProfile),
string(settingscontract.ScopeProfileDevice),
string(settingscontract.ScopeProfileLibrary),
string(settingscontract.ScopeProfileSeries),
},
"supports_batched_effective": true,
"supports_idempotent_writes": true,
})
}
// HandleGetValue returns the explicit value at one scope, or 404 when the user
// has none there.
//
// Deliberately not a resolution: this answers "did I set this here", which is
// what a reset affordance needs. Use the effective endpoint for "what applies".
func (h *SettingValuesHandler) HandleGetValue(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
identity, ok := h.identityFromRequest(w, r)
if !ok {
return
}
value, err := store.GetSettingValue(r.Context(), identity)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read the setting")
return
}
if value == nil {
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
writeJSON(w, http.StatusOK, settingValueToResponse(*value))
}
// HandleSetValue writes an explicit value at one scope.
//
// A value that exceeds a policy restriction is stored, not rejected: the
// restriction filters what a preference does at resolution time, and destroying
// the preference would mean a capped 4K choice never takes effect when the cap
// lifts.
func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
identity, ok := h.identityFromRequest(w, r)
if !ok {
return
}
def, ok := h.definitionFor(w, identity.Key)
if !ok {
return
}
var body struct {
Value json.RawMessage `json:"value"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Body must be {\"value\": …}")
return
}
if len(body.Value) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "value is required")
return
}
normalized, err := def.ValueSchema.NormalizeValue(body.Value, settingscontract.ObjectSchemas())
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
return
}
// Idempotency: a client that retries a write after a dropped response must
// not double-apply it, and must be able to tell "already done" from "that
// id means something else".
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
if mutationID != "" {
requestHash := hashMutationRequest(identity, normalized)
prior, err := store.GetSettingMutation(r.Context(), mutationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check the mutation id")
return
}
if prior != nil {
if prior.RequestHash != requestHash {
writeError(w, http.StatusConflict, "mutation_id_conflict",
"This mutation id was used for a different write")
return
}
w.Header().Set("X-Silo-Idempotent-Replay", "true")
writeRawJSON(w, http.StatusOK, prior.Result)
return
}
defer h.recordMutation(r, store, mutationID, requestHash, identity, normalized)
}
stored, err := store.UpsertSettingValue(r.Context(), identity, normalized)
if err != nil {
if errors.Is(err, userstore.ErrInvalidSettingIdentity) ||
errors.Is(err, userstore.ErrInvalidSettingValue) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
return
}
writeJSON(w, http.StatusOK, settingValueToResponse(*stored))
}
// HandleDeleteValue removes the explicit value at one scope, which is how a
// client says "stop overriding here and inherit again".
func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
identity, ok := h.identityFromRequest(w, r)
if !ok {
return
}
removed, err := store.DeleteSettingValue(r.Context(), identity)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the setting")
return
}
if !removed {
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetEffective resolves any number of keys in one request.
//
// Batched deliberately: a client opening a settings screen needs every key at
// once, and a season view needs several keys across many series. One store read
// serves all of it.
func (h *SettingValuesHandler) HandleGetEffective(w http.ResponseWriter, r *http.Request) {
store, ok := h.storeFor(w, r)
if !ok {
return
}
keys := splitCSV(r.URL.Query().Get("keys"))
if len(keys) == 0 {
// No keys named means every remote definition, which is what a settings
// screen wants and saves clients enumerating the manifest themselves.
for i := range h.contract.Definitions {
def := &h.contract.Definitions[i]
if def.IsRemote() {
keys = append(keys, def.Key)
}
}
}
rc := settingsresolve.Context{
ProfileID: strings.TrimSpace(apimw.GetProfileID(r.Context())),
DeviceID: deviceMetadataFromRequest(r).DeviceID,
LibraryIDs: parseIntCSV(r.URL.Query().Get("library_ids")),
SeriesIDs: splitCSV(r.URL.Query().Get("series_ids")),
}
resolved, err := h.resolver.Resolve(r.Context(), store, rc, keys, h.constraintsFor(r))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve settings")
return
}
out := make([]effectiveSettingValueResponse, 0, len(resolved))
for _, eff := range resolved {
out = append(out, effectiveToResponse(eff))
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": out,
fieldRevision: h.contract.Revision,
})
}
// constraintsFor gathers the policy inputs that narrow this viewer's settings.
//
// Nothing is wired yet: internal/policy resolves max_playback_quality and the
// metadata-language allowlist against its own inputs, and binding them here is
// the remaining half of the preferences-versus-restrictions seam. Returning nil
// means no setting is narrowed, which is the same answer the legacy endpoint
// gave — so this is a gap, not a regression.
func (h *SettingValuesHandler) constraintsFor(_ *http.Request) settingsresolve.Constraints {
return nil
}
// recordMutation stores the idempotency receipt after a successful write.
func (h *SettingValuesHandler) recordMutation(
r *http.Request,
store userstore.UserStore,
mutationID, requestHash string,
identity userstore.SettingIdentity,
value json.RawMessage,
) {
receipt, _ := json.Marshal(settingValueResponse{
Key: identity.Key,
Scope: string(identity.Scope),
ProfileID: identity.ProfileID,
DeviceID: identity.DeviceID,
LibraryID: identity.LibraryID,
SeriesID: identity.SeriesID,
Value: value,
})
// Best effort: the write already succeeded, and failing the request now
// would tell the client the opposite of the truth. A missing receipt costs
// at most a duplicate write on retry, which upsert makes harmless.
_, _, _ = store.PutSettingMutation(r.Context(), userstore.SettingMutationRecord{
MutationID: mutationID,
RequestHash: requestHash,
Result: receipt,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
})
}
func (h *SettingValuesHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
store, err := h.storeProvider.ForUser(r.Context(), apimw.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, false
}
return store, true
}
func (h *SettingValuesHandler) definitionFor(w http.ResponseWriter, key string) (*settingscontract.Definition, bool) {
def, ok := h.contract.Lookup(key)
if !ok {
writeError(w, http.StatusNotFound, "unknown_setting",
"No setting named "+key+" exists in this server's contract")
return nil, false
}
if !def.IsRemote() {
writeError(w, http.StatusBadRequest, "client_local_setting",
key+" is a device-local setting and is never stored by the server")
return nil, false
}
return def, true
}
// identityFromRequest builds and validates the scope identity a request names.
//
// Scope comes from the query string rather than the path so one route serves
// every scope; the store's own Validate then enforces that the identity fields
// match the scope, which is the same check the database CHECK constraint makes.
func (h *SettingValuesHandler) identityFromRequest(
w http.ResponseWriter, r *http.Request,
) (userstore.SettingIdentity, bool) {
key := chi.URLParam(r, "key")
if strings.TrimSpace(key) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "A setting key is required")
return userstore.SettingIdentity{}, false
}
if _, ok := h.definitionFor(w, key); !ok {
return userstore.SettingIdentity{}, false
}
query := r.URL.Query()
scope := settingscontract.Scope(strings.TrimSpace(query.Get("scope")))
if scope == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"A scope is required: account, profile, profile_device, profile_library or profile_series")
return userstore.SettingIdentity{}, false
}
identity := userstore.SettingIdentity{Key: key, Scope: scope}
// Profile and device come from the session headers rather than the query,
// so one profile cannot write another's settings by naming it.
if scope != settingscontract.ScopeAccount {
identity.ProfileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
if identity.ProfileID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Profile-Id header is required for this scope")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileDevice {
identity.DeviceID = deviceMetadataFromRequest(r).DeviceID
if identity.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"X-Silo-Device-Id header is required for a device override")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileLibrary {
libraryID, err := strconv.Atoi(strings.TrimSpace(query.Get("library_id")))
if err != nil || libraryID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"library_id is required for a library override")
return userstore.SettingIdentity{}, false
}
identity.LibraryID = libraryID
}
if scope == settingscontract.ScopeProfileSeries {
identity.SeriesID = strings.TrimSpace(query.Get("series_id"))
if identity.SeriesID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"series_id is required for a series override")
return userstore.SettingIdentity{}, false
}
}
if err := identity.Validate(); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return userstore.SettingIdentity{}, false
}
// The contract decides where a setting may be written, independently of
// whether the identity is well formed.
def, _ := h.contract.Lookup(key)
if !def.AllowsScope(scope) {
writeError(w, http.StatusBadRequest, "scope_not_allowed",
key+" cannot be set at "+string(scope))
return userstore.SettingIdentity{}, false
}
return identity, true
}
func settingValueToResponse(value userstore.SettingValue) settingValueResponse {
return settingValueResponse{
Key: value.Key,
Scope: string(value.Scope),
ProfileID: value.ProfileID,
DeviceID: value.DeviceID,
LibraryID: value.LibraryID,
SeriesID: value.SeriesID,
Value: value.Value,
Revision: value.Revision,
UpdatedAt: value.UpdatedAt,
}
}
func effectiveToResponse(eff settingsresolve.Effective) effectiveSettingValueResponse {
out := effectiveSettingValueResponse{
Key: eff.Key,
Value: eff.Value,
Source: string(eff.Source),
StoredValue: eff.StoredValue,
Constrained: eff.Constrained,
}
if eff.ConstraintKind != "" {
out.ConstraintKind = string(eff.ConstraintKind)
}
if eff.Identity != nil {
out.Scope = string(eff.Identity.Scope)
out.ProfileID = eff.Identity.ProfileID
out.DeviceID = eff.Identity.DeviceID
out.LibraryID = eff.Identity.LibraryID
out.SeriesID = eff.Identity.SeriesID
}
return out
}
// hashMutationRequest fingerprints what a mutation id was used for, so a reused
// id carrying different content is a conflict rather than a silent replay of
// the wrong write.
func hashMutationRequest(identity userstore.SettingIdentity, value json.RawMessage) string {
sum := sha256.New()
sum.Write([]byte(identity.Key))
sum.Write([]byte{0})
sum.Write([]byte(identity.Scope))
sum.Write([]byte{0})
sum.Write([]byte(identity.ProfileID))
sum.Write([]byte{0})
sum.Write([]byte(identity.DeviceID))
sum.Write([]byte{0})
sum.Write([]byte(strconv.Itoa(identity.LibraryID)))
sum.Write([]byte{0})
sum.Write([]byte(identity.SeriesID))
sum.Write([]byte{0})
sum.Write(value)
return hex.EncodeToString(sum.Sum(nil))
}
func writeRawJSON(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}
// etagMatches handles the comma-separated If-None-Match list, including "*".
func etagMatches(header, etag string) bool {
header = strings.TrimSpace(header)
if header == "*" {
return true
}
for _, candidate := range strings.Split(header, ",") {
if strings.TrimSpace(candidate) == etag {
return true
}
}
return false
}
func splitCSV(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func parseIntCSV(raw string) []int {
parts := splitCSV(raw)
out := make([]int, 0, len(parts))
for _, part := range parts {
if value, err := strconv.Atoi(part); err == nil && value > 0 {
out = append(out, value)
}
}
return out
}
@@ -0,0 +1,424 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func newValuesTestHandler(t *testing.T) (*SettingValuesHandler, userstore.UserStore) {
t.Helper()
dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) +
"?mode=memory&cache=shared"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
store := userdb.NewSQLiteUserStore(db)
if err := store.CreateProfile(context.Background(),
userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil {
t.Fatalf("create profile: %v", err)
}
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
return NewSettingValuesHandler(testUserStoreProvider{store: store}, contract), store
}
// valuesRequest builds a request carrying the session identity the handlers
// read: user, profile and device all come from context or headers rather than
// the query string, so one profile cannot address another's settings.
func valuesRequest(method, target string, body []byte) *http.Request {
var req *http.Request
if body == nil {
req = httptest.NewRequest(method, target, nil)
} else {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
}
req.Header.Set(deviceIDHeader, "device-1")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
return req.WithContext(apimw.SetProfileID(ctx, "profile-1"))
}
// routeValues wires the chi URL params the handlers read from the path.
func routeValues(t *testing.T, h *SettingValuesHandler, method, key, query string, body []byte) *httptest.ResponseRecorder {
t.Helper()
target := "/settings/values/" + key
if query != "" {
target += "?" + query
}
req := valuesRequest(method, target, body)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", key)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
switch method {
case http.MethodGet:
h.HandleGetValue(rec, req)
case http.MethodPut:
h.HandleSetValue(rec, req)
case http.MethodDelete:
h.HandleDeleteValue(rec, req)
}
return rec
}
func TestSettingValuesRoundTrip(t *testing.T) {
handler, _ := newValuesTestHandler(t)
// Nothing stored yet.
if rec := routeValues(t, handler, http.MethodGet,
"playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNotFound {
t.Fatalf("GET before write = %d, want 404", rec.Code)
}
rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile", []byte(`{"value":"ja"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding PUT response: %v", err)
}
if string(stored.Value) != `"ja"` || stored.Scope != "profile" {
t.Errorf("stored %s at %s, want \"ja\" at profile", stored.Value, stored.Scope)
}
rec = routeValues(t, handler, http.MethodGet, "playback.subtitle_language", "scope=profile", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET after write = %d", rec.Code)
}
if rec := routeValues(t, handler, http.MethodDelete,
"playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d", rec.Code)
}
if rec := routeValues(t, handler, http.MethodGet,
"playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNotFound {
t.Errorf("GET after delete = %d, want 404", rec.Code)
}
}
// TestUnknownKeysAreRefused is the extension bag closing. The legacy endpoint
// stored any unregistered key as an unvalidated string, which is how six ui.*
// settings and five orphan keys reached production untyped.
func TestUnknownKeysAreRefused(t *testing.T) {
handler, _ := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut, "totally.invented.key",
"scope=profile", []byte(`{"value":"x"}`))
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT of an unknown key = %d, want 404: %s", rec.Code, rec.Body.String())
}
// A contract-known local setting is not server storage either.
rec = routeValues(t, handler, http.MethodPut, "downloads.wifi_only",
"scope=profile", []byte(`{"value":true}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("PUT of a client_local key = %d, want 400: %s", rec.Code, rec.Body.String())
}
}
// TestInvalidValuesAreRefusedByType covers what the string-only endpoint could
// not check at all.
func TestInvalidValuesAreRefusedByType(t *testing.T) {
handler, _ := newValuesTestHandler(t)
for name, tc := range map[string]struct{ key, body string }{
"enum member": {"playback.subtitle_mode", `{"value":"sideways"}`},
"integer range": {"playback.next_up_prompt_seconds", `{"value":9999}`},
"wrong type": {"playback.auto_skip_intro", `{"value":"yes"}`},
"quoted number": {"playback.next_up_prompt_seconds", `{"value":"30"}`},
"bad language": {"playback.subtitle_language", `{"value":"!!!"}`},
"object schema": {"playback.subtitle_appearance", `{"value":{"fontSize":"enormous"}}`},
"null when not ok": {"playback.subtitle_mode", `{"value":null}`},
} {
t.Run(name, func(t *testing.T) {
rec := routeValues(t, handler, http.MethodPut, tc.key, "scope=profile", []byte(tc.body))
if rec.Code != http.StatusBadRequest {
t.Errorf("PUT %s = %d, want 400: %s", tc.body, rec.Code, rec.Body.String())
}
})
}
}
// TestScopeMustBeAllowedByTheContract. A definition declares where it may be
// written; the identity being well-formed is a separate question.
func TestScopeMustBeAllowedByTheContract(t *testing.T) {
handler, _ := newValuesTestHandler(t)
// ui.custom_css is profile-only, so a device override is refused.
rec := routeValues(t, handler, http.MethodPut, "ui.custom_css",
"scope=profile_device", []byte(`{"value":"body{}"}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("device write to a profile-only setting = %d, want 400: %s",
rec.Code, rec.Body.String())
}
// A missing scope is a request error rather than a silent default: writing
// to the wrong scope is exactly the mistake this API exists to prevent.
rec = routeValues(t, handler, http.MethodPut, "playback.subtitle_mode", "",
[]byte(`{"value":"always"}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("write with no scope = %d, want 400", rec.Code)
}
}
// TestLibraryAndSeriesScopesNeedTheirIdentity.
func TestLibraryAndSeriesScopesNeedTheirIdentity(t *testing.T) {
handler, _ := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_library", []byte(`{"value":"de"}`)); rec.Code != http.StatusBadRequest {
t.Errorf("library scope without library_id = %d, want 400", rec.Code)
}
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_series", []byte(`{"value":"de"}`)); rec.Code != http.StatusBadRequest {
t.Errorf("series scope without series_id = %d, want 400", rec.Code)
}
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_library&library_id=7", []byte(`{"value":"de"}`)); rec.Code != http.StatusOK {
t.Errorf("library write = %d, want 200", rec.Code)
}
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_series&series_id=s1", []byte(`{"value":"ja"}`)); rec.Code != http.StatusOK {
t.Errorf("series write = %d, want 200", rec.Code)
}
}
// TestEffectiveResolvesThroughTheLadder proves the route is wired to the real
// resolver rather than reading one scope.
func TestEffectiveResolvesThroughTheLadder(t *testing.T) {
handler, _ := newValuesTestHandler(t)
write := func(query, value string) {
t.Helper()
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
query, []byte(`{"value":`+value+`}`)); rec.Code != http.StatusOK {
t.Fatalf("seeding %s = %d: %s", query, rec.Code, rec.Body.String())
}
}
write("scope=profile", `"en"`)
write("scope=profile_device", `"de"`)
write("scope=profile_series&series_id=s1", `"ja"`)
effective := func(query string) effectiveSettingValueResponse {
t.Helper()
req := valuesRequest(http.MethodGet, "/settings/values/effective?"+query, nil)
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("effective %s = %d: %s", query, rec.Code, rec.Body.String())
}
var body struct {
Settings []effectiveSettingValueResponse `json:"settings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
if len(body.Settings) != 1 {
t.Fatalf("got %d settings, want 1", len(body.Settings))
}
return body.Settings[0]
}
// Without a series context the device override is the most specific match.
got := effective("keys=playback.subtitle_language")
if string(got.Value) != `"de"` || got.Source != "profile_device" {
t.Errorf("no-series resolution = %s from %s, want \"de\" from profile_device",
got.Value, got.Source)
}
// Naming the series promotes its override.
got = effective("keys=playback.subtitle_language&series_ids=s1")
if string(got.Value) != `"ja"` || got.Source != "profile_series" {
t.Errorf("series resolution = %s from %s, want \"ja\" from profile_series",
got.Value, got.Source)
}
if got.SeriesID != "s1" {
t.Errorf("resolved identity series = %q, want s1 so a client can reset it", got.SeriesID)
}
}
// TestEffectiveWithNoKeysReturnsEveryRemoteSetting, which is what a settings
// screen opening for the first time asks for.
func TestEffectiveWithNoKeysReturnsEveryRemoteSetting(t *testing.T) {
handler, _ := newValuesTestHandler(t)
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("effective = %d: %s", rec.Code, rec.Body.String())
}
var body struct {
Settings []effectiveSettingValueResponse `json:"settings"`
Revision int `json:"revision"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
contract, _ := settingscontract.Load()
remote := 0
for i := range contract.Definitions {
if contract.Definitions[i].IsRemote() {
remote++
}
}
if len(body.Settings) != remote {
t.Errorf("returned %d settings, want every remote definition (%d)",
len(body.Settings), remote)
}
if body.Revision != contract.Revision {
t.Errorf("revision = %d, want %d", body.Revision, contract.Revision)
}
// Everything unset resolves to its contract default.
for _, setting := range body.Settings {
if setting.Source != string(settingscontract.ScopeDefault) {
t.Errorf("%s resolved from %s with nothing stored", setting.Key, setting.Source)
}
}
}
// TestMutationIDMakesWritesIdempotent covers the retry a mobile client performs
// when a response is lost.
func TestMutationIDMakesWritesIdempotent(t *testing.T) {
handler, store := newValuesTestHandler(t)
send := func(mutationID, value string) *httptest.ResponseRecorder {
req := valuesRequest(http.MethodPut,
"/settings/values/playback.subtitle_mode?scope=profile",
[]byte(`{"value":`+value+`}`))
req.Header.Set(mutationIDHeader, mutationID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", "playback.subtitle_mode")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
return rec
}
if rec := send("mut-1", `"always"`); rec.Code != http.StatusOK {
t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String())
}
// The same id and body replays the receipt rather than writing again.
replay := send("mut-1", `"always"`)
if replay.Code != http.StatusOK {
t.Fatalf("replay = %d: %s", replay.Code, replay.Body.String())
}
if replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Error("a repeated mutation id was not reported as a replay")
}
// The same id with different content is a conflict, not a silent overwrite.
conflict := send("mut-1", `"off"`)
if conflict.Code != http.StatusConflict {
t.Errorf("reused id with new content = %d, want 409", conflict.Code)
}
// The stored value is still the first write's.
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: "playback.subtitle_mode",
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
})
if err != nil || value == nil {
t.Fatalf("reading stored value: %v", err)
}
if string(value.Value) != `"always"` {
t.Errorf("stored value = %s, want the first write preserved", value.Value)
}
}
// TestContractIsServedWithAnETag. Clients vendor a pinned copy and generate
// bindings from it, so the common request asks "still the same contract?".
func TestContractIsServedWithAnETag(t *testing.T) {
handler, _ := newValuesTestHandler(t)
rec := httptest.NewRecorder()
handler.HandleGetContract(rec, httptest.NewRequest(http.MethodGet, "/settings/contract", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET contract = %d", rec.Code)
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag on the contract response")
}
var manifest map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
t.Fatalf("contract body is not JSON: %v", err)
}
// Maintainer notes are stripped from the public projection.
if definitions, ok := manifest["definitions"].([]any); ok && len(definitions) > 0 {
if first, ok := definitions[0].(map[string]any); ok {
if _, leaked := first["notes"]; leaked {
t.Error("maintainer notes leaked into the public contract")
}
}
}
conditional := httptest.NewRequest(http.MethodGet, "/settings/contract", nil)
conditional.Header.Set("If-None-Match", etag)
rec = httptest.NewRecorder()
handler.HandleGetContract(rec, conditional)
if rec.Code != http.StatusNotModified {
t.Errorf("conditional GET = %d, want 304", rec.Code)
}
}
func TestCapabilitiesReportTheContractRevision(t *testing.T) {
handler, _ := newValuesTestHandler(t)
rec := httptest.NewRecorder()
handler.HandleGetCapabilities(rec,
httptest.NewRequest(http.MethodGet, "/settings/contract/capabilities", nil))
if rec.Code != http.StatusOK {
t.Fatalf("capabilities = %d", rec.Code)
}
var body struct {
APIVersion int `json:"api_version"`
Revision int `json:"revision"`
Scopes []string `json:"scopes"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
contract, _ := settingscontract.Load()
if body.APIVersion != contract.APIVersion || body.Revision != contract.Revision {
t.Errorf("reported %d/%d, want %d/%d",
body.APIVersion, body.Revision, contract.APIVersion, contract.Revision)
}
if len(body.Scopes) != 5 {
t.Errorf("reported %d scopes, want 5", len(body.Scopes))
}
}
+24
View File
@@ -62,6 +62,7 @@ import (
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/subtitles"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
@@ -733,6 +734,7 @@ func NewRouter(deps Dependencies) chi.Router {
var progressHandler *handlers.ProgressHandler
var collectionHandler *handlers.CollectionHandler
var settingsHandler *handlers.SettingsHandler
var settingValuesHandler *handlers.SettingValuesHandler
var homeDismissalHandler *handlers.HomeDismissalHandler
var subtitlePrefHandler *handlers.SubtitlePrefHandler
var audioPrefHandler *handlers.AudioPrefHandler
@@ -782,6 +784,13 @@ func NewRouter(deps Dependencies) chi.Router {
if settingsRepo != nil {
settingsHandler.SetServerSettings(settingsRepo)
}
// The canonical settings API. main.go has already loaded and validated
// the contract by the time the router is built, so a failure here is
// unreachable — but the handler is simply omitted rather than panicking,
// which degrades to "no typed settings routes" instead of no server.
if contract, err := settingscontract.Load(); err == nil {
settingValuesHandler = handlers.NewSettingValuesHandler(deps.UserStoreProvider, contract)
}
homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider)
homeDismissalHandler.EventsHub = deps.EventsHub
subtitlePrefHandler = handlers.NewSubtitlePrefHandler(deps.UserStoreProvider)
@@ -2214,6 +2223,21 @@ func NewRouter(deps Dependencies) chi.Router {
r.Put("/device/{key}", settingsHandler.HandleSetDeviceSetting)
r.Delete("/device/{key}", settingsHandler.HandleDeleteDeviceSetting)
})
// The canonical settings API. Registered before the
// catch-all /{key} routes below, which would otherwise
// swallow "contract" and "values" as setting names.
if settingValuesHandler != nil {
r.Get("/contract", settingValuesHandler.HandleGetContract)
r.Get("/contract/capabilities", settingValuesHandler.HandleGetCapabilities)
r.Group(func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/values/effective", settingValuesHandler.HandleGetEffective)
r.Get("/values/{key}", settingValuesHandler.HandleGetValue)
r.Put("/values/{key}", settingValuesHandler.HandleSetValue)
r.Delete("/values/{key}", settingValuesHandler.HandleDeleteValue)
})
}
r.Get("/{key}", settingsHandler.HandleGetSetting)
r.Put("/{key}", settingsHandler.HandleSetSetting)
r.Delete("/{key}", settingsHandler.HandleDeleteSetting)