@seifer-webapp-factory/user-settings
v0.1.1
Published
user-settings — vijfde capability-module: een herbruikbare preferences-laag (theme/locale/dir, density, layout, dismissed hints, onboarding) met één useSettings()-seam over twee runtime-adapters (client: cookie+localStorage anoniem; server: NestJS+persist
Downloads
197
Readme
@seifer-webapp-factory/user-settings
A reusable preferences layer capability-module: one useSettings() seam the UI reads/writes, backed by
a client store (cookie + localStorage, no login) or a per-user server store (auth), chosen at
runtime and transparent to the UI — with graceful guest→login sync. Drops into a no-auth product (client
only) and an auth product (client + server) without changing the surface.
It is schema-parameterised: the module ships the engine, the host declares its keys as
SettingDescriptor[]. Consent is not a preference — analytics/marketing opt-in/out belongs to the
account module; a consent-namespace descriptor is rejected here.
See ../user-settings.md for the full build plan, locked decisions, and the
best-practice validation (2026 research).
Install
// the app's package.json
"dependencies": {
"@seifer-webapp-factory/user-settings": "^0.1.0",
"@seifer-webapp-factory/kits": "^0.1.0"
}Subpath exports: ./contract · ./backend · ./frontend · ./manifest · ./scaffolder. The surface
(backend/templates/**, frontend/templates/**) is materialized into the project and becomes project-owned.
Requires-ports (the host provides)
| Port | Used by | Default the module ships |
|---|---|---|
| SettingsStore (client) | anonymous path | createClientAdapter (cookie + localStorage) |
| SettingsStore (server) | auth path | pgSettingsStore over the persistence kit |
| SubjectProvider | server adapter | none — host injects (resolve the auth subject) |
| Database | server adapter | host's persistence pool |
| DesignSystem | reference page | plain semantic HTML (the page is ejectable) |
No requires.modules — this module composes kits only (decoupled from account).
Config (config fragment)
| Knob | Default | Meaning |
|---|---|---|
| serverSync | true | server persistence when auth is present; a no-auth product never selects the server adapter |
| cookiePrefix | us_ | prefix for the render-key cookies |
| renderKeys | ['theme','locale','dir'] | SSR-critical keys stored on a server-readable cookie |
| cookieMaxAgeDays | 365 | render-cookie lifetime |
| onboardingScope | server | onboardingSeen follows the user across devices (or device = localStorage) |
The host also passes its descriptors: SettingDescriptor[] (the key set) to UserSettingsModule.forRoot
and to the client/server adapters. Each descriptor carries a key, namespace (render|pref), a coded
default, a Zod schema, and optionally version + migrate (load-time migration) and deprecated.
The seam — one API, two adapters
// no-auth (client only)
const api = createUserSettings({ descriptors, initialAdapter: createClientAdapter({ ... }) });
provideUserSettings(app, api);
// in a component
const theme = useSetting<string>('theme'); // reactive two-way bindingOn login, merge the anonymous map into the server store and repoint the seam:
await api.syncOnLogin({ from: clientAdapter, to: serverAdapter, client, opId, renderKeys });syncOnLogin is idempotent via opId (a retry after a lost response is a no-op). Per-key conflict
policy: server-wins for keys the account already has, client-fills for keys it lacks; unknown/invalid keys
are reported in conflicts, never silently dropped. Render keys stay cookie-mirrored so SSR keeps working
post-login. See sample/app/assemble.ts.
SSR — first-paint-safe theming
ssr.ts resolves render keys server-side from the cookie (returning visitor → correct <html class> in the
initial HTML, no flash). First visit: ship color-scheme: light dark + light-dark() as the zero-JS default,
plus renderHeadScript(cookiePrefix) (a synchronous <head> script, injected with a CSP nonce, with
suppressHydrationWarning on <html>) that only overrides when a stored preference contradicts the OS.
Accept-Language is used as a first-visit locale hint only — an explicit choice always wins.
Data model & sync semantics
The server owns one table, user_settings, keyed on (subject_id, key) — a keyed map, never one blob, so
two devices editing different keys never conflict. This is an LWW-Element-Map CRDT: the server stamps a
monotonic version per (subject,key) (the ordering authority — dodges client clock skew), the client
carries a baseVersion so a stale overwrite is detectable (stale_write, 409), and deletes are versioned
tombstones (deleted column), never a physical row-delete.
v0.1 scope note: there is no
DELETE /settings/:keyendpoint;SettingsStore.clear()on the server adapter resets to the descriptor default. Thedeletedtombstone column is used bymergeand reserved for a future DELETE endpoint. Server versioning is receive-order LWW (documented consciously — not intent-order); if intent-time ordering of offline edits ever matters, the server version upgrades to a hybrid logical clock behind the contract.
Security & compliance
- Consent is not a preference — delegated to
account(GDPR Art. 7: consent must be demonstrable). Aconsent-namespace descriptor is rejected. Jurisdiction note: EU = opt-in for analytics; UK DUAA 2025 (5 Feb 2026) = info + opt-out for narrow aggregate analytics — modelled byaccount's consent store, not here. - Tiny cookies — only render keys on the cookie (~4 KB cap); never localStorage for render keys.
- The theme cookie is client-read → also XSS-readable (not
HttpOnly); it holds no secrets. SetSameSite=Lax; Secure; neverPartitioned. - Functional-only exemption for
prefkeys holds only while user-set and never repurposed for tracking. - Server writes are authenticated + CSRF-guarded (double-submit); every read/write is schema-validated (junk → default, never a crash); events carry no values.
Divergence & eject surfaces
- Configure (level 1): the key set,
serverSync,renderKeys, cookie names. - Extend (level 2): add a
SettingDescriptor, register a custom clientSettingsStore. - Materialize & edit (level 3): eject
frontend/templates/pages/settings.vue, the pg store, the NestJS controller/module — project-owned, reconciled on upgrade via three-way merge. - Fork (level 4): replace the adapter/merge mechanism — last resort.
Testing
npm test runs the unit suite (contract, manifest, backend services, frontend client/store/useSettings/ssr,
the reference page, scaffolder). npm run test:e2e runs the backend testcontainers e2e (needs Docker). The
vertical Playwright proof (tests/e2e/vertical/) assembles a sample app (needs a browser + running app).
