Files
sencho/scripts
Anso e7b457f456 feat(preferences): server-backed per-user appearance and navigation (#1901)
* feat(db): per-user preference store with versioned revision contract

Add the user_preferences table (per user and domain, with a strictly
advancing integer revision and schema_version) plus the atomic
persistence methods behind it:

- getUserPreferenceDomain returns a transport envelope with corrupt-row
  reporting (the revision stays intact so clients can repair)
- mutateUserPreferenceDomain applies a document mutation inside a single
  transaction under an expectedRevision or create-if-absent
  precondition; two competing writers cannot both succeed
- resetUserPreferenceDomain writes a tombstone (schema_version 0) under
  the same precondition contract, so a post-reset edit can
  conditionally un-tombstone while a stale pre-reset edit conflicts
- deleteUserPreferenceDomains removes every row on account deletion
  (foreign keys are declarative only in this codebase, so deleteUser
  runs the cleanup explicitly in its transaction)

* feat(preferences): per-user preference API with conditional writes

Add the /api/user-preferences router: collection GET, per-domain PUT
and DELETE (reset to tombstone), and a migrate endpoint for the
create-if-absent first-run race.

- Every operation is identity-guarded: the x-sencho-pref-user header
  must match the authenticated user before any persistence access, so
  a stale tab can never read or write another account's rows. A
  mismatch is a 409 IDENTITY_CHANGED with no current envelope, kept
  distinct from the revision-conflict 409 CONFLICT which carries one.
- Writes are conditional (expectedRevision or absent: true); there is
  no unguarded write path. Stale preconditions return the fresh
  envelope so clients reconcile instead of retrying blind.
- Documents are validated server-side with strict Zod schemas mirroring
  the frontend field guards; legacy navigation mode values are rejected
- Machine identities (node proxy, pilot tunnel) are rejected and API
  tokens lack the required scope
- The route is hub-only: a remote x-node-id targeting it gets an
  explicit 403, since account-owned state lives on the hub
- Audit summaries cover migrate, update, reset, and failed attempts

* feat(preferences): parity script pinning server enums to frontend registries

The server-side enum tables duplicate the frontend's field guards, and
nothing in the TypeScript compilation boundary links the two (backend
and frontend compile separately). Add a standalone check script that
extracts both value sets from source text and fails on drift, wired as
the pretest step for backend and frontend so a mismatch surfaces in
the ordinary test run instead of as a 400 at write time.

* feat(preferences): frontend preference domains, sync bus, and identity guards

Introduce the client half of server-backed per-user preferences: the
existing localStorage hooks remain the owners of state and DOM
application; a thin sync layer carries their documents to and from the
per-user preference API.

- preferenceEvents: the leaf module with write notifications (dirty
  field attribution), the identity-generation counter, the
  eligibility-readiness store with ownership-captured publications,
  and the unsaved-episode failure surface
- preferencesDocuments: build and per-field sanitize of appearance and
  navigation documents, tombstone-aware hydration, and the owner
  marker that wipes cached values when another account claims the
  browser
- syncBus: the single write choke point. Per-domain serialization,
  same-kind coalescing, reset-cancels-prior-puts chronology, debounced
  trailing writes, conditional PUTs with 409 reconciliation (reset
  conflicts re-queue the DELETE once on the adopted revision),
  keepalive flush on page hide, and pump-level identity guards that
  drop operations captured by a superseded account or generation
- useUserPreferencesSync: the always-mounted sync owner. Hydrates on
  identity resolution, reconciles late GETs against dirty fields
  (server wins untouched fields), adopts remote resets by observable
  revision, repairs corrupt rows with a conditional PUT, and surfaces
  failures as a single unsaved episode per domain
- AuthContext: sequenced identity transitions and a cross-tab storage
  signal so a login or logout in one tab re-checks auth in the others
- the localStorage hooks gain apply/current exports for the hydration
  path; user-facing setters notify the bus, hydration and derived
  seeding paths deliberately do not

* feat(preferences): reset actions, account-scope copy, and frontend tests

Wire the per-domain reset actions into the settings surface: the
appearance section footer and navigation section each offer a
complete-domain reset, and the recovery section's interface reset
runs both domains through the shared handler with a reload only after
both tombstones settle successfully.

- Settings registry: appearance scope moves from browser to account,
  with copy that states where preferences now live
- ThemeQuickSwitch: the save-location line reflects account syncing
- Tests: dirty-field merge against a late hydration GET, identity
  guards (cache ownership, queue invalidation on account switch,
  loading-state no-op), corrupt-row repair through the hook path,
  remote-reset precedence, reset settle/failure handling, registry
  scope assertions, and the eligibility readiness bridge

* test(e2e): cross-browser preference convergence suite

Add the end-to-end spec covering the promise the feature makes: two
browser contexts on the same account converge (appearance fields,
density, navigation mode and pin order), a reset in one context
reaches the other on reload, an account switch renders the new
account's state rather than the previous account's cache, and a
pending write captured before the switch is rejected server-side
without touching either account's rows.

The existing navigation, motion, and effects suites seed a known
server-side preference baseline per suite account so the new
hydration layer cannot overwrite their localStorage-only setups.

* docs: appearance and navigation preferences are account-scoped

Rewrite the user-facing pages that described browser-only
personalization: the appearance feature page (including the quick
switcher, troubleshooting entries, and screenshot description), the
settings reference scope legend, and the multi-node and overview
pages that stated preferences stay per browser. The recovery page's
emergency-reset entry now explains that clearing site data only
drops the local cache, not the saved preferences.

* test(preferences): mint API tokens through the shared test helper

Replace the duplicated token-minting code with the existing
createTestApiToken helper so the file leans on the repo-standard
scaffolding and CodeQL stops flagging the inline sha256 hashing
as new alerts on this diff.
2026-09-17 23:52:46 -04:00
..