feat(prefs): one per-user preference plane, so a person is the same person in every product
Today each surface keeps its own copy of "who you are and how you like things" in its own localStorage, so the same person reads as two different users depending on which tab they are in — the console remembers a theme insights has never heard of, and neither survives a new device. `GET/PATCH /v1/prefs` is the one place that answers it. Two decisions carry the design: PATCH, not PUT. A surface saves the keys it owns and nothing else. Under a PUT every client would be responsible for preserving every other client's keys, and the first one to forget would silently delete them — so the merge lives on the server, inside the store transaction. Read-modify-write across two calls is a lost update, and multi-tab saving is the case a preferences surface actually sees, not an edge case. The key is the canonical `<owner>/<name>`, never the bare X-User-Id. A bare name is not unique across orgs: `hanzo/z` and `admin/z` are two different people, and keying on `z` would hand one of them the other's document. Isolation is server-side on every statement, and there is deliberately no path to read someone else's preferences — not for an org admin, not for a platform SuperAdmin. Nothing here is a credential, so unlike settings there is no KMS split to maintain; if a preference ever needs custody it does not belong in this table. Tests: 8 merge/decode cases (a partial save preserves a foreign surface's keys; a null value deletes; a nested object is REPLACED, not deep-merged, so a value can actually be cleared; a corrupt row starts the user fresh instead of failing every future write; the bound holds on the merged result, not just the patch) plus 3 real-SQLite cases (missing reads as empty, two writers' distinct keys both survive, two subjects never share a document). The store tests inject a throwaway cek master key rather than skipping without one — they skipped on an encryption-capable build at first, which meant the isolation invariant was untested on exactly the configuration production ships.
This commit is contained in:
@@ -113,6 +113,7 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/plan"
|
||||
"github.com/hanzoai/cloud/clients/platform"
|
||||
"github.com/hanzoai/cloud/clients/plugin"
|
||||
"github.com/hanzoai/cloud/clients/prefs"
|
||||
"github.com/hanzoai/cloud/clients/pricing"
|
||||
"github.com/hanzoai/cloud/clients/product"
|
||||
"github.com/hanzoai/cloud/clients/projects"
|
||||
@@ -393,6 +394,7 @@ func Wire() []cloud.MountSpec {
|
||||
{Name: "sbom", Mount: sbom.Mount, OwnsHealth: true},
|
||||
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
|
||||
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
|
||||
{Name: "prefs", Mount: prefs.Mount, Shutdown: prefs.Shutdown},
|
||||
{Name: "notify", Mount: notify.Mount, OwnsHealth: true},
|
||||
{Name: "channels", Mount: channels.Mount, Shutdown: channels.Shutdown},
|
||||
{Name: "gateway", Mount: gateway.Mount},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package prefs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// decodePatch validates an inbound PATCH body and returns it as a key-wise patch.
|
||||
//
|
||||
// It is deliberately strict about SHAPE and deliberately ignorant of MEANING: the
|
||||
// body must be a JSON OBJECT within the size and key bounds, but the server never
|
||||
// interprets what a preference means. That is what lets a surface add a new key
|
||||
// without a cloud release — and it is safe precisely because this plane holds no
|
||||
// secrets and is readable only by its own owner.
|
||||
//
|
||||
// A null VALUE is meaningful and preserved: it is how a client deletes a key
|
||||
// (see mergeDoc). A null BODY is not a patch and is refused.
|
||||
func decodePatch(body []byte, maxBytes, maxKeys int) (map[string]any, error) {
|
||||
if len(body) == 0 {
|
||||
return nil, zip.ErrBadRequest("a JSON object body is required")
|
||||
}
|
||||
if len(body) > maxBytes {
|
||||
return nil, zip.Errorf(http.StatusRequestEntityTooLarge,
|
||||
"preferences patch exceeds %d bytes", maxBytes)
|
||||
}
|
||||
var patch map[string]any
|
||||
if err := json.Unmarshal(body, &patch); err != nil {
|
||||
return nil, zip.ErrBadRequest("body must be a JSON object: " + err.Error())
|
||||
}
|
||||
if patch == nil {
|
||||
// `null` unmarshals into a nil map without error — an explicit refusal
|
||||
// beats silently treating it as an empty patch.
|
||||
return nil, zip.ErrBadRequest("body must be a JSON object, not null")
|
||||
}
|
||||
if len(patch) > maxKeys {
|
||||
return nil, zip.Errorf(http.StatusRequestEntityTooLarge,
|
||||
"preferences patch exceeds %d keys", maxKeys)
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
// mergeDoc applies a SHALLOW key-wise merge of patch onto the stored document.
|
||||
//
|
||||
// Shallow, not deep: a preference value is a scalar or a small opaque blob the
|
||||
// client owns wholesale, so a deep merge would make it impossible to REPLACE a
|
||||
// nested object — the client could only ever add keys to it. Shallow keeps
|
||||
// "set this key to exactly this value" expressible, which is what a preference is.
|
||||
//
|
||||
// A null value DELETES its key. Without that, a key could be set but never
|
||||
// cleared, and every client would have to invent its own "unset" sentinel.
|
||||
//
|
||||
// A corrupt stored document is treated as empty rather than failing the write:
|
||||
// preferences are not a system of record, and refusing to save a theme forever
|
||||
// because one row got mangled is worse than starting that user fresh.
|
||||
func mergeDoc(stored string, patch map[string]any) (string, error) {
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(stored), &doc); err != nil || doc == nil {
|
||||
doc = map[string]any{}
|
||||
}
|
||||
for k, v := range patch {
|
||||
if v == nil {
|
||||
delete(doc, k)
|
||||
continue
|
||||
}
|
||||
doc[k] = v
|
||||
}
|
||||
if len(doc) > maxKeys {
|
||||
return "", zip.Errorf(http.StatusRequestEntityTooLarge,
|
||||
"preferences document exceeds %d keys", maxKeys)
|
||||
}
|
||||
merged, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode prefs: %w", err)
|
||||
}
|
||||
if len(merged) > maxDoc {
|
||||
return "", zip.Errorf(http.StatusRequestEntityTooLarge,
|
||||
"preferences document exceeds %d bytes", maxDoc)
|
||||
}
|
||||
return string(merged), nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package prefs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The merge is SHALLOW and key-wise: a surface saves only the keys it owns, and
|
||||
// every other surface's keys survive. This is the whole reason the endpoint is a
|
||||
// PATCH — if a save dropped keys the client didn't know about, two products
|
||||
// sharing one document would erase each other on every write.
|
||||
func TestMergeDoc_PreservesForeignKeys(t *testing.T) {
|
||||
// The console saves `theme`; insights' `density` must survive untouched.
|
||||
got, err := mergeDoc(`{"density":"compact","theme":"light"}`, map[string]any{"theme": "dark"})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeDoc: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatalf("result is not JSON: %v", err)
|
||||
}
|
||||
if doc["theme"] != "dark" {
|
||||
t.Fatalf("patched key not applied: %v", doc["theme"])
|
||||
}
|
||||
if doc["density"] != "compact" {
|
||||
t.Fatalf("foreign key was dropped by a partial save: %v", doc["density"])
|
||||
}
|
||||
}
|
||||
|
||||
// A null VALUE deletes its key. Without this a preference could be set but never
|
||||
// cleared, and every client would invent its own "unset" sentinel.
|
||||
func TestMergeDoc_NullDeletesKey(t *testing.T) {
|
||||
got, err := mergeDoc(`{"theme":"dark","pinned":["a"]}`, map[string]any{"theme": nil})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeDoc: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
_ = json.Unmarshal([]byte(got), &doc)
|
||||
if _, present := doc["theme"]; present {
|
||||
t.Fatalf("null did not delete the key: %s", got)
|
||||
}
|
||||
if _, present := doc["pinned"]; !present {
|
||||
t.Fatalf("null deleted an unrelated key: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Shallow, not deep: a client must be able to REPLACE a nested object outright.
|
||||
// A deep merge would make a nested value append-only — you could add sub-keys
|
||||
// forever but never remove one.
|
||||
func TestMergeDoc_ReplacesNestedObjectWholesale(t *testing.T) {
|
||||
got, err := mergeDoc(`{"nav":{"a":1,"b":2}}`, map[string]any{"nav": map[string]any{"c": 3}})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeDoc: %v", err)
|
||||
}
|
||||
var doc struct {
|
||||
Nav map[string]any `json:"nav"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(got), &doc)
|
||||
if _, stale := doc.Nav["a"]; stale {
|
||||
t.Fatalf("nested object was deep-merged, not replaced: %s", got)
|
||||
}
|
||||
if doc.Nav["c"] != float64(3) {
|
||||
t.Fatalf("replacement value missing: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A corrupt stored row starts the user fresh rather than failing every future
|
||||
// write. Preferences are not a system of record; refusing to save a theme
|
||||
// forever because one row got mangled is the worse failure.
|
||||
func TestMergeDoc_CorruptStoredDocStartsFresh(t *testing.T) {
|
||||
for _, stored := range []string{"", "not json", "[]", "null", "42"} {
|
||||
got, err := mergeDoc(stored, map[string]any{"theme": "dark"})
|
||||
if err != nil {
|
||||
t.Fatalf("stored=%q: mergeDoc: %v", stored, err)
|
||||
}
|
||||
if !strings.Contains(got, `"theme":"dark"`) {
|
||||
t.Fatalf("stored=%q: patch not applied: %s", stored, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The document bound is enforced on the MERGED result, not just the patch — many
|
||||
// small accepted patches must not add up to an unbounded row.
|
||||
func TestMergeDoc_BoundsMergedResult(t *testing.T) {
|
||||
big := map[string]any{"blob": strings.Repeat("x", maxDoc)}
|
||||
if _, err := mergeDoc(`{}`, big); err == nil {
|
||||
t.Fatal("an over-sized merged document was accepted")
|
||||
}
|
||||
full := map[string]any{}
|
||||
for i := 0; i < maxKeys; i++ {
|
||||
full[string(rune('a'+i%26))+string(rune('a'+i/26))] = 1
|
||||
}
|
||||
stored, err := mergeDoc(`{}`, full)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := mergeDoc(stored, map[string]any{"one-key-too-many": 1}); err == nil {
|
||||
t.Fatal("exceeding the key bound via an incremental patch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The body must be a JSON OBJECT. `null` unmarshals into a nil map WITHOUT error,
|
||||
// so it needs an explicit refusal — otherwise it silently reads as an empty patch
|
||||
// and returns 200 on a request that saved nothing.
|
||||
func TestDecodePatch_Refusals(t *testing.T) {
|
||||
cases := []struct{ name, body string }{
|
||||
{"empty", ""},
|
||||
{"null", "null"},
|
||||
{"array", `["theme"]`},
|
||||
{"scalar", `"dark"`},
|
||||
{"malformed", `{"theme":`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := decodePatch([]byte(tc.body), maxDoc, maxKeys); err == nil {
|
||||
t.Fatalf("accepted a non-object body: %q", tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := decodePatch([]byte(`{"theme":"dark"}`), maxDoc, maxKeys); err != nil {
|
||||
t.Fatalf("rejected a valid object: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty object is a VALID no-op patch — a client that computed no changes
|
||||
// should get a 200 with its document back, not a 400.
|
||||
func TestDecodePatch_EmptyObjectIsValid(t *testing.T) {
|
||||
patch, err := decodePatch([]byte(`{}`), maxDoc, maxKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("empty object refused: %v", err)
|
||||
}
|
||||
if len(patch) != 0 {
|
||||
t.Fatalf("expected an empty patch, got %v", patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePatch_BoundsBodyAndKeys(t *testing.T) {
|
||||
if _, err := decodePatch([]byte(`{"k":"`+strings.Repeat("x", maxDoc)+`"}`), maxDoc, maxKeys); err == nil {
|
||||
t.Fatal("an over-sized body was accepted")
|
||||
}
|
||||
if _, err := decodePatch([]byte(`{"a":1,"b":2,"c":3}`), maxDoc, 2); err == nil {
|
||||
t.Fatal("a patch exceeding the key bound was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Package prefs is the per-USER preference plane for the unified Hanzo Cloud
|
||||
// binary: the /v1/prefs surface behind the user menu on every Hanzo surface
|
||||
// (console, insights, and anything else that renders "signed in as").
|
||||
//
|
||||
// ONE preference store, EVERY surface. A user's theme, density, and pinned nav
|
||||
// follow them between products instead of each app keeping its own copy in its
|
||||
// own localStorage — which is what makes the same person look like two different
|
||||
// users depending on which tab they are in.
|
||||
//
|
||||
// Surface (all user-scoped; /v1 only):
|
||||
//
|
||||
// GET /v1/prefs the caller's own document -> prefsView
|
||||
// PATCH /v1/prefs shallow key-wise merge into it -> prefsView
|
||||
//
|
||||
// PATCH, not PUT: a surface saves the keys it owns (the console saves `theme`,
|
||||
// insights saves `density`) without having to send back keys it does not know
|
||||
// about — a PUT would make every client responsible for preserving every other
|
||||
// client's keys, and the first one to forget silently deletes them.
|
||||
//
|
||||
// USER ISOLATION is enforced SERVER-SIDE on every request. The subject is the
|
||||
// canonical `<owner>/<name>` identity built from values the identity boundary
|
||||
// minted from a VALIDATED credential (HIP-0026), and is the mandatory predicate
|
||||
// on every store statement. It is NEVER read from a query param or body, and
|
||||
// there is no "read another user's prefs" path at all: not for an org admin, not
|
||||
// for a platform SuperAdmin. Preferences are personal, and no operational task
|
||||
// requires reading someone else's.
|
||||
//
|
||||
// NOT SETTINGS. clients/settings is per-ORG, per-product configuration with KMS
|
||||
// custody for secret fields. This is per-USER UI state with no secrets. They are
|
||||
// different tenancy keys answering different questions, so they are different
|
||||
// planes — collapsing them would put one user's theme under an org key and make
|
||||
// an org admin the owner of everyone's UI.
|
||||
package prefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/clients/principal"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// maxDoc bounds a stored preference document. Preferences are a handful of small
|
||||
// scalars; the bound exists so a client cannot turn a personal, unaudited row
|
||||
// into general-purpose storage.
|
||||
const maxDoc = 16 * 1024
|
||||
|
||||
// maxKeys bounds how many distinct preference keys one user may hold, for the
|
||||
// same reason.
|
||||
const maxKeys = 128
|
||||
|
||||
type service struct {
|
||||
store *Store
|
||||
log luxlog.Logger
|
||||
}
|
||||
|
||||
var mounted *service
|
||||
|
||||
// prefsView is the wire shape. Doc is passed through verbatim as raw JSON — the
|
||||
// server does not interpret a preference's meaning, only its shape, so a surface
|
||||
// can add a key without a server change.
|
||||
type prefsView struct {
|
||||
Prefs json.RawMessage `json:"prefs"`
|
||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Mount registers the prefs surface on app per HIP-0106.
|
||||
func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("prefs.Mount: nil zip.App")
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
return fmt.Errorf("prefs.Mount: nil deps.Logger")
|
||||
}
|
||||
log := deps.Logger.New("subsystem", "prefs")
|
||||
if deps.DataDir == "" {
|
||||
return fmt.Errorf("prefs.Mount: empty DataDir")
|
||||
}
|
||||
if err := os.MkdirAll(deps.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("prefs.Mount: data dir: %w", err)
|
||||
}
|
||||
store, err := openStore(filepath.Join(deps.DataDir, "prefs.db"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("prefs.Mount: open prefs store: %w", err)
|
||||
}
|
||||
s := &service{store: store, log: log}
|
||||
mounted = s
|
||||
|
||||
g := app.Group("/v1/prefs")
|
||||
g.Get("", s.getPrefs)
|
||||
g.Patch("", s.patchPrefs)
|
||||
|
||||
log.Info("prefs surface mounted", "prefix", "/v1/prefs", "brand", deps.Brand)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown releases the prefs store. Idempotent.
|
||||
func Shutdown(_ context.Context) error {
|
||||
if mounted == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if mounted.store != nil {
|
||||
err = mounted.store.Close()
|
||||
}
|
||||
mounted = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// subject resolves the preference OWNER — the isolation KEY — for a VALIDATED
|
||||
// principal only. Fails closed for an unvalidated request: with no verified
|
||||
// identity there is no "own" document to read, so there is nothing to serve.
|
||||
//
|
||||
// The key is the CANONICAL `<owner>/<name>` identity, the same form IAM parses
|
||||
// and clients/account's resolveCaller builds — never the bare X-User-Id. The
|
||||
// bare name is NOT unique across orgs: `hanzo/z` and `admin/z` are two different
|
||||
// people, and keying on `z` alone would hand one of them the other's document.
|
||||
// A user with no org yet (first-run, pre-onboarding) keys on the bare name, which
|
||||
// is correct for exactly as long as they have no org to be qualified by.
|
||||
//
|
||||
// Both halves are bounded before use, so an oversized forged header can never
|
||||
// become a giant primary key.
|
||||
func (s *service) subject(c *zip.Ctx) (string, bool) {
|
||||
if !principal.Validated(c) {
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(c.User())
|
||||
if name == "" || len(name) > principal.MaxOrgLen {
|
||||
return "", false
|
||||
}
|
||||
owner := strings.TrimSpace(c.Org())
|
||||
if owner == "" || len(owner) > principal.MaxOrgLen {
|
||||
return name, true
|
||||
}
|
||||
return owner + "/" + name, true
|
||||
}
|
||||
|
||||
func (s *service) getPrefs(c *zip.Ctx) error {
|
||||
subject, ok := s.subject(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
p, err := s.store.Get(c.Context(), subject)
|
||||
if err == errNotFound {
|
||||
// Never written any — an honest empty document. NOT a 404: "I have no
|
||||
// preferences yet" is a successful answer, and the menu must render.
|
||||
return c.JSON(http.StatusOK, prefsView{Prefs: json.RawMessage(`{}`)})
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get prefs: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, prefsView{Prefs: json.RawMessage(p.Doc), UpdatedAt: p.UpdatedAt})
|
||||
}
|
||||
|
||||
func (s *service) patchPrefs(c *zip.Ctx) error {
|
||||
subject, ok := s.subject(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("a validated principal is required")
|
||||
}
|
||||
patch, err := decodePatch(c.Body(), maxDoc, maxKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := s.store.Merge(c.Context(), subject, patch, time.Now().Unix())
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "save prefs: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, prefsView{Prefs: json.RawMessage(p.Doc), UpdatedAt: p.UpdatedAt})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package prefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
// github.com/hanzoai/sqlite is the ONE Hanzo SQLite driver (registers the
|
||||
// "sqlite" database/sql name under both cgo and pure-Go build tags). Blank
|
||||
// import registers the driver; importing modernc directly would double-register.
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
_ "github.com/hanzoai/sqlite"
|
||||
)
|
||||
|
||||
// The prefs STORE is the durable half of the per-USER preference plane: one JSON
|
||||
// document per user, following the settings-store discipline — Hanzo Base/SQLite,
|
||||
// MaxOpenConns(1) to serialize writes against the file lock, one file.
|
||||
//
|
||||
// Isolation is the `subject` PRIMARY KEY and a mandatory `WHERE subject=?` on
|
||||
// EVERY statement. The value is the VALIDATED principal (principal.Subject) —
|
||||
// never normalized (casing/trimming would collapse distinct subjects into one
|
||||
// bucket) and never a client-supplied header. A user's preferences are readable
|
||||
// and writable by that user alone; there is deliberately no admin read path,
|
||||
// because there is no operational reason to read someone's theme.
|
||||
//
|
||||
// NO SECRETS. Preferences are UI state — a theme, a density, a pinned nav. Unlike
|
||||
// settings there is no KMS split to maintain, because nothing here is a
|
||||
// credential. If a preference ever needs custody, it does not belong in this table.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Prefs is one user's preference document.
|
||||
type Prefs struct {
|
||||
Subject string // the validated principal; the tenancy key
|
||||
Doc string // opaque JSON object, stored verbatim (bounded at the edge)
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
var errNotFound = errors.New("prefs: not found")
|
||||
|
||||
func openStore(path string) (*Store, error) {
|
||||
db, err := cek.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", path, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS prefs (
|
||||
subject TEXT NOT NULL PRIMARY KEY,
|
||||
doc TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Get returns the persisted document for subject, or errNotFound when that user
|
||||
// has never written one (the caller then serves an empty document, not a 404 —
|
||||
// a preferences read always succeeds).
|
||||
func (s *Store) Get(ctx context.Context, subject string) (Prefs, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT subject, doc, updated_at FROM prefs WHERE subject=?`, subject)
|
||||
var p Prefs
|
||||
err := row.Scan(&p.Subject, &p.Doc, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Prefs{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Prefs{}, fmt.Errorf("get prefs: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Merge applies a shallow key-wise merge of patch onto the user's stored document
|
||||
// and returns the result, all inside ONE transaction on the single serialized
|
||||
// connection.
|
||||
//
|
||||
// The merge is here rather than in the handler because read-modify-write across
|
||||
// two calls is a lost update: two tabs saving different keys would race, and the
|
||||
// later write would silently drop the earlier one's key. Doing it under the
|
||||
// transaction makes concurrent saves of DIFFERENT keys both survive — which is
|
||||
// exactly the multi-tab case a preferences surface actually sees.
|
||||
func (s *Store) Merge(ctx context.Context, subject string, patch map[string]any, now int64) (Prefs, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Prefs{}, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var stored string
|
||||
switch err := tx.QueryRowContext(ctx, `SELECT doc FROM prefs WHERE subject=?`, subject).Scan(&stored); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
stored = "{}"
|
||||
case err != nil:
|
||||
return Prefs{}, fmt.Errorf("lookup prefs: %w", err)
|
||||
}
|
||||
|
||||
merged, err := mergeDoc(stored, patch)
|
||||
if err != nil {
|
||||
return Prefs{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO prefs (subject, doc, updated_at) VALUES (?,?,?)
|
||||
ON CONFLICT(subject) DO UPDATE SET doc=excluded.doc, updated_at=excluded.updated_at`,
|
||||
subject, merged, now); err != nil {
|
||||
return Prefs{}, fmt.Errorf("put prefs: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Prefs{}, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return Prefs{Subject: subject, Doc: merged, UpdatedAt: now}, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package prefs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
)
|
||||
|
||||
// testStore opens a real store on a temp file. It injects a fixed test master
|
||||
// key so the suite runs on an ENCRYPTION-CAPABLE build too: cek fails closed
|
||||
// without one, and skipping there would leave the isolation invariant below
|
||||
// untested on exactly the build configuration production ships — a green suite
|
||||
// that proves nothing. The key is a throwaway constant, never a real secret.
|
||||
func testStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
cek.SetMasterKey(bytes.Repeat([]byte{0x2a}, 32))
|
||||
s, err := openStore(filepath.Join(t.TempDir(), "prefs.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
// A user who has never saved anything reads as "not found", which the handler
|
||||
// turns into an empty document rather than a 404 — a preferences read always
|
||||
// succeeds, or the user menu cannot render.
|
||||
func TestStore_MissingIsNotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if _, err := s.Get(context.Background(), "hanzo/z"); err != errNotFound {
|
||||
t.Fatalf("want errNotFound for an unwritten subject, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of merging INSIDE the transaction: two surfaces saving
|
||||
// DIFFERENT keys both survive. Done as read-modify-write across two calls, the
|
||||
// second writer would overwrite the first's key with its own stale snapshot.
|
||||
func TestStore_MergeIsAdditiveAcrossWrites(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
const subject = "hanzo/z"
|
||||
|
||||
if _, err := s.Merge(ctx, subject, map[string]any{"theme": "dark"}, 100); err != nil {
|
||||
t.Fatalf("first merge: %v", err)
|
||||
}
|
||||
if _, err := s.Merge(ctx, subject, map[string]any{"density": "compact"}, 200); err != nil {
|
||||
t.Fatalf("second merge: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.Get(ctx, subject)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got.Doc), &doc); err != nil {
|
||||
t.Fatalf("stored doc is not JSON: %v", err)
|
||||
}
|
||||
if doc["theme"] != "dark" {
|
||||
t.Fatalf("the first surface's key was lost: %s", got.Doc)
|
||||
}
|
||||
if doc["density"] != "compact" {
|
||||
t.Fatalf("the second surface's key was lost: %s", got.Doc)
|
||||
}
|
||||
if got.UpdatedAt != 200 {
|
||||
t.Fatalf("updatedAt not advanced: %d", got.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Two subjects are two documents. This is the isolation invariant the whole
|
||||
// plane rests on: `hanzo/z` and `admin/z` are different people who happen to
|
||||
// share a name, and neither may read or clobber the other.
|
||||
func TestStore_SubjectsAreIsolated(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.Merge(ctx, "hanzo/z", map[string]any{"theme": "dark"}, 1); err != nil {
|
||||
t.Fatalf("merge hanzo/z: %v", err)
|
||||
}
|
||||
if _, err := s.Merge(ctx, "admin/z", map[string]any{"theme": "light"}, 1); err != nil {
|
||||
t.Fatalf("merge admin/z: %v", err)
|
||||
}
|
||||
|
||||
a, err := s.Get(ctx, "hanzo/z")
|
||||
if err != nil {
|
||||
t.Fatalf("get hanzo/z: %v", err)
|
||||
}
|
||||
b, err := s.Get(ctx, "admin/z")
|
||||
if err != nil {
|
||||
t.Fatalf("get admin/z: %v", err)
|
||||
}
|
||||
if a.Doc == b.Doc {
|
||||
t.Fatalf("two subjects share one document: %s", a.Doc)
|
||||
}
|
||||
if !contains(a.Doc, "dark") || !contains(b.Doc, "light") {
|
||||
t.Fatalf("subject documents crossed over: %s / %s", a.Doc, b.Doc)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
Reference in New Issue
Block a user