@seifer-webapp-factory/user-settings 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +122 -0
  2. package/backend/templates/config/config-fragment.ts +22 -0
  3. package/backend/templates/nestjs/tokens.ts +15 -0
  4. package/backend/templates/nestjs/user-settings.controller.ts +119 -0
  5. package/backend/templates/nestjs/user-settings.module.ts +107 -0
  6. package/backend/templates/persistence/migrations/0001_user_settings.sql +16 -0
  7. package/backend/templates/persistence/migrations/index.ts +37 -0
  8. package/backend/templates/persistence/pg-settings-store.ts +62 -0
  9. package/backend/templates/security/security.ts +79 -0
  10. package/dist/backend/src/descriptors.d.ts +11 -0
  11. package/dist/backend/src/descriptors.d.ts.map +1 -0
  12. package/dist/backend/src/descriptors.js +38 -0
  13. package/dist/backend/src/descriptors.js.map +1 -0
  14. package/dist/backend/src/errors.d.ts +18 -0
  15. package/dist/backend/src/errors.d.ts.map +1 -0
  16. package/dist/backend/src/errors.js +29 -0
  17. package/dist/backend/src/errors.js.map +1 -0
  18. package/dist/backend/src/index.d.ts +10 -0
  19. package/dist/backend/src/index.d.ts.map +1 -0
  20. package/dist/backend/src/index.js +9 -0
  21. package/dist/backend/src/index.js.map +1 -0
  22. package/dist/backend/src/ports.d.ts +58 -0
  23. package/dist/backend/src/ports.d.ts.map +1 -0
  24. package/dist/backend/src/ports.js +2 -0
  25. package/dist/backend/src/ports.js.map +1 -0
  26. package/dist/backend/src/services.d.ts +26 -0
  27. package/dist/backend/src/services.d.ts.map +1 -0
  28. package/dist/backend/src/services.js +104 -0
  29. package/dist/backend/src/services.js.map +1 -0
  30. package/dist/contract/config.d.ts +35 -0
  31. package/dist/contract/config.d.ts.map +1 -0
  32. package/dist/contract/config.js +20 -0
  33. package/dist/contract/config.js.map +1 -0
  34. package/dist/contract/endpoints.d.ts +188 -0
  35. package/dist/contract/endpoints.d.ts.map +1 -0
  36. package/dist/contract/endpoints.js +21 -0
  37. package/dist/contract/endpoints.js.map +1 -0
  38. package/dist/contract/errors.d.ts +22 -0
  39. package/dist/contract/errors.d.ts.map +1 -0
  40. package/dist/contract/errors.js +26 -0
  41. package/dist/contract/errors.js.map +1 -0
  42. package/dist/contract/events.d.ts +38 -0
  43. package/dist/contract/events.d.ts.map +1 -0
  44. package/dist/contract/events.js +7 -0
  45. package/dist/contract/events.js.map +1 -0
  46. package/dist/contract/index.d.ts +11 -0
  47. package/dist/contract/index.d.ts.map +1 -0
  48. package/dist/contract/index.js +11 -0
  49. package/dist/contract/index.js.map +1 -0
  50. package/dist/contract/schemas.d.ts +255 -0
  51. package/dist/contract/schemas.d.ts.map +1 -0
  52. package/dist/contract/schemas.js +54 -0
  53. package/dist/contract/schemas.js.map +1 -0
  54. package/dist/frontend/src/client.d.ts +22 -0
  55. package/dist/frontend/src/client.d.ts.map +1 -0
  56. package/dist/frontend/src/client.js +64 -0
  57. package/dist/frontend/src/client.js.map +1 -0
  58. package/dist/frontend/src/descriptors.d.ts +12 -0
  59. package/dist/frontend/src/descriptors.d.ts.map +1 -0
  60. package/dist/frontend/src/descriptors.js +44 -0
  61. package/dist/frontend/src/descriptors.js.map +1 -0
  62. package/dist/frontend/src/index.d.ts +16 -0
  63. package/dist/frontend/src/index.d.ts.map +1 -0
  64. package/dist/frontend/src/index.js +12 -0
  65. package/dist/frontend/src/index.js.map +1 -0
  66. package/dist/frontend/src/ssr.d.ts +43 -0
  67. package/dist/frontend/src/ssr.d.ts.map +1 -0
  68. package/dist/frontend/src/ssr.js +90 -0
  69. package/dist/frontend/src/ssr.js.map +1 -0
  70. package/dist/frontend/src/store.d.ts +46 -0
  71. package/dist/frontend/src/store.d.ts.map +1 -0
  72. package/dist/frontend/src/store.js +111 -0
  73. package/dist/frontend/src/store.js.map +1 -0
  74. package/dist/frontend/src/useSettings.d.ts +46 -0
  75. package/dist/frontend/src/useSettings.d.ts.map +1 -0
  76. package/dist/frontend/src/useSettings.js +100 -0
  77. package/dist/frontend/src/useSettings.js.map +1 -0
  78. package/dist/manifest.d.ts +59 -0
  79. package/dist/manifest.d.ts.map +1 -0
  80. package/dist/manifest.js +45 -0
  81. package/dist/manifest.js.map +1 -0
  82. package/dist/scaffolder/core/errors.d.ts +24 -0
  83. package/dist/scaffolder/core/errors.d.ts.map +1 -0
  84. package/dist/scaffolder/core/errors.js +38 -0
  85. package/dist/scaffolder/core/errors.js.map +1 -0
  86. package/dist/scaffolder/core/materialize.d.ts +40 -0
  87. package/dist/scaffolder/core/materialize.d.ts.map +1 -0
  88. package/dist/scaffolder/core/materialize.js +38 -0
  89. package/dist/scaffolder/core/materialize.js.map +1 -0
  90. package/dist/scaffolder/core/ports.d.ts +28 -0
  91. package/dist/scaffolder/core/ports.d.ts.map +1 -0
  92. package/dist/scaffolder/core/ports.js +28 -0
  93. package/dist/scaffolder/core/ports.js.map +1 -0
  94. package/dist/scaffolder/core/presence.d.ts +21 -0
  95. package/dist/scaffolder/core/presence.d.ts.map +1 -0
  96. package/dist/scaffolder/core/presence.js +20 -0
  97. package/dist/scaffolder/core/presence.js.map +1 -0
  98. package/dist/scaffolder/index.d.ts +20 -0
  99. package/dist/scaffolder/index.d.ts.map +1 -0
  100. package/dist/scaffolder/index.js +6 -0
  101. package/dist/scaffolder/index.js.map +1 -0
  102. package/frontend/templates/i18n/en.json +20 -0
  103. package/frontend/templates/i18n/nl.json +20 -0
  104. package/frontend/templates/pages/settings.vue +62 -0
  105. package/frontend/templates/runtime.ts +19 -0
  106. package/package.json +79 -0
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # `@seifer-webapp-factory/user-settings`
2
+
3
+ A reusable **preferences layer** capability-module: one `useSettings()` seam the UI reads/writes, backed by
4
+ a **client store** (cookie + localStorage, no login) *or* a **per-user server store** (auth), chosen at
5
+ runtime and transparent to the UI — with graceful guest→login sync. Drops into a no-auth product (client
6
+ only) and an auth product (client + server) **without changing the surface**.
7
+
8
+ It is **schema-parameterised**: the module ships the engine, the host declares its keys as
9
+ `SettingDescriptor[]`. Consent is **not** a preference — analytics/marketing opt-in/out belongs to the
10
+ [`account`](../account/) module; a `consent`-namespace descriptor is rejected here.
11
+
12
+ See [`../user-settings.md`](../user-settings.md) for the full build plan, locked decisions, and the
13
+ best-practice validation (2026 research).
14
+
15
+ ## Install
16
+
17
+ ```jsonc
18
+ // the app's package.json
19
+ "dependencies": {
20
+ "@seifer-webapp-factory/user-settings": "^0.1.0",
21
+ "@seifer-webapp-factory/kits": "^0.1.0"
22
+ }
23
+ ```
24
+
25
+ Subpath exports: `./contract` · `./backend` · `./frontend` · `./manifest` · `./scaffolder`. The **surface**
26
+ (`backend/templates/**`, `frontend/templates/**`) is materialized into the project and becomes project-owned.
27
+
28
+ ## Requires-ports (the host provides)
29
+
30
+ | Port | Used by | Default the module ships |
31
+ |---|---|---|
32
+ | `SettingsStore` (client) | anonymous path | `createClientAdapter` (cookie + localStorage) |
33
+ | `SettingsStore` (server) | auth path | `pgSettingsStore` over the persistence kit |
34
+ | `SubjectProvider` | server adapter | none — host injects (resolve the auth subject) |
35
+ | `Database` | server adapter | host's persistence pool |
36
+ | `DesignSystem` | reference page | plain semantic HTML (the page is ejectable) |
37
+
38
+ No `requires.modules` — this module composes kits only (decoupled from `account`).
39
+
40
+ ## Config (`config` fragment)
41
+
42
+ | Knob | Default | Meaning |
43
+ |---|---|---|
44
+ | `serverSync` | `true` | server persistence when auth is present; a no-auth product never selects the server adapter |
45
+ | `cookiePrefix` | `us_` | prefix for the render-key cookies |
46
+ | `renderKeys` | `['theme','locale','dir']` | SSR-critical keys stored on a server-readable cookie |
47
+ | `cookieMaxAgeDays` | `365` | render-cookie lifetime |
48
+ | `onboardingScope` | `server` | `onboardingSeen` follows the user across devices (or `device` = localStorage) |
49
+
50
+ The host also passes its **`descriptors: SettingDescriptor[]`** (the key set) to `UserSettingsModule.forRoot`
51
+ and to the client/server adapters. Each descriptor carries a `key`, `namespace` (`render`|`pref`), a coded
52
+ `default`, a Zod `schema`, and optionally `version` + `migrate` (load-time migration) and `deprecated`.
53
+
54
+ ## The seam — one API, two adapters
55
+
56
+ ```ts
57
+ // no-auth (client only)
58
+ const api = createUserSettings({ descriptors, initialAdapter: createClientAdapter({ ... }) });
59
+ provideUserSettings(app, api);
60
+
61
+ // in a component
62
+ const theme = useSetting<string>('theme'); // reactive two-way binding
63
+ ```
64
+
65
+ On login, merge the anonymous map into the server store and repoint the seam:
66
+
67
+ ```ts
68
+ await api.syncOnLogin({ from: clientAdapter, to: serverAdapter, client, opId, renderKeys });
69
+ ```
70
+
71
+ `syncOnLogin` is **idempotent** via `opId` (a retry after a lost response is a no-op). Per-key conflict
72
+ policy: server-wins for keys the account already has, client-fills for keys it lacks; unknown/invalid keys
73
+ are reported in `conflicts`, never silently dropped. Render keys stay cookie-mirrored so SSR keeps working
74
+ post-login. See [`sample/app/assemble.ts`](./sample/app/assemble.ts).
75
+
76
+ ## SSR — first-paint-safe theming
77
+
78
+ `ssr.ts` resolves render keys server-side from the cookie (returning visitor → correct `<html class>` in the
79
+ initial HTML, no flash). First visit: ship `color-scheme: light dark` + `light-dark()` as the zero-JS default,
80
+ plus `renderHeadScript(cookiePrefix)` (a synchronous `<head>` script, injected with a CSP nonce, with
81
+ `suppressHydrationWarning` on `<html>`) that only overrides when a stored preference contradicts the OS.
82
+ `Accept-Language` is used as a first-visit locale hint only — an explicit choice always wins.
83
+
84
+ ## Data model & sync semantics
85
+
86
+ The server owns one table, `user_settings`, keyed on `(subject_id, key)` — a **keyed map, never one blob**, so
87
+ two devices editing different keys never conflict. This is an **LWW-Element-Map CRDT**: the server stamps a
88
+ **monotonic `version`** per `(subject,key)` (the ordering authority — dodges client clock skew), the client
89
+ carries a `baseVersion` so a stale overwrite is detectable (`stale_write`, 409), and deletes are **versioned
90
+ tombstones** (`deleted` column), never a physical row-delete.
91
+
92
+ > **v0.1 scope note:** there is no `DELETE /settings/:key` endpoint; `SettingsStore.clear()` on the server
93
+ > adapter **resets to the descriptor default**. The `deleted` tombstone column is used by `merge` and reserved
94
+ > for a future DELETE endpoint. Server versioning is **receive-order** LWW (documented consciously — not
95
+ > intent-order); if intent-time ordering of offline edits ever matters, the server version upgrades to a
96
+ > hybrid logical clock behind the contract.
97
+
98
+ ## Security & compliance
99
+
100
+ - **Consent is not a preference** — delegated to `account` (GDPR Art. 7: consent must be demonstrable). A
101
+ `consent`-namespace descriptor is rejected. *Jurisdiction note:* EU = opt-in for analytics; UK DUAA 2025
102
+ (5 Feb 2026) = info + opt-out for narrow aggregate analytics — modelled by `account`'s consent store, not here.
103
+ - **Tiny cookies** — only render keys on the cookie (~4 KB cap); never localStorage for render keys.
104
+ - **The theme cookie is client-read → also XSS-readable** (not `HttpOnly`); it holds no secrets. Set
105
+ `SameSite=Lax; Secure`; never `Partitioned`.
106
+ - **Functional-only exemption** for `pref` keys holds only while user-set and never repurposed for tracking.
107
+ - **Server writes are authenticated + CSRF-guarded** (double-submit); every read/write is schema-validated
108
+ (junk → default, never a crash); **events carry no values**.
109
+
110
+ ## Divergence & eject surfaces
111
+
112
+ - **Configure** (level 1): the key set, `serverSync`, `renderKeys`, cookie names.
113
+ - **Extend** (level 2): add a `SettingDescriptor`, register a custom client `SettingsStore`.
114
+ - **Materialize & edit** (level 3): eject `frontend/templates/pages/settings.vue`, the pg store, the NestJS
115
+ controller/module — project-owned, reconciled on upgrade via three-way merge.
116
+ - **Fork** (level 4): replace the adapter/merge mechanism — last resort.
117
+
118
+ ## Testing
119
+
120
+ `npm test` runs the unit suite (contract, manifest, backend services, frontend client/store/useSettings/ssr,
121
+ the reference page, scaffolder). `npm run test:e2e` runs the backend testcontainers e2e (needs Docker). The
122
+ vertical Playwright proof (`tests/e2e/vertical/`) assembles a sample app (needs a browser + running app).
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Config-fragment voor de user-settings-capability. Een Zod-schema dat de host in zijn eigen app-config-schema
3
+ * mengt (config-kit). Het bouwt voort op het contract-`userSettingsConfigSchema` (de knoppen die de
4
+ * mechanism-laag leest) en voegt surface-only instellingen toe. Bevat GEEN secrets.
5
+ */
6
+ import { z } from 'zod';
7
+ import { userSettingsConfigSchema } from '../../../contract/index.js';
8
+
9
+ /** Het volledige user-settings-config-fragment: de contract-policy + surface-instellingen. */
10
+ export const userSettingsConfigFragment = z.object({
11
+ /** De contract-policy (serverSync, cookiePrefix, renderKeys, cookieMaxAgeDays, onboardingScope). */
12
+ policy: userSettingsConfigSchema.default({}),
13
+ /** Publieke app-basis-URL (optioneel; voor toekomstige links, momenteel ongebruikt door de surface). */
14
+ appBaseUrl: z.string().optional(),
15
+ });
16
+
17
+ export type UserSettingsConfigFragment = z.infer<typeof userSettingsConfigFragment>;
18
+
19
+ /** Parse (met defaults) een ruwe config-invoer tot een `UserSettingsConfigFragment`. */
20
+ export function parseUserSettingsConfig(input: unknown = {}): UserSettingsConfigFragment {
21
+ return userSettingsConfigFragment.parse(input);
22
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * DI-tokens + kleine surface-config-typen voor de user-settings-module. In een apart bestand zodat de
3
+ * controller en de module ze allebei kunnen importeren zonder circulaire import.
4
+ */
5
+ import type { InjectionToken } from '@nestjs/common';
6
+
7
+ export const USER_SETTINGS_MODULE_DEPS: InjectionToken = Symbol.for('user-settings.module-deps');
8
+ export const USER_SETTINGS_SUBJECT_PROVIDER: InjectionToken = Symbol.for('user-settings.subject-provider');
9
+ export const USER_SETTINGS_COOKIE_OPTIONS: InjectionToken = Symbol.for('user-settings.cookie-options');
10
+
11
+ /** Cookie-gedrag voor de CSRF-cookie. `Lax` volstaat (first-party); nooit `HttpOnly` nodig hier. */
12
+ export interface UserSettingsCookieOptions {
13
+ secure: boolean;
14
+ sameSite: 'Strict' | 'Lax' | 'None';
15
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * De NestJS-surface voor de 3 user-settings-server-endpoints (alleen relevant wanneer `serverSync` aanstaat).
3
+ * Puur *plumbing*: resolve het subject via de geïnjecteerde `SubjectProvider`, valideer de body met de
4
+ * contract-Zod-schema's, roep de framework-vrije mechanism-services (backend/src) aan en vertaal naar HTTP.
5
+ *
6
+ * Security-invarianten die hier (en niet in het mechanisme) horen:
7
+ * - elke route eist een geauthenticeerd subject (via SubjectProvider); geen anoniem server-endpoint
8
+ * - CSRF double-submit op state-changing routes; security-headers op elke response
9
+ * - generieke, niet-lekkende foutbodies `{ code, message, fields? }`
10
+ *
11
+ * DI is EXPLICIET via `@Inject(TOKEN)` (geen type-based DI) zodat de controller onder vitest/esbuild draait.
12
+ */
13
+ import { Body, Controller, Get, HttpCode, HttpException, Inject, Param, Post, Put, Req, Res } from '@nestjs/common';
14
+ import { ZodError, type ZodTypeAny, type z } from 'zod';
15
+ import {
16
+ putSettingRequestSchema,
17
+ mergeRequestSchema,
18
+ USER_SETTINGS_ERROR_TAXONOMY,
19
+ type UserSettingsErrorCode,
20
+ } from '../../../contract/index.js';
21
+ import {
22
+ getSettings,
23
+ putSetting,
24
+ mergeSettings,
25
+ UserSettingsModuleError,
26
+ type SubjectProvider,
27
+ type UserSettingsModuleDeps,
28
+ } from '../../src/index.js';
29
+ import { applySecurityHeaders, generateCsrfToken, isCsrfValid, csrfCookieValue, csrfSetCookie } from '../security/security.js';
30
+ import { USER_SETTINGS_MODULE_DEPS, USER_SETTINGS_SUBJECT_PROVIDER, USER_SETTINGS_COOKIE_OPTIONS, type UserSettingsCookieOptions } from './tokens.js';
31
+
32
+ interface SettingsRequest {
33
+ headers: Record<string, string | string[] | undefined>;
34
+ body: unknown;
35
+ }
36
+ interface SettingsResponse {
37
+ setHeader(name: string, value: string): unknown;
38
+ append(name: string, value: string | string[]): unknown;
39
+ }
40
+
41
+ @Controller('settings')
42
+ export class UserSettingsController {
43
+ constructor(
44
+ @Inject(USER_SETTINGS_MODULE_DEPS) private readonly deps: UserSettingsModuleDeps,
45
+ @Inject(USER_SETTINGS_SUBJECT_PROVIDER) private readonly subjectProvider: SubjectProvider,
46
+ @Inject(USER_SETTINGS_COOKIE_OPTIONS) private readonly cookie: UserSettingsCookieOptions,
47
+ ) {}
48
+
49
+ @Get()
50
+ @HttpCode(200)
51
+ async getSettings(@Req() req: SettingsRequest, @Res({ passthrough: true }) res: SettingsResponse) {
52
+ try {
53
+ const subject = await this.subject(req, res, false);
54
+ return await getSettings(this.deps, subject);
55
+ } catch (err) { this.toHttp(err); }
56
+ }
57
+
58
+ @Put(':key')
59
+ @HttpCode(200)
60
+ async putSetting(@Param('key') key: string, @Req() req: SettingsRequest, @Res({ passthrough: true }) res: SettingsResponse) {
61
+ try {
62
+ const subject = await this.subject(req, res, true);
63
+ return await putSetting(this.deps, subject, key, this.parse(putSettingRequestSchema, req.body));
64
+ } catch (err) { this.toHttp(err); }
65
+ }
66
+
67
+ @Post('merge')
68
+ @HttpCode(200)
69
+ async mergeSettings(@Req() req: SettingsRequest, @Res({ passthrough: true }) res: SettingsResponse) {
70
+ try {
71
+ const subject = await this.subject(req, res, true);
72
+ return await mergeSettings(this.deps, subject, this.parse(mergeRequestSchema, req.body));
73
+ } catch (err) { this.toHttp(err); }
74
+ }
75
+
76
+ // --- Interne surface-helpers ---
77
+
78
+ /**
79
+ * Bereid de response voor (security-headers + CSRF-seed + optionele double-submit-controle) en resolve
80
+ * het subject via de host-`SubjectProvider`. Geen/ongeldig subject → generieke `unauthenticated`;
81
+ * mislukte CSRF → `csrf_failed`.
82
+ */
83
+ private async subject(req: SettingsRequest, res: SettingsResponse, csrf: boolean): Promise<string> {
84
+ applySecurityHeaders(res);
85
+ if (csrfCookieValue(req) === undefined) {
86
+ res.append('Set-Cookie', csrfSetCookie(generateCsrfToken(), this.cookie));
87
+ }
88
+ if (csrf && !isCsrfValid(req)) throw new UserSettingsModuleError('csrf_failed');
89
+
90
+ const subject = await this.subjectProvider.resolve(req);
91
+ if (!subject) throw new UserSettingsModuleError('unauthenticated');
92
+ return subject;
93
+ }
94
+
95
+ private parse<S extends ZodTypeAny>(schema: S, body: unknown): z.infer<S> {
96
+ return schema.parse(body);
97
+ }
98
+
99
+ /**
100
+ * Vertaal een fout naar HTTP. Zod → `validation_failed` (422, met veldpaden zonder waarden);
101
+ * `UserSettingsModuleError` → taxonomie-status; al-gevormde `HttpException` blijft; onbekend bubbelt
102
+ * (Nest → 500, geen leak).
103
+ */
104
+ private toHttp(err: unknown): never {
105
+ if (err instanceof HttpException) throw err;
106
+ if (err instanceof ZodError) {
107
+ const fields = err.flatten().fieldErrors as Record<string, string[]>;
108
+ throw new HttpException(
109
+ { code: 'validation_failed', message: USER_SETTINGS_ERROR_TAXONOMY.validation_failed.i18nKey, fields },
110
+ USER_SETTINGS_ERROR_TAXONOMY.validation_failed.httpStatus,
111
+ );
112
+ }
113
+ if (err instanceof UserSettingsModuleError) {
114
+ const descriptor = USER_SETTINGS_ERROR_TAXONOMY[err.code as UserSettingsErrorCode];
115
+ throw new HttpException({ code: err.code, message: err.message }, descriptor.httpStatus);
116
+ }
117
+ throw err;
118
+ }
119
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `UserSettingsModule.forRoot(options)` — de NestJS-surface die de framework-vrije mechanism-laag
3
+ * (backend/src) samenbindt met de pg-keyed-store en de host-`SubjectProvider`. Alleen relevant wanneer
4
+ * `serverSync` aanstaat; een no-auth product wired deze module niet.
5
+ *
6
+ * Er is GEEN module→module-afhankelijkheid: user-settings is kit-only. De host injecteert alleen een
7
+ * persistence-pool, een subject-provider en de descriptor-set (de host-gedeclareerde keys). Alle providers
8
+ * zijn EXPLICIET gewired (geen type-based DI).
9
+ */
10
+ import { Module, type DynamicModule, type Provider } from '@nestjs/common';
11
+ import type { Pool } from '@seifer-webapp-factory/kits/backend/persistence';
12
+ import type { SettingsSchema, UserSettingsEvent, UserSettingsEventSink } from '../../../contract/index.js';
13
+ import {
14
+ assertValidDescriptors,
15
+ type Clock,
16
+ type SubjectProvider,
17
+ type UserSettingsModuleConfig,
18
+ type UserSettingsModuleDeps,
19
+ } from '../../src/index.js';
20
+ import { pgSettingsStore } from '../persistence/pg-settings-store.js';
21
+ import { parseUserSettingsConfig } from '../config/config-fragment.js';
22
+ import { UserSettingsController } from './user-settings.controller.js';
23
+ import {
24
+ USER_SETTINGS_MODULE_DEPS,
25
+ USER_SETTINGS_SUBJECT_PROVIDER,
26
+ USER_SETTINGS_COOKIE_OPTIONS,
27
+ type UserSettingsCookieOptions,
28
+ } from './tokens.js';
29
+
30
+ export {
31
+ USER_SETTINGS_MODULE_DEPS,
32
+ USER_SETTINGS_SUBJECT_PROVIDER,
33
+ USER_SETTINGS_COOKIE_OPTIONS,
34
+ type UserSettingsCookieOptions,
35
+ } from './tokens.js';
36
+
37
+ export interface UserSettingsModuleOptions {
38
+ /** Persistence-kit-pool waarover de pg-keyed-store draait (verplicht). */
39
+ pool: Pool;
40
+ /** Resolve het geauthenticeerde subject-id uit het request (verplicht). */
41
+ subjectProvider: SubjectProvider;
42
+ /** De host-gedeclareerde keys. 'consent'-namespace wordt geweigerd (hoort bij de account-module). */
43
+ descriptors: SettingsSchema;
44
+ /** Ruwe config-invoer (contract-policy + surface); defaults vullen de rest. */
45
+ config?: unknown;
46
+ /** Injecteerbare klok (deterministische versies/timestamps in tests). Default `Date.now`. */
47
+ clock?: Clock;
48
+ /** Event-sink (host-hooks/analytics/audit-log). Default: no-op. */
49
+ events?: UserSettingsEventSink;
50
+ cookie?: Partial<UserSettingsCookieOptions>;
51
+ }
52
+
53
+ const DEFAULT_COOKIE: UserSettingsCookieOptions = { secure: true, sameSite: 'Lax' };
54
+ const NOOP_EVENTS: UserSettingsEventSink = { emit: (_event: UserSettingsEvent): void => undefined };
55
+
56
+ @Module({})
57
+ export class UserSettingsModule {
58
+ static forRoot(options: UserSettingsModuleOptions): DynamicModule {
59
+ requireOption('pool', options.pool);
60
+ requireOption('subjectProvider', options.subjectProvider, ['resolve']);
61
+ if (!Array.isArray(options.descriptors)) {
62
+ throw new Error('UserSettingsModule.forRoot: "descriptors" ontbreekt of is geen array');
63
+ }
64
+ // Handhaaf de contract-invariant: geen 'consent'-namespace hier.
65
+ assertValidDescriptors(options.descriptors);
66
+
67
+ const clock: Clock = options.clock ?? Date.now;
68
+ const fragment = parseUserSettingsConfig(options.config);
69
+ const cookie: UserSettingsCookieOptions = { ...DEFAULT_COOKIE, ...options.cookie };
70
+
71
+ const config: UserSettingsModuleConfig = {
72
+ descriptors: options.descriptors,
73
+ policy: fragment.policy,
74
+ };
75
+
76
+ const deps: UserSettingsModuleDeps = {
77
+ store: pgSettingsStore(options.pool),
78
+ events: options.events ?? NOOP_EVENTS,
79
+ clock,
80
+ config,
81
+ };
82
+
83
+ const providers: Provider[] = [
84
+ { provide: USER_SETTINGS_MODULE_DEPS, useValue: deps },
85
+ { provide: USER_SETTINGS_SUBJECT_PROVIDER, useValue: options.subjectProvider },
86
+ { provide: USER_SETTINGS_COOKIE_OPTIONS, useValue: cookie },
87
+ ];
88
+
89
+ return {
90
+ module: UserSettingsModule,
91
+ controllers: [UserSettingsController],
92
+ providers,
93
+ exports: [USER_SETTINGS_MODULE_DEPS],
94
+ };
95
+ }
96
+ }
97
+
98
+ function requireOption(name: string, value: unknown, methods: string[] = []): void {
99
+ if (value === null || value === undefined || typeof value !== 'object') {
100
+ throw new Error(`UserSettingsModule.forRoot: "${name}" ontbreekt of is geen object`);
101
+ }
102
+ for (const method of methods) {
103
+ if (typeof (value as Record<string, unknown>)[method] !== 'function') {
104
+ throw new Error(`UserSettingsModule.forRoot: "${name}" implementeert "${method}" niet`);
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,16 @@
1
+ -- 0001_user_settings — de keyed settings-store voor de server-adapter.
2
+ -- Eén rij per (subject, key). `version` is server-beheerd en monotoon (ordenings-autoriteit; omzeilt
3
+ -- client-klok-skew). `deleted` is een versioned tombstone: een delete is een write, nooit een row-delete.
4
+
5
+ CREATE TABLE IF NOT EXISTS user_settings (
6
+ subject_id text NOT NULL,
7
+ key text NOT NULL,
8
+ namespace text NOT NULL,
9
+ value jsonb NOT NULL,
10
+ version bigint NOT NULL DEFAULT 1,
11
+ deleted boolean NOT NULL DEFAULT false,
12
+ updated_at timestamptz NOT NULL DEFAULT now(),
13
+ PRIMARY KEY (subject_id, key)
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS user_settings_subject_idx ON user_settings(subject_id);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Schema-migraties voor de user-settings-capability, in het persistence-kit-{@link Migration}-formaat
3
+ * (stabiele id + idempotente up/down). De raw SQL staat naast dit bestand in `0001_user_settings.sql`.
4
+ *
5
+ * `0001_user_settings` legt de keyed store vast: één rij per (subject, key), server-beheerde monotone
6
+ * `version` (ordenings-autoriteit die client-klok-skew omzeilt), en een `deleted`-tombstone-kolom (een
7
+ * delete is een write, nooit een fysieke row-delete).
8
+ */
9
+ import type { Migration } from '@seifer-webapp-factory/kits/backend/persistence';
10
+
11
+ const userSettingsInit: Migration = {
12
+ id: '0001_user_settings',
13
+ async up(ctx) {
14
+ await ctx.execute(`
15
+ CREATE TABLE IF NOT EXISTS user_settings (
16
+ subject_id text NOT NULL,
17
+ key text NOT NULL,
18
+ namespace text NOT NULL,
19
+ value jsonb NOT NULL,
20
+ version bigint NOT NULL DEFAULT 1,
21
+ deleted boolean NOT NULL DEFAULT false,
22
+ updated_at timestamptz NOT NULL DEFAULT now(),
23
+ PRIMARY KEY (subject_id, key)
24
+ )
25
+ `);
26
+ await ctx.execute('CREATE INDEX IF NOT EXISTS user_settings_subject_idx ON user_settings(subject_id)');
27
+ },
28
+ async down(ctx) {
29
+ await ctx.execute('DROP TABLE IF EXISTS user_settings');
30
+ },
31
+ };
32
+
33
+ /** Alle user-settings-migraties in toepassingsvolgorde. */
34
+ export const userSettingsMigrations: Migration[] = [userSettingsInit];
35
+
36
+ /** Tabellen die een test-reset-helper mag leegmaken (exclusief de migratie-ledger). */
37
+ export const USER_SETTINGS_MANAGED_TABLES = ['user_settings'];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Postgres-backed {@link SettingsServerStore} bovenop de persistence-kit-{@link Pool}. `upsert` is een
3
+ * upsert op (subject_id, key) die de `version` server-side monotoon ophoogt (`version = user_settings.version
4
+ * + 1`) — dat is de ordenings-autoriteit die client-klok-skew omzeilt. `value` is `jsonb`; node-pg parset
5
+ * die bij het lezen automatisch terug naar een JS-waarde.
6
+ */
7
+ import type { Pool, Row } from '@seifer-webapp-factory/kits/backend/persistence';
8
+ import type { SettingsServerStore, StoredSetting } from '../../src/index.js';
9
+
10
+ type SettingRow = Row & {
11
+ key: string;
12
+ namespace: string;
13
+ value: unknown;
14
+ version: string | number;
15
+ deleted: boolean;
16
+ updatedAt: string | number;
17
+ };
18
+
19
+ const SELECT =
20
+ 'SELECT key, namespace, value, version, deleted, ' +
21
+ "(extract(epoch from updated_at) * 1000)::bigint AS \"updatedAt\" " +
22
+ 'FROM user_settings';
23
+
24
+ function toRecord(r: SettingRow): StoredSetting {
25
+ return {
26
+ key: r.key,
27
+ namespace: r.namespace,
28
+ value: r.value,
29
+ version: Number(r.version),
30
+ deleted: r.deleted,
31
+ updatedAt: Number(r.updatedAt),
32
+ };
33
+ }
34
+
35
+ export function pgSettingsStore(pool: Pool): SettingsServerStore {
36
+ return {
37
+ async all(subjectId) {
38
+ const { rows } = await pool.execute<SettingRow>(`${SELECT} WHERE subject_id = $1 ORDER BY key`, [subjectId]);
39
+ return rows.map(toRecord);
40
+ },
41
+ async get(subjectId, key) {
42
+ const { rows } = await pool.execute<SettingRow>(`${SELECT} WHERE subject_id = $1 AND key = $2`, [subjectId, key]);
43
+ return rows[0] ? toRecord(rows[0]) : null;
44
+ },
45
+ async upsert(subjectId, entry) {
46
+ const { rows } = await pool.execute<SettingRow>(
47
+ `INSERT INTO user_settings (subject_id, key, namespace, value, version, deleted, updated_at)
48
+ VALUES ($1, $2, $3, $4::jsonb, 1, $5, to_timestamp($6 / 1000.0))
49
+ ON CONFLICT (subject_id, key) DO UPDATE SET
50
+ namespace = EXCLUDED.namespace,
51
+ value = EXCLUDED.value,
52
+ version = user_settings.version + 1,
53
+ deleted = EXCLUDED.deleted,
54
+ updated_at = EXCLUDED.updated_at
55
+ RETURNING key, namespace, value, version, deleted,
56
+ (extract(epoch from updated_at) * 1000)::bigint AS "updatedAt"`,
57
+ [subjectId, entry.key, entry.namespace, JSON.stringify(entry.value ?? null), entry.deleted ?? false, entry.updatedAt],
58
+ );
59
+ return toRecord(rows[0]!);
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Security-helpers voor de user-settings-surface: framework-neutrale security-headers + CSRF double-submit.
3
+ * De schrijf-endpoints (`PUT /settings/:key`, `POST /settings/merge`) wijzigen state, dus elke state-changing
4
+ * route eist een geldig double-submit-CSRF-token. Sessies worden NIET hier uitgegeven — het subject komt via
5
+ * de geïnjecteerde `SubjectProvider` van de host.
6
+ */
7
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
8
+
9
+ /** Naam van de leesbare CSRF-cookie (double-submit). */
10
+ export const CSRF_COOKIE = 'user_settings_csrf';
11
+ /** Header waarin de client het CSRF-token terugspiegelt. */
12
+ export const CSRF_HEADER = 'x-csrf-token';
13
+
14
+ /** Defensieve security-headerset — geen sniffing/clickjacking/referrer-lek, geen caching van state. */
15
+ export const SECURITY_HEADERS: Readonly<Record<string, string>> = Object.freeze({
16
+ 'X-Content-Type-Options': 'nosniff',
17
+ 'X-Frame-Options': 'DENY',
18
+ 'Referrer-Policy': 'no-referrer',
19
+ 'Cross-Origin-Opener-Policy': 'same-origin',
20
+ 'Cross-Origin-Resource-Policy': 'same-origin',
21
+ 'Cache-Control': 'no-store',
22
+ 'Pragma': 'no-cache',
23
+ });
24
+
25
+ export interface HeaderResponseLike {
26
+ setHeader(name: string, value: string): unknown;
27
+ }
28
+ export function applySecurityHeaders(res: HeaderResponseLike): void {
29
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) res.setHeader(name, value);
30
+ }
31
+
32
+ export interface CsrfRequestLike {
33
+ headers: Record<string, string | string[] | undefined>;
34
+ }
35
+
36
+ /** Parse een rauwe `Cookie`-header naar een naam→waarde-map. */
37
+ export function parseCookies(header: string | undefined): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ if (!header) return out;
40
+ for (const part of header.split(';')) {
41
+ const eq = part.indexOf('=');
42
+ if (eq < 0) continue;
43
+ const name = part.slice(0, eq).trim();
44
+ if (name) out[name] = decodeURIComponent(part.slice(eq + 1).trim());
45
+ }
46
+ return out;
47
+ }
48
+
49
+ export function generateCsrfToken(): string {
50
+ return randomBytes(32).toString('base64url');
51
+ }
52
+
53
+ export function csrfCookieValue(req: CsrfRequestLike): string | undefined {
54
+ const raw = req.headers['cookie'];
55
+ const header = Array.isArray(raw) ? raw.join('; ') : raw;
56
+ return parseCookies(header)[CSRF_COOKIE];
57
+ }
58
+ export function csrfHeaderValue(req: CsrfRequestLike): string | undefined {
59
+ const raw = req.headers[CSRF_HEADER];
60
+ return Array.isArray(raw) ? raw[0] : raw;
61
+ }
62
+
63
+ /** Valideer double-submit: cookie én header aanwezig en timing-safe gelijk. */
64
+ export function isCsrfValid(req: CsrfRequestLike): boolean {
65
+ const cookie = csrfCookieValue(req);
66
+ const header = csrfHeaderValue(req);
67
+ if (!cookie || !header) return false;
68
+ const a = Buffer.from(cookie);
69
+ const b = Buffer.from(header);
70
+ if (a.length !== b.length) return false;
71
+ return timingSafeEqual(a, b);
72
+ }
73
+
74
+ /** Bouw de `Set-Cookie`-string voor het (leesbare) CSRF-token. */
75
+ export function csrfSetCookie(value: string, opts: { secure: boolean; sameSite: 'Strict' | 'Lax' | 'None' }): string {
76
+ const parts = [`${CSRF_COOKIE}=${encodeURIComponent(value)}`, 'Path=/', `SameSite=${opts.sameSite}`];
77
+ if (opts.secure) parts.push('Secure');
78
+ return parts.join('; ');
79
+ }
@@ -0,0 +1,11 @@
1
+ import type { SettingDescriptor, SettingsSchema } from '../../contract/index.js';
2
+ /**
3
+ * Valideer de host-descriptorset bij module-init. Een descriptor met een namespace buiten
4
+ * {@link SETTING_NAMESPACES} (bv. 'consent') wordt geweigerd — consent hoort bij de account-module.
5
+ */
6
+ export declare function assertValidDescriptors(descriptors: SettingsSchema): void;
7
+ /** Zoek de descriptor voor een key; onbekend → `unknown_key`. */
8
+ export declare function resolveDescriptor(descriptors: SettingsSchema, key: string): SettingDescriptor;
9
+ /** Valideer een waarde tegen de descriptor-`schema`; faalt → `validation_failed`. Geeft de geparste waarde terug. */
10
+ export declare function validateValue(descriptor: SettingDescriptor, value: unknown): unknown;
11
+ //# sourceMappingURL=descriptors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"descriptors.d.ts","sourceRoot":"","sources":["../../../backend/src/descriptors.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAKjF;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,cAAc,GAAG,IAAI,CAMxE;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAI7F;AAED,qHAAqH;AACrH,wBAAgB,aAAa,CAAC,UAAU,EAAE,iBAAiB,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpF"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Descriptor-hulplaag (mechanism). Deze module is geparametriseerd door een host-descriptor-schema; deze
3
+ * helpers handhaven de invarianten die het contract belooft: 'consent' is geen namespace hier, een
4
+ * onbekende key is `unknown_key`, een waarde die niet tegen de descriptor-`schema` valideert is
5
+ * `validation_failed`, en een opgeslagen waarde van een oudere descriptor-versie draait door `migrate`.
6
+ *
7
+ * NB: bewust een dunne kopie van dezelfde guards aan de frontend-kant (frontend/src) — de twee helften zijn
8
+ * losse bundels. Zo blijft elke adapter zelfstandig de host-descriptors valideren.
9
+ */
10
+ import { SETTING_NAMESPACES } from '../../contract/index.js';
11
+ import { UserSettingsModuleError } from './errors.js';
12
+ const VALID_NAMESPACES = SETTING_NAMESPACES;
13
+ /**
14
+ * Valideer de host-descriptorset bij module-init. Een descriptor met een namespace buiten
15
+ * {@link SETTING_NAMESPACES} (bv. 'consent') wordt geweigerd — consent hoort bij de account-module.
16
+ */
17
+ export function assertValidDescriptors(descriptors) {
18
+ for (const descriptor of descriptors) {
19
+ if (!VALID_NAMESPACES.includes(descriptor.namespace)) {
20
+ throw new UserSettingsModuleError('consent_namespace_rejected');
21
+ }
22
+ }
23
+ }
24
+ /** Zoek de descriptor voor een key; onbekend → `unknown_key`. */
25
+ export function resolveDescriptor(descriptors, key) {
26
+ const descriptor = descriptors.find((d) => d.key === key);
27
+ if (descriptor === undefined)
28
+ throw new UserSettingsModuleError('unknown_key');
29
+ return descriptor;
30
+ }
31
+ /** Valideer een waarde tegen de descriptor-`schema`; faalt → `validation_failed`. Geeft de geparste waarde terug. */
32
+ export function validateValue(descriptor, value) {
33
+ const parsed = descriptor.schema.safeParse(value);
34
+ if (!parsed.success)
35
+ throw new UserSettingsModuleError('validation_failed');
36
+ return parsed.data;
37
+ }
38
+ //# sourceMappingURL=descriptors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"descriptors.js","sourceRoot":"","sources":["../../../backend/src/descriptors.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAEtD,MAAM,gBAAgB,GAAsB,kBAAkB,CAAC;AAE/D;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAAC,WAA2B;IAChE,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACrC,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YACrD,MAAM,IAAI,uBAAuB,CAAC,4BAA4B,CAAC,CAAC;QAClE,CAAC;IACH,CAAC;AACH,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,iBAAiB,CAAC,WAA2B,EAAE,GAAW;IACxE,MAAM,UAAU,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC;IAC1D,IAAI,UAAU,KAAK,SAAS;QAAE,MAAM,IAAI,uBAAuB,CAAC,aAAa,CAAC,CAAC;IAC/E,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,qHAAqH;AACrH,MAAM,UAAU,aAAa,CAAC,UAA6B,EAAE,KAAc;IACzE,MAAM,MAAM,GAAG,UAAU,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAClD,IAAI,CAAC,MAAM,CAAC,OAAO;QAAE,MAAM,IAAI,uBAAuB,CAAC,mBAAmB,CAAC,CAAC;IAC5E,OAAO,MAAM,CAAC,IAAI,CAAC;AACrB,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Foutvertaling: mechanism-fouten gedragen door de gedeelde contract-`UserSettingsErrorCode`-taxonomie.
3
+ * Boodschappen blijven generiek — nooit waarden of PII. Onbekende (niet-herkende) fouten worden NIET
4
+ * geslikt: die bubbelen zodat de surface-laag ze als 500 afhandelt zonder details te lekken.
5
+ */
6
+ import type { UserSettingsErrorCode } from '../../contract/index.js';
7
+ /** Fout van de mechanism-laag, gedragen door een contract-`UserSettingsErrorCode`. */
8
+ export declare class UserSettingsModuleError extends Error {
9
+ readonly code: UserSettingsErrorCode;
10
+ constructor(code: UserSettingsErrorCode, message?: string);
11
+ }
12
+ /**
13
+ * Vertaal een reeds-getypte module-fout door; geef `undefined` terug voor een onbekende fout (die laat de
14
+ * aanroeper bubbelen). Bestaat voor pariteit met de siblings; user-settings heeft geen kit-fouten om te
15
+ * mappen.
16
+ */
17
+ export declare function asModuleError(err: unknown): UserSettingsModuleError | undefined;
18
+ //# sourceMappingURL=errors.d.ts.map