@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.
- package/README.md +122 -0
- package/backend/templates/config/config-fragment.ts +22 -0
- package/backend/templates/nestjs/tokens.ts +15 -0
- package/backend/templates/nestjs/user-settings.controller.ts +119 -0
- package/backend/templates/nestjs/user-settings.module.ts +107 -0
- package/backend/templates/persistence/migrations/0001_user_settings.sql +16 -0
- package/backend/templates/persistence/migrations/index.ts +37 -0
- package/backend/templates/persistence/pg-settings-store.ts +62 -0
- package/backend/templates/security/security.ts +79 -0
- package/dist/backend/src/descriptors.d.ts +11 -0
- package/dist/backend/src/descriptors.d.ts.map +1 -0
- package/dist/backend/src/descriptors.js +38 -0
- package/dist/backend/src/descriptors.js.map +1 -0
- package/dist/backend/src/errors.d.ts +18 -0
- package/dist/backend/src/errors.d.ts.map +1 -0
- package/dist/backend/src/errors.js +29 -0
- package/dist/backend/src/errors.js.map +1 -0
- package/dist/backend/src/index.d.ts +10 -0
- package/dist/backend/src/index.d.ts.map +1 -0
- package/dist/backend/src/index.js +9 -0
- package/dist/backend/src/index.js.map +1 -0
- package/dist/backend/src/ports.d.ts +58 -0
- package/dist/backend/src/ports.d.ts.map +1 -0
- package/dist/backend/src/ports.js +2 -0
- package/dist/backend/src/ports.js.map +1 -0
- package/dist/backend/src/services.d.ts +26 -0
- package/dist/backend/src/services.d.ts.map +1 -0
- package/dist/backend/src/services.js +104 -0
- package/dist/backend/src/services.js.map +1 -0
- package/dist/contract/config.d.ts +35 -0
- package/dist/contract/config.d.ts.map +1 -0
- package/dist/contract/config.js +20 -0
- package/dist/contract/config.js.map +1 -0
- package/dist/contract/endpoints.d.ts +188 -0
- package/dist/contract/endpoints.d.ts.map +1 -0
- package/dist/contract/endpoints.js +21 -0
- package/dist/contract/endpoints.js.map +1 -0
- package/dist/contract/errors.d.ts +22 -0
- package/dist/contract/errors.d.ts.map +1 -0
- package/dist/contract/errors.js +26 -0
- package/dist/contract/errors.js.map +1 -0
- package/dist/contract/events.d.ts +38 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +7 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +11 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +11 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/schemas.d.ts +255 -0
- package/dist/contract/schemas.d.ts.map +1 -0
- package/dist/contract/schemas.js +54 -0
- package/dist/contract/schemas.js.map +1 -0
- package/dist/frontend/src/client.d.ts +22 -0
- package/dist/frontend/src/client.d.ts.map +1 -0
- package/dist/frontend/src/client.js +64 -0
- package/dist/frontend/src/client.js.map +1 -0
- package/dist/frontend/src/descriptors.d.ts +12 -0
- package/dist/frontend/src/descriptors.d.ts.map +1 -0
- package/dist/frontend/src/descriptors.js +44 -0
- package/dist/frontend/src/descriptors.js.map +1 -0
- package/dist/frontend/src/index.d.ts +16 -0
- package/dist/frontend/src/index.d.ts.map +1 -0
- package/dist/frontend/src/index.js +12 -0
- package/dist/frontend/src/index.js.map +1 -0
- package/dist/frontend/src/ssr.d.ts +43 -0
- package/dist/frontend/src/ssr.d.ts.map +1 -0
- package/dist/frontend/src/ssr.js +90 -0
- package/dist/frontend/src/ssr.js.map +1 -0
- package/dist/frontend/src/store.d.ts +46 -0
- package/dist/frontend/src/store.d.ts.map +1 -0
- package/dist/frontend/src/store.js +111 -0
- package/dist/frontend/src/store.js.map +1 -0
- package/dist/frontend/src/useSettings.d.ts +46 -0
- package/dist/frontend/src/useSettings.d.ts.map +1 -0
- package/dist/frontend/src/useSettings.js +100 -0
- package/dist/frontend/src/useSettings.js.map +1 -0
- package/dist/manifest.d.ts +59 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +45 -0
- package/dist/manifest.js.map +1 -0
- package/dist/scaffolder/core/errors.d.ts +24 -0
- package/dist/scaffolder/core/errors.d.ts.map +1 -0
- package/dist/scaffolder/core/errors.js +38 -0
- package/dist/scaffolder/core/errors.js.map +1 -0
- package/dist/scaffolder/core/materialize.d.ts +40 -0
- package/dist/scaffolder/core/materialize.d.ts.map +1 -0
- package/dist/scaffolder/core/materialize.js +38 -0
- package/dist/scaffolder/core/materialize.js.map +1 -0
- package/dist/scaffolder/core/ports.d.ts +28 -0
- package/dist/scaffolder/core/ports.d.ts.map +1 -0
- package/dist/scaffolder/core/ports.js +28 -0
- package/dist/scaffolder/core/ports.js.map +1 -0
- package/dist/scaffolder/core/presence.d.ts +21 -0
- package/dist/scaffolder/core/presence.d.ts.map +1 -0
- package/dist/scaffolder/core/presence.js +20 -0
- package/dist/scaffolder/core/presence.js.map +1 -0
- package/dist/scaffolder/index.d.ts +20 -0
- package/dist/scaffolder/index.d.ts.map +1 -0
- package/dist/scaffolder/index.js +6 -0
- package/dist/scaffolder/index.js.map +1 -0
- package/frontend/templates/i18n/en.json +20 -0
- package/frontend/templates/i18n/nl.json +20 -0
- package/frontend/templates/pages/settings.vue +62 -0
- package/frontend/templates/runtime.ts +19 -0
- 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
|