@consenti/api 0.2.0 → 0.4.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 (79) hide show
  1. package/README.md +578 -143
  2. package/dist/chunk-COR72NFQ.js +2 -0
  3. package/dist/chunk-COR72NFQ.js.map +1 -0
  4. package/dist/chunk-S2IEYESR.js +2 -0
  5. package/dist/chunk-S2IEYESR.js.map +1 -0
  6. package/dist/dashboard/assets/ApiConfig-CZMtYRfK.js +1 -0
  7. package/dist/dashboard/assets/ArchivedProfileList-BlQ2gEWz.js +1 -0
  8. package/dist/dashboard/assets/AuditLog-BpZoAFOe.js +1 -0
  9. package/dist/dashboard/assets/ChangePassword-BZkP2dXQ.js +1 -0
  10. package/dist/dashboard/assets/ConfirmDialog-BDlnfTdx.js +1 -0
  11. package/dist/dashboard/assets/ConsentList-DgJMrhih.js +1 -0
  12. package/dist/dashboard/assets/ConsentTemplateEditor-DA7B5aI_.js +1 -0
  13. package/dist/dashboard/assets/ConsentTemplateList-DMPd2y5s.js +1 -0
  14. package/dist/dashboard/assets/HeadingTagInput-D2S9xDMJ.js +1 -0
  15. package/dist/dashboard/assets/HowItWorks-CJCrdr3o.js +1 -0
  16. package/dist/dashboard/assets/PermissionGate-C69dhwV1.js +1 -0
  17. package/dist/dashboard/assets/PreviewPane-D7mDeBT8.js +38 -0
  18. package/dist/dashboard/assets/ProfileEditor-CSFdOweN.js +2 -0
  19. package/dist/dashboard/assets/ProfileList-C4cIDrM6.js +1 -0
  20. package/dist/dashboard/assets/ProfileVersionHistory-CX5-Oupy.js +3 -0
  21. package/dist/dashboard/assets/RecordDetailModal-DIpGxfqA.js +1 -0
  22. package/dist/dashboard/assets/Reports-B1Ww0ovT.js +1 -0
  23. package/dist/dashboard/assets/RoleList-CQ1GjR0W.js +1 -0
  24. package/dist/dashboard/assets/Select-C_JusbT5.js +1 -0
  25. package/dist/dashboard/assets/Settings-sIMVWQ0V.js +1 -0
  26. package/dist/dashboard/assets/SetupWizard-ChFBxqo3.js +1 -0
  27. package/dist/dashboard/assets/Table-BUxoz0YR.js +1 -0
  28. package/dist/dashboard/assets/TenantList-DCsT7ghb.js +1 -0
  29. package/dist/dashboard/assets/UITemplateEditor-BAaMnuOU.js +2 -0
  30. package/dist/dashboard/assets/UITemplateList-C3UMbnfs.js +1 -0
  31. package/dist/dashboard/assets/UserList-CHGNnS50.js +1 -0
  32. package/dist/dashboard/assets/VendorList-C-sswM1A.js +1 -0
  33. package/dist/dashboard/assets/VisitorList-DNB9Ia72.js +1 -0
  34. package/dist/dashboard/assets/check-D0g4XvmN.js +1 -0
  35. package/dist/dashboard/assets/client-CYntrO2g.js +1 -0
  36. package/dist/dashboard/assets/copy-DEi_W2TM.js +1 -0
  37. package/dist/dashboard/assets/createLucideIcon-DAiYBrnd.js +1 -0
  38. package/dist/dashboard/assets/de-jwngydrC.js +25 -0
  39. package/dist/dashboard/assets/debounce-DlvAAT2V.js +1 -0
  40. package/dist/dashboard/assets/dist-GoiMJCTt.js +1 -0
  41. package/dist/dashboard/assets/es-B6Jp0OPe.js +25 -0
  42. package/dist/dashboard/assets/external-link-CFZxxe3W.js +1 -0
  43. package/dist/dashboard/assets/fr-CshYcQPW.js +25 -0
  44. package/dist/dashboard/assets/index-Cr5M1Qy_.css +2 -0
  45. package/dist/dashboard/assets/index-Tr_BcjkN.js +26 -0
  46. package/dist/dashboard/assets/ja-CgjZNBrc.js +25 -0
  47. package/dist/dashboard/assets/profiles-C_SuoLJD.js +1 -0
  48. package/dist/dashboard/assets/roles-D7L936FV.js +1 -0
  49. package/dist/dashboard/assets/rotate-ccw-clock-6y2NDrJ7.js +1 -0
  50. package/dist/dashboard/assets/search-DKwfLBOv.js +1 -0
  51. package/dist/dashboard/assets/templates-CIyMjskA.js +1 -0
  52. package/dist/dashboard/assets/templates-DY68xNys.js +1 -0
  53. package/dist/dashboard/assets/trash-2-eX9s6x8V.js +1 -0
  54. package/dist/dashboard/assets/trash-O30ycMcz.js +1 -0
  55. package/dist/dashboard/assets/useHashQuery-BDjUPXDR.js +1 -0
  56. package/dist/dashboard/assets/users-BRrNOK0W.js +1 -0
  57. package/dist/dashboard/assets/x-BhZiH8AL.js +1 -0
  58. package/dist/dashboard/assets/zh-CN-BnLZkfBC.js +25 -0
  59. package/dist/dashboard/index.html +4 -2
  60. package/dist/dist-QJYW5KUK.js +2 -0
  61. package/dist/dist-QJYW5KUK.js.map +1 -0
  62. package/dist/index.cjs +90 -101
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +847 -150
  65. package/dist/index.d.ts +847 -150
  66. package/dist/index.js +90 -101
  67. package/dist/index.js.map +1 -1
  68. package/dist/profiles-FCX454HS.js +2 -0
  69. package/dist/profiles-FCX454HS.js.map +1 -0
  70. package/dist/widget/widget.css +1 -1
  71. package/dist/widget/widget.js +1 -1
  72. package/package.json +32 -17
  73. package/dist/dashboard/assets/de-DwE2aQfW.js +0 -5
  74. package/dist/dashboard/assets/es-Dq88TWkB.js +0 -5
  75. package/dist/dashboard/assets/fr-o_Pyg8eR.js +0 -5
  76. package/dist/dashboard/assets/index-BSsYDdb9.js +0 -40
  77. package/dist/dashboard/assets/index-bOTGevzl.css +0 -2
  78. package/dist/dashboard/assets/ja-CwgXYmpD.js +0 -5
  79. package/dist/dashboard/assets/zh-CN-ety9KoQ0.js +0 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @consenti/api
2
2
 
3
- Zero-dependency, GDPR-compliant Consent Management Platform backend for Node.js.
3
+ GDPR-style consent management platform backend for Node.js. No required runtime dependencies — SQLite storage and everything else works out of the box; Mongo/MySQL/Postgres storage and spec-correct IAB TCF encoding are opt-in via optional peer dependencies.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@consenti/api.svg)](https://www.npmjs.com/package/@consenti/api)
6
6
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
@@ -13,8 +13,11 @@ Zero-dependency, GDPR-compliant Consent Management Platform backend for Node.js.
13
13
  - **Three SQLite drivers** — `node:sqlite` (Node 22.5+ built-in, zero install), `node-sqlite3-wasm` (WASM, zero compilation, optional peer dep), `better-sqlite3` (native, optional peer dep)
14
14
  - **One function to mount** — `createConsenti()` returns a router you drop into any Node.js HTTP framework
15
15
  - **Self-contained admin dashboard** — full SPA served at your `basePath`, no separate deploy
16
- - **Multi-regulation** — 76 regulations across EU/EEA, UK, US (22 state laws), India, Brazil, China, Canada, 18 APAC countries, 9 Middle East countries, 14 African countries, and more — with automatic geo-routing via 8 compliance groups
16
+ - **Multi-regulation** — Maintained: GDPR/UK-GDPR, CCPA/CPRA + US state laws, LGPD. Partial: IAB TCF v2.3 (spec-correct encoding needs `tcf.publisherCC` + the optional `@iabtechlabtcf/core` peer dependency) — only relevant if you monetize through programmatic/RTB ad exchanges. In development: DPDPA. Geo-routing and compliance groups extend well beyond that tier (APAC, Middle East, Africa, and more) — see [ECOSYSTEM.md](../../ECOSYSTEM.md) for per-regulation status; contributions to extend or maintain coverage are welcome
17
+
18
+ > **Registration is only needed for programmatic ad monetization.** For first-party analytics/marketing consent, Consenti is fully compliant with zero external registration. Consenti implements the IAB TCF/GPP technical specs but is **not** itself a registered CMP; if you do need TCF/GPP, you must register your own `cmpId` with IAB Europe/MSPA independently before going live — see the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration).
17
19
  - **Pluggable storage** — SQLite (zero-config, default) · PostgreSQL · MySQL · MongoDB
20
+ - **Companion CLI**: [`@consenti/scanner`](../scanner) crawls a site under none/reject-all/accept-all consent states and reports undeclared third-party trackers
18
21
 
19
22
  ---
20
23
 
@@ -77,7 +80,7 @@ On Node 20/21, choose between `node-sqlite3-wasm` (pure WASM, no compilation) or
77
80
 
78
81
  Consenti ships with four geo resolver options. The default requires zero installation; the others are opt-in.
79
82
 
80
- **Bonus:** You can also supply a custom resolver function via `compliance.geoDataProvider` — it receives `{ ip, timezone, language }` and must return `{ country: string | null, region: string | null, locale: string | null }`.
83
+ **Bonus:** You can also supply a custom resolver function via `compliance.geoDataProvider` — it receives `{ ip, timezone, language }` and must return `{ country: string | null, region: string | null, locale: string | null }`, plus an optional `complianceGroup?: string` — when set, that group is used directly, skipping the country/region jurisdiction-map lookup entirely (for a provider that already carries legal-grade jurisdiction data and needs to route into an operator-defined custom group).
81
84
 
82
85
  > **Note:** The default (`'default'`) and `'hosted-geoip-lite'` resolvers are suitable for development and lower-traffic deployments. For high-accuracy production use, choose `'geoip'` or `'maxmind'`.
83
86
 
@@ -109,6 +112,7 @@ npm install maxmind
109
112
 
110
113
  # OR 5. Custom (optional — only needed when geoDataProvider: CountryResolverFn)
111
114
  # Do what it requires, just return `{ country, region, locale }` in supported format
115
+ # (plus an optional `complianceGroup` to skip the jurisdiction-map lookup entirely)
112
116
 
113
117
  ```
114
118
  ---
@@ -129,6 +133,12 @@ const consenti = createConsenti({
129
133
  // Optional
130
134
  uri: 'mongodb://localhost:27017/consenti', // db connection string/uri, only for (driver: 'postgresql' | 'mysql' | 'mongodb')
131
135
  database: 'consenti', // database name when not in above uri
136
+
137
+ // Postgres/MySQL only — connection pool tuning
138
+ poolMax: 10, // max pool connections (default: 10, both drivers' own default)
139
+ statementTimeoutMs: 30_000, // abort a query after N ms — unset by default; a forced
140
+ // default could break a legitimate long-running export
141
+ idleInTransactionTimeoutMs: 30_000, // Postgres only: kill a connection idle inside an open transaction after N ms
132
142
  },
133
143
 
134
144
  // ── Auth ─────────────────────────────────────────────────────────────────────
@@ -136,7 +146,7 @@ const consenti = createConsenti({
136
146
  mode: 'local', // 'local' | 'jwt' | 'oidc' | 'saml' | 'custom'
137
147
  adminEmail: 'user@consenti.dev',
138
148
  adminPassword: process.env.CONSENTI_ADMIN_PASSWORD ?? "Consenti@123",
139
- jwtSecret: process.env.CONSENTI_ADMIN_JWT_SECRET, // auto-generated if omitted (sessions expire on restart)
149
+ masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET, // auto-generated if omitted (sessions expire on restart)
140
150
 
141
151
  // OIDC (Auth0, Keycloak, Google, etc.)
142
152
  // mode: 'oidc',
@@ -144,7 +154,7 @@ const consenti = createConsenti({
144
154
  // issuer: 'https://your-idp.example.com',
145
155
  // clientId: 'your-client-id',
146
156
  // clientSecret: process.env.OIDC_SECRET,
147
- // redirectUri: 'https://app.example.com/consenti/admin/auth/oidc/callback',
157
+ // redirectUri: 'https://app.example.com/consenti/admin/v1/auth/oidc/callback',
148
158
  // claimsMapping: { email: 'email', roles: 'consenti_roles' },
149
159
  // },
150
160
 
@@ -154,7 +164,7 @@ const consenti = createConsenti({
154
164
  // issuer: 'https://idp.example.com',
155
165
  // entryPoint: 'https://idp.example.com/sso/saml',
156
166
  // cert: process.env.SAML_CERT!, // IdP signing cert (PEM, no headers)
157
- // callbackUrl: 'https://app.example.com/consenti/admin/auth/saml/callback',
167
+ // callbackUrl: 'https://app.example.com/consenti/admin/v1/auth/saml/acs',
158
168
  // },
159
169
 
160
170
  // Custom — bring your own authentication function
@@ -181,17 +191,63 @@ const consenti = createConsenti({
181
191
  },
182
192
 
183
193
  // ── Compliance ───────────────────────────────────────────────────────────────
194
+ // Every compliance-program setting lives here — jurisdiction routing, IAB TCF, consent-record
195
+ // signing, and data retention. (Age gate is per-profile now, not a server-wide setting — see
196
+ // the dashboard's Profile Editor Step 1, and the widget's `profile.ageGate`.)
184
197
  compliance: {
185
198
  // Geo-based auto routing (recommended)
186
199
  type: 'auto', // 'auto' = geo-resolve per visitor | ComplianceGroupId = one fixed group globally
187
200
  geoDataProvider: 'default', // 'default' (timezone+language heuristic, zero-dep) | 'hosted-geoip-lite' (no install, calls ipinfo.io) | 'geoip' (optional peer dep) | 'maxmind' (optional peer dep) | CountryResolverFn
188
- autoComplianceMap: 'default',// 'default' = embedded 240-country map | operator object | 'auto' = fetch from complianceMapUrl
189
- // complianceMapUrl: 'https://consenti.dev/data/v1/compliance-map.json', // used when autoComplianceMap: 'auto'
201
+ complianceMap: 'default', // 'default' = embedded 240-country map | a URL string (fetched, refreshed per Cache-Control/Expires, 24h fallback) | an operator-supplied ComplianceMapData object
202
+ // complianceMap: 'https://consenti.dev/data/v1/compliance-map.json',
203
+ // A region under overriddenRegions can set requiresSensitiveOptIn: true — a per-region carve-out
204
+ // (not a different group) denying cpraCategory:'sensitive' cookies by default for that region only.
205
+ // The embedded map sets this for Colorado's CPA already; only takes effect with a geoip/maxmind
206
+ // geoDataProvider (the timezone/language heuristic never resolves a US state).
207
+
208
+ // IAB TCF v2.3
209
+ tcf: {
210
+ enabled: false,
211
+ cmpId: 123, // your IAB-registered CMP ID (required when enabled)
212
+ cmpVersion: 1, // your CMP software version
213
+ publisherCC: 'DE', // ISO 3166-1 alpha-2 publisher country — required for spec-correct
214
+ // binary TC-string encoding; omitted → simplified fallback string
215
+ },
216
+
217
+ // IAB GPP (US National / "usnat" section only)
218
+ gpp: {
219
+ enabled: false,
220
+ cmpId: 123, // must match the widget's compliance.gpp.cmpId
221
+ cmpVersion: 1,
222
+ mspaCoveredTransaction: false, // whether this deployment falls under MSPA signatory obligations — required, no honest default
223
+ mspaOptOutOptionMode: 0, // 0 = not applicable | 1 = yes | 2 = no
224
+ mspaServiceProviderMode: 0,
225
+ },
226
+
227
+ // Signs server-stored consent records, ownership cookies, and parental-consent tokens, and
228
+ // salts hashed IPs — HMAC-SHA256 over each record's core fields at create/update time,
229
+ // hex-encoded into `signature`, checked on `GET /consent/:visitorId/verify`. Independent of
230
+ // the widget's own cookie-signing (`core.cookieSigningKey`, which protects the visitor's
231
+ // local cookie). Auto-generated in memory if omitted — fine for local dev, but every restart
232
+ // invalidates outstanding ownership cookies, in-flight parental-consent tokens, and (if you
233
+ // compare across restarts) hashed-IP continuity. Set this for production.
234
+ dataSigningHash: process.env.CONSENTI_DATA_SIGNING_HASH,
235
+ parentalConsentTokenTtlDays: 7, // expiry window for parental-consent tokens (only meaningful when dataSigningHash is set)
236
+
237
+ // Data retention
238
+ dataRetention: {
239
+ purgeAfterDays: 365, // delete consent records older than N days; runs nightly
240
+ auditLogPurgeAfterDays: 730, // optional, independent from purgeAfterDays — unset = keep forever
241
+ },
190
242
  },
191
243
 
192
244
  // ── S3 profile file sync (optional) ─────────────────────────────────────────
193
- // When enabled, every locale JSON written to disk is also PUT to S3.
194
- // /resolve-profile returns an s3:// URL so the widget can fetch directly from CDN.
245
+ // When enabled, every locale JSON written to disk is also PUT to S3, plus a small
246
+ // pointer.json per compliance group ({ profileId, version }) written on activate —
247
+ // S3 has no symlink equivalent to the local hot-serve swap, so this pointer plays
248
+ // the same role. Consenti's own /resolve-profile route does NOT read from S3 or
249
+ // resolve this pointer — it's a contract for an external CDN/edge function to use
250
+ // if you want reads to bypass the Node process entirely.
195
251
  s3Api: {
196
252
  enabled: false,
197
253
  region: 'us-east-1',
@@ -204,32 +260,13 @@ const consenti = createConsenti({
204
260
  // ── Cache invalidation hook (optional) ──────────────────────────────────────
205
261
  // Called after every profile creation, edit, activation, deactivation, or delete.
206
262
  // isPurge=false = warm the cache at these paths; isPurge=true = purge.
207
- handleCache: (paths: string[], version: number, isPurge: boolean) => {
263
+ handleCache: (paths: string[], profileId: string, isPurge: boolean) => {
208
264
  // paths: array of file paths that were written or removed
209
- // version: current profile version
265
+ // profileId: id of the profile that triggered this write (stable across edits — see Profile Edit History)
210
266
  // isPurge: true when files were removed (deactivate/delete); false when written (activate/create)
211
267
  // Example: purge CDN edge nodes, update nginx cache keys, etc.
212
268
  },
213
269
 
214
- // ── IAB TCF v2.2 ─────────────────────────────────────────────────────────────
215
- tcf: {
216
- enabled: false,
217
- cmpId: 123, // your IAB-registered CMP ID (required when enabled)
218
- cmpVersion: 1, // your CMP software version
219
- },
220
-
221
- // ── Age gate ─────────────────────────────────────────────────────────────────
222
- ageGate: {
223
- enabled: false,
224
- minimumAge: 13, // COPPA = 13; GDPR Article 8 = 16
225
- requireParentalConsent: false,
226
- },
227
-
228
- // ── Data retention ───────────────────────────────────────────────────────────
229
- dataRetention: {
230
- purgeAfterDays: 365, // delete consent records older than N days; runs nightly
231
- },
232
-
233
270
  // ── Multi-tenant ─────────────────────────────────────────────────────────────
234
271
  multiTenant: {
235
272
  enabled: false, // scope all data to tenant from X-Tenant-ID header
@@ -305,7 +342,7 @@ server.listen(3001, () => {
305
342
  ### Express / Connect
306
343
 
307
344
  ```ts
308
- app.use(consenti.router) // as middleware (calls next() for unmatched routes)
345
+ app.use(consenti.router) // as middleware (calls next() for requests outside its basePath)
309
346
  // — or —
310
347
  app.use(consenti.handler) // as a terminal handler (no next())
311
348
  ```
@@ -373,6 +410,46 @@ On first start with JSON, Consenti:
373
410
 
374
411
  Subsequent starts run only pending migrations.
375
412
 
413
+ ### Default Profile Seeding
414
+
415
+ `ProfileService` exposes two idempotent helpers for seeding the 8 built-in compliance groups:
416
+
417
+ ```ts
418
+ // Seed one group (skips if an active profile already exists for that group)
419
+ await profileService.seedDefaultProfile('opt-in')
420
+
421
+ // Seed all 8 groups in parallel
422
+ await profileService.seedAllDefaults()
423
+ ```
424
+
425
+ Each seeded profile is built from the English base in `@consenti/utils/profiles` and automatically includes translated text overlays for **German (de), Spanish (es), French (fr), and Japanese (ja)** — 5 locales total per group, 40 locale files across all 8 groups.
426
+
427
+ Locale coverage per compliance group:
428
+
429
+ | Group | en | de | es | fr | ja |
430
+ |-----------------------------|----|----|----|----|----|
431
+ | `opt-in` (GDPR) | ✓ | ✓ | ✓ | ✓ | ✓ |
432
+ | `opt-out` (CCPA) | ✓ | ✓ | ✓ | ✓ | ✓ |
433
+ | `opt-out-strict` (CPRA) | ✓ | ✓ | ✓ | ✓ | ✓ |
434
+ | `opt-in-dpdpa` (India) | ✓ | ✓ | ✓ | ✓ | ✓ |
435
+ | `opt-in-china` (PIPL) | ✓ | ✓ | ✓ | ✓ | ✓ |
436
+ | `opt-in-brazil` (LGPD) | ✓ | ✓ | ✓ | ✓ | ✓ |
437
+ | `general-privacy-consent` | ✓ | ✓ | ✓ | ✓ | ✓ |
438
+ | `notice-only` | ✓ | ✓ | ✓ | ✓ | ✓ |
439
+
440
+ All 5 locales — English base plus the de/es/fr/ja overlays — live in `packages/utils/src/profiles/`, the single source of truth for default compliance-profile content shared by profile seeding, the dashboard's "Load Defaults", and `@consenti/ui`'s embedded fallback profile (English only there — see below).
441
+
442
+ ### First-Run Setup Wizard
443
+
444
+ The dashboard shows a 4-step setup wizard (`#/setup`) once — the first time any admin logs in after install — like a self-hosted app's installer (WordPress, phpMyAdmin, etc.):
445
+
446
+ 1. **Welcome** — links to documentation and the in-dashboard "How Consenti Works" page. Skippable.
447
+ 2. **Your configuration** — a plain, read-only view of the fully merged `ConsentiServerConfig` (your config + env vars merged over the built-in defaults — exactly what `createConsenti` resolved at boot), with secrets redacted.
448
+ 3. **Default compliance profiles** — an accordion of the 8 built-in compliance groups (label, description, and badges for GPC mode / TCF / CPRA categories / DPDPA disclosure), all checked by default. Continuing calls the same idempotent `seedDefaultProfile`/`seedAllDefaults` helpers described above.
449
+ 4. **Confirmation** — a summary of what was installed, plus a production-readiness panel that surfaces the JSON-storage-driver and default-credentials warnings `createConsenti` already logs to the server console (otherwise invisible to a dashboard-only admin). CTAs: **Go to Dashboard** / **See How Consenti Works**.
450
+
451
+ Gated by `tenant_settings.setup_completed` (per-tenant in multi-tenant mode) — set once the wizard finishes **or** is skipped, and never reset from the dashboard afterward. There is no "run it again" entry point; it's a one-time first-run flow, not a recurring settings screen: navigating to `#/setup` directly once setup is complete redirects to the dashboard instead of reopening it, and the two mutating routes (`seed-profiles`, `complete`) reject with `409` if called again after completion — enforced both in the router and on the server, not just by hiding the nav entry. Backed by 5 admin routes under `/setup/*` (see [Admin REST API](#admin-rest-api)).
452
+
376
453
  ---
377
454
 
378
455
  ## Storage Directory Structure
@@ -393,19 +470,17 @@ ${storage.path}/
393
470
  default.json # copy of defaultLocale content (fallback)
394
471
  2/ # version 2 written on next save
395
472
  …
396
- ${complianceGroup}/ # hot-serve path (active profile only)
397
- en.json
398
- fr-FR.json
399
- default.json
473
+ ${complianceGroup}/ # → symlink to the active profile's version dir (hot-serve path)
400
474
  logs/ # reserved for future structured logging
401
475
  ```
402
476
 
403
477
  **Key rules:**
404
478
  - Every profile save writes to a new immutable version directory under `${profileId}/${version}/`
405
- - `${complianceGroup}/` only exists when a profile is **active** for that group
406
- - On **activate**: locale files are copied from `${profileId}/${currentVersion}/` → `${complianceGroup}/`
407
- - On **deactivate** / **delete**: `${complianceGroup}/` files are removed
479
+ - `${complianceGroup}/` only exists when a profile is **active** for that group — it's a directory symlink (junction on Windows), not a copy
480
+ - On **activate**: `${complianceGroup}/` is atomically repointed at `${profileId}/${version}/` — one filesystem rename, no window where it's missing or a mix of two versions, and no leftover files from a version whose locale set has since shrunk
481
+ - On **deactivate** / **delete**: the `${complianceGroup}/` symlink is removed (the version directory it pointed to is untouched)
408
482
  - `default.json` = full resolved profile for `defaultLocale`; used as locale fallback (303 redirect)
483
+ - Windows: symlinks are created as directory **junctions** — no admin rights or Developer Mode required, but the volume must be NTFS and `storage.path` must stay on the same drive
409
484
 
410
485
  **Backward compat:** If `storage.path` has a file extension (`.json`, `.db`), the parent directory is used and a deprecation warning is logged.
411
486
 
@@ -444,23 +519,35 @@ Cache headers: `public, max-age=3600, stale-while-revalidate=60` — suitable fo
444
519
  ### Resolve profile (for `compliance.type: 'auto'`)
445
520
 
446
521
  ```http
447
- GET /consenti/api/v1/resolve-profile?tz=Europe/Paris&lang=fr-FR&locale=fr-FR
522
+ GET /consenti/api/v1/resolve-profile?data=<base64-encoded-GeoHints>
523
+ ```
524
+
525
+ For widgets using `compliance.type: 'auto'` — called once on page load to discover which compliance group and static profile file to serve. The widget sends browser geo hints encoded as base64 JSON:
526
+
527
+ ```js
528
+ // Widget encodes automatically via encodeGeoHints()
529
+ const data = btoa(JSON.stringify({
530
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
531
+ languages: navigator.languages,
532
+ language: navigator.language,
533
+ locale: 'fr-FR',
534
+ }))
448
535
  ```
449
536
 
450
- For widgets using `compliance.type: 'auto'` — call this once on page load to discover which compliance group and locale the visitor should receive:
537
+ Response:
451
538
 
452
539
  ```json
453
540
  {
454
541
  "path": "/consenti/api/v1/profiles/default/opt-in/fr-FR",
455
- "resolvedLocale": "fr-FR",
456
- "resolvedComplianceGroup": "opt-in",
457
- "profileId": "gdpr-profile-uuid",
458
- "version": 3,
459
- "warning": "locale_not_found" // only present when locale was unavailable; path points to default
542
+ "complianceGroup": "opt-in",
543
+ "locale": "fr-FR",
544
+ "found": true
460
545
  }
461
546
  ```
462
547
 
463
- Query params: `tz` (IANA timezone), `lang` (Accept-Language value), `locale` (preferred BCP 47 locale).
548
+ When `found` is `false` (no active profile for this tenant/group), `path` is `null` and the widget automatically falls back to the embedded profile for the resolved `complianceGroup`. This means the widget works even before you have seeded profiles for every compliance group.
549
+
550
+ Legacy query params `?tz=`, `?lang=`, `?locale=` are still accepted for backward compatibility — the `data` param takes priority when both are present.
464
551
 
465
552
  ### Submit consent
466
553
 
@@ -476,13 +563,16 @@ Content-Type: application/json
476
563
  "marketing": "denied"
477
564
  },
478
565
  "visitorId": "uuid-v4",
479
- "profileVersion": 3,
480
566
  "locale": "en",
481
567
  "gpcDetected": false,
482
- "source": "banner"
568
+ "source": "banner",
569
+ "ageVerified": true,
570
+ "parentalConsentToken": "pcon_..."
483
571
  }
484
572
  ```
485
573
 
574
+ `ageVerified`/`parentalConsentToken` are optional — only sent by the widget when the active profile's `ageGate.enabled: true` (a per-profile, per-locale dashboard setting — see the `@consenti/ui` README's "Age Gate" section). Both are stored as-is; this endpoint doesn't validate or enforce them.
575
+
486
576
  Response `201`:
487
577
 
488
578
  ```json
@@ -490,7 +580,6 @@ Response `201`:
490
580
  "id": "record-uuid",
491
581
  "visitorId": "visitor-uuid",
492
582
  "profileId": "my-profile-id",
493
- "profileVersion": 3,
494
583
  "locale": "en",
495
584
  "consentJson": { "necessary": "granted", "analytics": "granted", "marketing": "denied" },
496
585
  "gpcDetected": false,
@@ -510,28 +599,66 @@ GET /consenti/api/v1/consent/visitor-uuid/verify
510
599
  { "valid": true }
511
600
 
512
601
  // or, when stale:
513
- { "valid": false, "reason": "profile_version_mismatch" }
514
- // other reasons: "consent_expired" | "missing_cookies"
602
+ { "valid": false, "reason": "profile_changed" }
603
+ // other reasons: "consent_expired" | "hmac_invalid"
604
+ ```
605
+
606
+ `profile_changed` means the profile this consent was collected against is no longer the active profile for its compliance group (a newer edit has superseded it) — every profile edit mints a new id rather than mutating in place, so an id mismatch is what signals staleness now (there is no separate version counter).
607
+
608
+ ### Parental consent request/resolve
609
+
610
+ See "Parental consent request/resolve (stateless hook)" under Security below for the full design and caveats.
611
+
612
+ ```http
613
+ POST /consenti/api/v1/consent/visitor-uuid/parental-consent-request
614
+ Cookie: consenti_visitor-uuid=...
615
+
616
+ { "profileId": "my-profile-id" }
617
+ ```
618
+
619
+ ```json
620
+ { "token": "pcon_eyJ2aXNpdG9ySWQiOi...ab12cd34..." }
621
+ ```
622
+
623
+ ```http
624
+ POST /consenti/api/v1/consent/parental-consent-resolve
625
+
626
+ { "token": "pcon_eyJ2aXNpdG9ySWQiOi...ab12cd34..." }
627
+ ```
628
+
629
+ ```json
630
+ { "visitorId": "visitor-uuid", "profileId": "my-profile-id" }
631
+
632
+ // or, on an invalid/expired/tampered token:
633
+ // 403 { "error": "Invalid or expired token (invalid_signature)" }
515
634
  ```
516
635
 
517
636
  ---
518
637
 
519
638
  ## Admin REST API
520
639
 
521
- Base path: `/consenti/admin`. Every route requires `Authorization: Bearer <jwt>`.
640
+ Base path: `/consenti/admin/v1` (change with `basePath` in config). Every route requires `Authorization: Bearer <jwt>`.
522
641
 
523
- Obtain a token via `POST /consenti/admin/auth/login`.
642
+ Obtain a token via `POST /consenti/admin/v1/auth/login`.
524
643
 
525
644
  ### Auth
526
645
 
527
- | Method | Path | Description |
528
- |--------|-------------------|-----------------------------------|
529
- | `POST` | `/auth/login` | Authenticate — returns a JWT |
530
- | `GET` | `/auth/me` | Get current authenticated user |
531
- | `POST` | `/auth/logout` | Invalidate session |
646
+ | Method | Path | Description |
647
+ |--------|-------------------------|-----------------------------------|
648
+ | `POST` | `/auth/login` | Authenticate (mode `'local'` only) — returns a JWT |
649
+ | `GET` | `/auth/me` | Get current authenticated user (includes `totpEnabled`) |
650
+ | `POST` | `/auth/logout` | Invalidate session |
651
+ | `POST` | `/auth/refresh` | Reissue a fresh token from the current one — extends the session |
652
+ | `GET` | `/auth/oidc/authorize` | Start OIDC authorization (PKCE) — 302-redirects to the IdP (mode `'oidc'` only) |
653
+ | `GET` | `/auth/oidc/callback` | OIDC redirect target — exchanges the code for tokens, verifies the ID token, upserts the admin user from claims, returns a JWT |
654
+ | `GET` | `/auth/saml/metadata` | SAML SP metadata XML for your IdP config (mode `'saml'` only) |
655
+ | `POST` | `/auth/saml/acs` | SAML Assertion Consumer Service — validates the `SAMLResponse`, upserts the admin user, returns a JWT |
656
+ | `POST` | `/auth/totp/setup` | Generate a TOTP secret for the current user and a QR-code URL (`totpEnabled` stays `false` until verified) |
657
+ | `POST` | `/auth/totp/verify` | Verify a TOTP code against the pending secret and set `totpEnabled: true` |
658
+ | `POST` | `/auth/totp/disable` | Clear the TOTP secret and set `totpEnabled: false` |
532
659
 
533
660
  ```http
534
- POST /consenti/admin/auth/login
661
+ POST /consenti/admin/v1/auth/login
535
662
  Content-Type: application/json
536
663
 
537
664
  { "email": "admin@example.com", "password": "your-password" }
@@ -541,21 +668,41 @@ Content-Type: application/json
541
668
  { "token": "eyJhbGciOiJIUzI1NiJ9..." }
542
669
  ```
543
670
 
671
+ **Session length**: admin tokens are valid for 30 minutes from issuance, not a flat expiry from
672
+ login — the dashboard calls `POST /auth/refresh` on user activity (mouse/keyboard/scroll/touch) to
673
+ extend it another 30 minutes, and proactively logs the user out the moment 30 minutes pass with no
674
+ activity at all, rather than leaving the session to silently lapse in the background.
675
+
676
+ **SSO (OIDC / SAML)**: set `auth.mode: 'oidc'` (with `auth.oidc: { issuer, clientId, clientSecret,
677
+ redirectUri, claimsMapping? }`) or `auth.mode: 'saml'` (with `auth.saml: { issuer, entryPoint, cert,
678
+ callbackUrl }`) — see the `auth` block in [Full Configuration](#full-configuration). Both flows
679
+ upsert (create-if-missing) an admin user by email on first successful login and, for OIDC, assign
680
+ roles from `claimsMapping.roles` when the IdP's claim matches an existing role name; there is no
681
+ separate admin-created-user requirement for SSO logins. `POST /auth/login` (local email/password)
682
+ returns `400` when `auth.mode` isn't `'local'`.
683
+
684
+ **TOTP (per-user MFA)**: each admin user can independently enable TOTP via `setup` → scan the QR
685
+ code → `verify`. This is opt-in per user and layered on top of whichever `auth.mode` is active; it
686
+ is not currently enforced as a required second factor on `POST /auth/login` itself — treat it as
687
+ available account-hardening, not a login gate, until wired into the login flow.
688
+
544
689
  ### Profiles
545
690
 
546
691
  | Method | Path | Description |
547
692
  |------------|----------------------------------|-------------------------------------------------------------------------|
548
693
  | `GET` | `/profiles` | List all profiles |
549
694
  | `GET` | `/profiles?summary=1` | List profiles as `ProfileSummary[]` (no blob, with template names) |
550
- | `POST` | `/profiles` | Create a profile — 422 on compliance errors; `{ conflict, requiresChoice: true }` when an active profile exists on the same `complianceGroup` |
695
+ | `POST` | `/profiles` | Create a profile — 422 on missing mandatory content or compliance errors; `{ conflict, requiresChoice: true }` when an active profile exists on the same `complianceGroup` |
551
696
  | `GET` | `/profiles/:id` | Get a profile |
552
- | `PUT` | `/profiles/:id` | Update a profile (auto-bumps version) — same compliance validation + conflict detection |
697
+ | `PUT` | `/profiles/:id` | Edit a profile — mutates the row **in place**, increments `version`; returned `id` always matches the URL's. Same mandatory-content + compliance validation + conflict detection |
553
698
  | `DELETE` | `/profiles/:id` | Delete a profile |
699
+ | `POST` | `/profiles/:id/copy` | Duplicate a profile as a new, always-inactive profile (`version` resets to 1). Optional `{ name }` body, defaults to `Copy of {name}` |
554
700
  | `POST` | `/profiles/:id/activate` | Activate a profile — writes locale JSON files to `${complianceGroup}/` |
555
701
  | `POST` | `/profiles/:id/deactivate` | Deactivate a profile — removes `${complianceGroup}/` locale files |
556
- | `GET` | `/profiles/:id/versions` | List version history entries (`{ version, createdAt, locales[] }[]`) |
557
- | `GET` | `/profiles/:id/versions/:n` | Read a specific version locale file (query: `?locale=en`) |
558
- | `POST` | `/profiles/validate` | Validate cookie template against a compliance group (no save) |
702
+ | `GET` | `/profiles/:id/versions` | List every version snapshot on disk, newest first (`{ version, createdAt, locales[] }[]`) |
703
+ | `GET` | `/profiles/:id/versions/:entryId` | Read a specific version's locale file, `:entryId` is its `version` number (query: `?locale=en`) |
704
+ | `GET` | `/profiles/archived` | List profile-id directories on disk with no matching DB row (deleted profiles whose version-snapshot history survives) — `ArchivedProfileSummary[]` |
705
+ | `POST` | `/profiles/validate` | Validate a consent template (`cookies` + `categories`) against a compliance group (no save) |
559
706
  | `GET` | `/compliance-coverage` | Active profile (or null) per compliance group — powers coverage panel |
560
707
 
561
708
  #### Profile Save Conflict Detection
@@ -586,41 +733,44 @@ The dashboard profile editor shows a three-button popup when `requiresChoice: tr
586
733
  name: string
587
734
  defaultLocale: string
588
735
  complianceGroup: ComplianceGroupId | null
589
- version: number
736
+ customComplianceGroup: string | null
590
737
  isActive: boolean
591
- cookieTemplateName: string | null
738
+ consentTemplateName: string | null
592
739
  uiTemplateName: string | null
593
740
  createdAt: string
594
741
  updatedAt: string
595
742
  }
596
743
  ```
597
744
 
598
- ### Cookie Templates
745
+ ### Consent Templates
746
+
747
+ A Consent Template merges what used to be two concerns — the parameter (cookie) list and its categories — into one entity. Categories own legal basis; a parameter's effective legal basis is derived from whichever category lists it (a parameter must belong to exactly one category).
599
748
 
600
749
  | Method | Path | Description |
601
750
  |------------|---------------------------------------------|----------------------------------------------------------|
602
- | `GET` | `/cookie-templates` | List cookie templates |
603
- | `GET` | `/cookie-templates/:id` | Get a cookie template |
604
- | `POST` | `/cookie-templates` | Create a cookie template |
605
- | `PUT` | `/cookie-templates/:id` | Update a cookie template — 422 if removing cookies breaks compliance for a dependent profile |
606
- | `DELETE` | `/cookie-templates/:id` | Delete a cookie template — 422 if any active profiles use it |
607
- | `POST` | `/cookie-templates/:id/copy` | Duplicate a cookie template |
608
- | `GET` | `/cookie-templates/:id/profile-usage` | List profiles using this template (`ProfileSummary[]`) |
751
+ | `GET` | `/consent-templates` | List consent templates |
752
+ | `GET` | `/consent-templates/:id` | Get a consent template |
753
+ | `POST` | `/consent-templates` | Create a consent template — `{ name, cookies: CookieMap, categories: CategoryMap }` |
754
+ | `PUT` | `/consent-templates/:id` | Update a consent template — 422 if the change breaks compliance for a dependent profile |
755
+ | `DELETE` | `/consent-templates/:id` | Delete a consent template — 422 if any active profiles use it |
756
+ | `POST` | `/consent-templates/:id/copy` | Duplicate a consent template |
757
+ | `GET` | `/consent-templates/:id/profile-usage` | List profiles using this template (`ProfileSummary[]`) |
609
758
 
610
- #### Cookie Template Safety Guards
759
+ `CookieMap`/`CategoryMap` are keyed by id (`Record<string, Cookie>` / `Record<string, Category>`) — the id is the map key, not a field on the value.
611
760
 
612
- **Deletion guard:** `DELETE /cookie-templates/:id` returns `422` with `{ activeProfiles: ProfileSummary[] }` when one or more active profiles reference the template. Deactivate those profiles first, then delete.
761
+ #### Consent Template Safety Guards
613
762
 
614
- **Cookie removal guard:** When updating a template (`PUT`), if cookies are removed, the backend checks all profiles using the template and runs compliance validation with the new cookie set. If any profile's `complianceGroup` requires a cookie that was removed, the response is `422` with:
763
+ **Deletion guard:** `DELETE /consent-templates/:id` returns `422` with `{ profiles: ProfileSummary[] }` when one or more active profiles reference the template. Deactivate those profiles first, then delete.
764
+
765
+ **Compliance guard:** When updating a template (`PUT`) with new `cookies` and/or `categories`, the backend re-runs compliance validation (using the *new* set) against every profile currently using the template. If any profile's `complianceGroup` rules would be violated, the response is `422` with:
615
766
 
616
767
  ```json
617
768
  {
618
- "blockingProfiles": [{ "id": "...", "name": "...", "complianceGroup": "opt-in" }],
619
- "removedCookieIds": ["analytics", "marketing"]
769
+ "blockingProfiles": [{ "id": "...", "name": "...", "complianceGroup": "opt-in" }]
620
770
  }
621
771
  ```
622
772
 
623
- Profiles listed in `blockingProfiles` must be updated (switch template or change complianceGroup) before the cookie removal can proceed.
773
+ Profiles listed in `blockingProfiles` must be updated (switch template or change complianceGroup) before the change can be saved.
624
774
 
625
775
  ### UI Templates
626
776
 
@@ -660,7 +810,7 @@ Query params: `tenantId`, `profileId`, `complianceGroup`, `from` (ISO date), `to
660
810
  | `GET` | `/consents/:visitorId` | Get consent record for a visitor |
661
811
  | `GET` | `/consents/:visitorId/history` | Get consent change history |
662
812
 
663
- Query params for `GET /consents`: `page`, `limit`, `profileId`, `from`, `to`.
813
+ Query params for `GET /consents`: `page`, `limit` (max 500), `profileId`, `from`, `to`, `q` (**prefix** search across visitorId, profileId, locale, source — matches from the start of the field, not a substring anywhere in it). Response: `{ items, total, page, limit }`. List rows omit `consentJson`/`parentalConsentToken`/`tcfString`/`signature` — fetch `GET /consents/:visitorId` for the full record.
664
814
 
665
815
  ### Visitors
666
816
 
@@ -668,17 +818,25 @@ Query params for `GET /consents`: `page`, `limit`, `profileId`, `from`, `to`.
668
818
  |--------|----------------|-------------------------------------|
669
819
  | `GET` | `/visitors` | List visitor records (paginated) |
670
820
 
671
- IPs are never stored raw — only SHA-256 hashes appear in the `ipHash` field.
821
+ Query params for `GET /visitors`: `page`, `limit` (max 500), `from`, `to`, `q` (**prefix** search across visitorId, country). Response: `{ items, total, page, limit }`.
822
+
823
+ IPs are never stored raw — masked (last IPv4 octet / last 80 bits of IPv6 zeroed), salted with `compliance.dataSigningHash`, and SHA-256 hashed into the `ipHash` field.
672
824
 
673
825
  ### Users & Roles
674
826
 
675
827
  | Method | Path | Description |
676
828
  |------------|-----------------------------------|------------------------------------------------------|
677
829
  | `GET` | `/users` | List admin users |
830
+ | `GET` | `/users/:id` | Get an admin user |
678
831
  | `POST` | `/users` | Create an admin user |
679
832
  | `PUT` | `/users/:id` | Update an admin user |
833
+ | `DELETE` | `/users/:id` | Delete an admin user |
834
+ | `POST` | `/users/:id/roles` | Assign a role to a user |
835
+ | `DELETE` | `/users/:id/roles/:roleId` | Revoke a role from a user |
680
836
  | `GET` | `/roles` | List roles |
681
837
  | `POST` | `/roles` | Create a role |
838
+ | `PUT` | `/roles/:id` | Update a role |
839
+ | `DELETE` | `/roles/:id` | Delete a role |
682
840
  | `GET` | `/roles/:id/permissions` | Get permissions for a role |
683
841
  | `POST` | `/roles/:id/permissions` | Assign a permission to a role |
684
842
  | `DELETE` | `/roles/:id/permissions/:permId` | Revoke a permission from a role |
@@ -706,11 +864,57 @@ The **Users** dashboard page includes an **Edit** button on each user row (visib
706
864
 
707
865
  ### API Keys
708
866
 
709
- | Method | Path | Description |
710
- |------------|-----------------|------------------------------------------------------|
711
- | `GET` | `/apikeys` | List API keys |
712
- | `POST` | `/apikeys` | Create an API key (raw key returned once — save it!) |
713
- | `DELETE` | `/apikeys/:id` | Revoke an API key |
867
+ | Method | Path | Description |
868
+ |------------|----------------------------|------------------------------------------------------------------|
869
+ | `GET` | `/apikeys` | List API keys |
870
+ | `POST` | `/apikeys` | Create an API key (raw key returned once — save it!) |
871
+ | `DELETE` | `/apikeys/:id` | Revoke an API key (soft — can be undone with `reactivate`) |
872
+ | `POST` | `/apikeys/:id/reactivate` | Re-enable a revoked key — same hash, no new secret to distribute |
873
+ | `DELETE` | `/apikeys/:id/permanent` | Permanently delete the key row — cannot be undone |
874
+
875
+ ### Settings
876
+
877
+ Tenant-wide dashboard settings — the Public and Admin API origin allowlists shown on the API
878
+ Config page (split into two panels, one per API).
879
+
880
+ | Method | Path | Description |
881
+ |----------|-------------|-------------------------------------|
882
+ | `GET` | `/settings` | Get this tenant's settings (`{}` if none set yet) |
883
+ | `PATCH` | `/settings` | Update settings (partial — only sends fields being changed) |
884
+
885
+ ```ts
886
+ // GET /consenti/admin/v1/settings
887
+ { "allowedOrigins": ["https://example.com"], "adminAllowedOrigins": ["https://dashboard.example.com"] }
888
+
889
+ // PATCH /consenti/admin/v1/settings
890
+ { "allowedOrigins": ["https://example.com", "https://foo.example.com"] }
891
+ ```
892
+
893
+ **`allowedOrigins`** is the **fallback** used by `POST /consenti/api/v1/consent`'s origin check
894
+ when the specific profile being submitted to doesn't set its own `profileJson.allowedOrigins`
895
+ (profile-level, if present, always takes precedence). The public API has no auth token, so this
896
+ is its only access gate.
897
+
898
+ **`adminAllowedOrigins`** is an additional CORS-layer check on top of Bearer-token auth for
899
+ browser-originated `/consenti/admin/v1/*` requests (server-to-server callers without an `Origin`
900
+ header are unaffected). Unauthenticated static assets (`widget.js`/`widget.css`) are exempt —
901
+ they must stay embeddable from any origin. **Be careful**: if you configure this, include the
902
+ dashboard's own origin or you'll lock yourself out of the dashboard along with everyone else.
903
+
904
+ Both lists accept full origins (`https://example.com`) or a wildcard subdomain pattern
905
+ (`*.example.com`). Empty/unset means no restriction — every origin is allowed.
906
+
907
+ ### Setup Wizard
908
+
909
+ Backs the dashboard's one-time first-run wizard (see [First-Run Setup Wizard](#first-run-setup-wizard)). All routes require `settings:update`, same as Settings above.
910
+
911
+ | Method | Path | Description |
912
+ |--------|-------------------------------|--------------------------------------------------------------------|
913
+ | `GET` | `/setup/status` | `{ completed: boolean }` — whether this tenant has finished/skipped the wizard |
914
+ | `GET` | `/setup/config` | Resolved `ConsentiServerConfig` (secrets redacted) plus `usingJsonStorage`/`usingDefaultCredentials` readiness flags |
915
+ | `GET` | `/setup/compliance-groups` | The 8 built-in compliance groups with label/description/regulation metadata |
916
+ | `POST` | `/setup/seed-profiles` | `{ groups: string[] }` — seeds default profiles for the given groups (subset of the 8 ids; 400 on an unknown id) |
917
+ | `POST` | `/setup/complete` | Marks the wizard complete — called on both finish and skip; never reset from the dashboard afterward |
714
918
 
715
919
  ### Stats & Reporting
716
920
 
@@ -728,9 +932,10 @@ The **Users** dashboard page includes an **Edit** button on each user row (visib
728
932
 
729
933
  ### Audit Log
730
934
 
731
- | Method | Path | Description |
732
- |--------|-----------|---------------------------------------------------------|
733
- | `GET` | `/audit` | Paginated audit log; filter by `action`, `resourceType`, date range |
935
+ | Method | Path | Description |
936
+ |--------|--------------|---------------------------------------------------------|
937
+ | `GET` | `/audit` | Paginated audit log; filter by `action`, `resourceType`, date range, `q` (**prefix** search across action, resourceType, resourceId, userId). Response: `{ items, total, page, limit }`; list rows omit `oldData`/`newData` — fetch `GET /audit/:id` for the full entry. |
938
+ | `GET` | `/audit/:id` | Full audit log entry (`oldData`/`newData`) — list rows omit these for performance; fetch on demand |
734
939
 
735
940
  ### Multi-Tenant
736
941
 
@@ -745,12 +950,37 @@ Only active when `multiTenant.enabled: true`.
745
950
 
746
951
  ### IAB TCF
747
952
 
748
- | Method | Path | Description |
749
- |--------|-------------------|---------------------------|
750
- | `GET` | `/tcf/vendors` | List IAB TCF vendors |
751
- | `GET` | `/tcf/purposes` | List IAB TCF purposes |
752
-
753
- Only active when `tcf.enabled: true`.
953
+ | Method | Path | Description |
954
+ |--------|-----------------------------|----------------------------|
955
+ | `GET` | `/tcf/vendors` | List IAB TCF vendors (paginated, `?q=` name filter) |
956
+ | `GET` | `/tcf/purposes` | List IAB TCF purposes |
957
+ | `GET` | `/tcf/registration-status` | Draft/confirmed diff plus a live IAB CMP-List lookup for `cmpId` (`?refresh=true` bypasses the 7-day cache) — powers the dashboard's TCF Registration panel |
958
+ | `POST` | `/tcf/confirm-registration` | `{ acknowledge: true }` — records the confirmation as a hash of `cmpId`/`cmpVersion`/`publisherCC` (never the raw values); `409` if IAB's CMP List shows `cmpId` deregistered, `404` if not found yet |
959
+
960
+ Only active when `tcf.enabled: true`. `cmpId`/`cmpVersion` here must match the widget's own
961
+ `compliance.tcf` config (`@consenti/ui` README, "TCF" section). The widget installs
962
+ `window.__tcfapi` itself; these admin-only routes are for the dashboard's vendor/purpose pickers
963
+ and registration-status panel, not consumed by the public widget.
964
+
965
+ Set `tcf.publisherCC` (your ISO 3166-1 alpha-2 publisher country code) and install the optional
966
+ `@iabtechlabtcf/core` peer dependency to get spec-correct binary TC-string encoding on consent
967
+ records; without both, `tcfString` falls back to the simplified base64url-JSON format the widget
968
+ also uses. See the [TCF & GPP Registration Guide](https://consenti.dev/docs/compliance/tcf-and-gpp-registration) for details.
969
+
970
+ ### IAB GPP (US National)
971
+
972
+ | Method | Path | Description |
973
+ |--------|------------------------------|----------------------------|
974
+ | `GET` | `/gpp/registration-status` | Draft/confirmed diff for `cmpId`/`cmpVersion` — powers the dashboard's GPP Registration panel |
975
+ | `POST` | `/gpp/confirm-registration` | `{ acknowledge: true }` — records the confirmation as a hash of `cmpId`/`cmpVersion` |
976
+
977
+ Only active when `gpp.enabled: true`. Unlike TCF, IAB publishes no CMP-List equivalent for GPP, so
978
+ confirmation here is self-attestation only (no external registry to validate `cmpId` against) — the
979
+ checkbox and hash-drift detection (re-arms when `cmpId`/`cmpVersion` changes) are the whole
980
+ mechanism. `cmpId`/`cmpVersion` must match the widget's own `compliance.gpp` config
981
+ (`@consenti/ui` README, "GPP" section). Install the optional `@iabgpp/cmpapi` peer dependency for
982
+ spec-correct binary GPP-string encoding; without it, `gppString` falls back to a base64url-JSON
983
+ payload, same fallback shape as the widget's own stub.
754
984
 
755
985
  ---
756
986
 
@@ -761,23 +991,27 @@ Served at `{basePath}/` when `dashboard: true`.
761
991
  | Section | Description |
762
992
  |---------------------|--------------------------------------------------------------------------|
763
993
  | Dashboard | Consent overview, timeline chart, country breakdown, GPC stats |
994
+ | Reports | Opt-in trend and category/locale breakdowns, date-range filter, requires `stats:view` |
764
995
  | Profiles | Create / edit / copy / delete / activate / deactivate consent profiles |
765
- | Profile History | Version history viewer — compare locale JSON per version |
766
- | Cookie Templates | Reusable cookie/purpose definitions (blank by default + Load Defaults) |
996
+ | Profile History | Edit history viewer — browse every past snapshot in a profile's lineage |
997
+ | Consent Templates | Reusable parameter + category definitions (blank by default + Load Defaults) |
767
998
  | UI Templates | Reusable banner + modal layout settings |
768
- | Consents | Browse, filter, export consent records; per-visitor history |
769
- | Visitors | Visitor list with geographic data |
770
- | Users | Admin user management with allowed-tenant scoping |
999
+ | Consents | Browse, filter, search, paginate, export consent records; per-visitor history; row click opens a detail modal with signature/consent-data breakdown |
1000
+ | Visitors | Visitor list with geographic data; search, paginate; row click opens a detail modal including proof-of-notice history |
1001
+ | Users | Admin user management with allowed-tenant scoping; search; local-auth password reset (`super_admin` only) |
771
1002
  | Roles | RBAC roles and fine-grained permission assignment |
772
1003
  | Sites | Multi-tenant site management **(superadmin only)** |
773
1004
  | TCF Vendors | IAB Global Vendor List |
774
- | Audit Log | Immutable log of all admin actions |
1005
+ | Audit Log | Append-only log of all admin actions, never deleted by Consenti; search, paginate; row click opens a detail modal with old/new data diff |
775
1006
  | Settings / API | API keys, branding, OpenAPI docs **(superadmin only)** |
1007
+ | Setup Wizard | One-time first-run welcome / config / default-profiles / confirmation flow — see [First-Run Setup Wizard](#first-run-setup-wizard) |
776
1008
 
777
1009
  ### Dashboard RBAC
778
1010
 
779
- - **Sites** and **API** sections are visible only to users with `role: 'superadmin'`
1011
+ - **Sites** and **API** sections are visible only to users with the `super_admin` role
780
1012
  - **TCF Vendors** is visible to all authenticated users
1013
+ - **Reports** requires the `stats:view` permission (granted by default to `super_admin`, `admin`, `compliance_officer`, and `viewer` roles)
1014
+ - **Password reset** in the Users edit modal is shown only to `super_admin` users, and only when `auth.mode === 'local'` (JWT/OIDC/SAML/custom auth manage credentials outside Consenti, so there is nothing to reset here)
781
1015
 
782
1016
  ### Profile Creation Wizard
783
1017
 
@@ -788,50 +1022,88 @@ Served at `{basePath}/` when `dashboard: true`.
788
1022
  | `name` | `string` | Human-readable profile name |
789
1023
  | `defaultLocale` | `string` (BCP 47) | Locale served when visitor locale is unavailable; written as `default.json` |
790
1024
  | `complianceGroup` | `ComplianceGroupId` | Regulation group for geo-routing (e.g. `opt-in`, `opt-out-strict`) |
1025
+ | `customComplianceGroup` | `string` (lower-kebab-case) | Required when `complianceGroup` is unset ("None / Custom") — it's the identifier the widget's `compliance.type` config uses to fetch this profile (see [Public REST API](#public-rest-api) hot-serve path). Drives activation, deactivation, and "one active profile per group" conflict detection the same way `complianceGroup` does; no `COMPLIANCE_GROUPS` validation rules or GPC defaults apply to it, since none exist for a free-form name. |
791
1026
  | `gpcMode` | `'ignore' \| 'honor' \| 'strict'` | GPC signal handling. Overrides group default. |
1027
+ | `expiryDays` | `number` (default `365`) | Days until stored consent expires and the visitor is asked again. Profile-wide — replaces the old per-parameter expiry field. |
792
1028
  | `darkMode` | `boolean` | Enable dark mode in the consent banner |
793
- | `hidePoweredBy` | `boolean` | Hide "Powered by Consenti" branding link |
1029
+ | `hidePoweredBy` | `boolean` (default `true`) | Hide "Powered by Consenti" branding link. Defaults to checked/hidden in the dashboard, matching the widget's own default for a profile that never sets this field. |
794
1030
  | `allowReceipt` | `boolean` | Allow visitors to download a PDF consent receipt |
795
1031
  | `allowedOrigins` | `string[]` | Allowlisted domains for CORS on this profile's consent endpoints |
796
- | `complianceConfig` | `Record<string, string>` | Per-compliance extra config (e.g. DPDPA data fiduciary name). Only shown when the selected `complianceGroup` requires it; otherwise omitted. |
1032
+ | `complianceConfig` | `Record<string, string>` | Per-compliance extra config (e.g. DPDPA data fiduciary name). Only shown when the selected `complianceGroup` requires it; otherwise omitted. |
1033
+ | `showFooterMetadata` | `boolean` | Show a metadata strip in the banner and modal footer with: Consent ID (visitor UUID), Consent Date, Profile Version, and a "Privacy Settings" link. |
1034
+ | `enhanceAccessibility` | `boolean` | Apply WCAG 2.1 AA enhancements: 44 px min button height, 3 px focus rings. Adds `.consenti--enhanced-a11y` class to the widget root. |
797
1035
 
798
- **Step 2 — Cookie Template:** Define cookie IDs and their properties. Clicking **Next** at the bottom of Step 2 calls `POST /admin/profiles/validate` with the selected template's cookies and the chosen `complianceGroup`:
1036
+ **Step 2 — Consent Template:** Define parameters (cookies) and the categories that own their legal basis, edited together. Clicking **Next** at the bottom of Step 2 calls `POST /admin/profiles/validate` with the selected template's `cookies` + `categories` and the chosen `complianceGroup`:
799
1037
 
800
- - **Compliance errors** (red panel): block advancing to Step 3 until resolved (e.g. template has no cookie with `listenGpc: true` but the group requires GPC support).
801
- - **Compliance warnings** (amber panel): show an acknowledgment checkbox; the user must check it before advancing. Warnings do not block save — they surface potential issues.
1038
+ - **Compliance errors** (red panel): block advancing to Step 3 until resolved (e.g. a category has no `legalBasis` set, or a marketing-purpose parameter is assigned to a `mandatory` category).
1039
+ - **Compliance warnings** (amber panel): show an acknowledgment checkbox; the user must check it before advancing. Warnings do not block save — they surface potential issues (e.g. `preGrant: true` on a strict opt-in profile).
1040
+
1041
+ Parameter fields:
802
1042
 
803
1043
  | Field | Type | Description |
804
1044
  |---------------------|-------------------------------------------------|---------------------------------------------------------------------------|
805
- | `id` | `string` | Unique identifier referenced in button arrays |
806
- | `legalBasis` | `'mandatory' \| 'consent' \| 'legitimate_interest'` | Legal basis (replaces deprecated `mandatory`+`type` fields) |
1045
+ | `id` | `string` (map key) | Unique identifier referenced in button arrays and category `cookies[]` |
1046
+ | `purpose` | `'necessary' \| 'functional' \| 'preferences' \| 'analytics' \| 'marketing'` | What the parameter is for. Required — parameter IDs are free-form, so the purpose is what integrations and compliance checks rely on. Selecting a purpose pre-fills `listenGpc` and `cpraCategory` (still editable). Known Google Consent Mode IDs (`ad_storage`, `analytics_storage`, …) auto-detect their purpose. |
807
1047
  | `listenGpc` | `boolean` | Auto-denied when GPC signal is active |
808
- | `expiry` | `number` | Days until re-consent is required (0 = session) |
809
- | `tcfSpecialFeatures`| `number[]` | IAB TCF special feature IDs (SF1, SF2) associated with this cookie |
1048
+ | `preGrant` | `boolean` | Pre-grant this parameter's default consent. Only editable when its category's `legalBasis === 'consent'` — `mandatory`/`legitimate_interest` categories are already effectively pre-granted, so the checkbox is locked checked for those. |
1049
+ | `tcfVendorId` / `tcfPurposes` | `number` / `number[]` | IAB TCF vendor + purpose IDs associated with this parameter |
1050
+ | `cpraCategory` | `'sale' \| 'sharing' \| 'sensitive'` | CPRA data category |
1051
+
1052
+ Category fields (own the legal basis for every parameter listed in `cookies[]` — a parameter must belong to **exactly one** category):
1053
+
1054
+ | Field | Type | Description |
1055
+ |---------------------|--------------------------------------------------------|--------------------------------------------------------------------|
1056
+ | `id` | `string` (map key) | Unique identifier |
1057
+ | `legalBasis` | `'mandatory' \| 'consent' \| 'legitimate_interest'` | Legal basis for every parameter in `cookies[]` — replaces the old per-parameter `legalBasis` field |
1058
+ | `cookies` | `string[]` | Parameter IDs belonging to this category |
1059
+
1060
+ Purpose defaults (applied to the parameter when a purpose is selected, all overridable; the category's `legalBasis` is authored separately):
810
1061
 
811
- **Step 3 — UI Template:** Configure banner and modal layout. New UI templates start **blank** (no prefilled buttons or categories). Click **Load Defaults** in the amber callout to populate a sensible starter structure.
1062
+ | `purpose` | Suggested category `legalBasis` | `listenGpc` | `cpraCategory` |
1063
+ |---------------|-----------------------------------|-------------|----------------|
1064
+ | `necessary` | `mandatory` | `false` | — |
1065
+ | `functional` | `legitimate_interest` | `false` | — |
1066
+ | `preferences` | `legitimate_interest` | `false` | — |
1067
+ | `analytics` | `consent` | `true` | — |
1068
+ | `marketing` | `consent` | `true` | `sharing` |
1069
+
1070
+ **Per-profile parameter overrides:** `ProfileConfig.cookiesOverride?: Record<string, Partial<Cookie>>` lets a profile tune specific fields (e.g. `preGrant`) of a template-authored parameter without forking the whole Consent Template — deltas merge onto the resolved `cookies` map by parameter id at resolve time (`GET /profiles/:tenantId/:complianceGroup/:locale` and friends); a delta for a parameter id not present in the template is ignored. `categoriesOverride`/`uiOverride` exist on the type for a future phase but aren't applied yet.
1071
+
1072
+ In the Step 2 parameter table, **Pre Grant** is editable per-profile (writes/removes a `cookiesOverride` delta) except for parameters whose category isn't `legalBasis: 'consent'` (locked checked-and-disabled, same rule as template authoring). Selecting a compliance group or Consent Template auto-defaults it: `opt-out`/`opt-out-strict` force it off (with an amber warning), every other group forces it on for parameters the template didn't already pre-grant — only where that differs from the template's authored value.
1073
+
1074
+ **Step 3 — UI Template:** Configure banner and modal layout. New UI templates start **blank** (no prefilled buttons). Click **Load Defaults** in the amber callout to populate a sensible starter structure.
812
1075
 
813
1076
  - **Main Banner / GPC Banner:** `position`, `overlayOpacity`, `showClose`, `headingTag`, `buttons`
814
- - **Preference Modal:** `position`, `overlayOpacity`, `showClose`, `persistent`, `buttons`, `categories`
1077
+ - **Preference Modal:** `position`, `overlayOpacity`, `showClose`, `persistent`, `buttons` — categories are no longer edited here; they come from the Consent Template selected in Step 2.
1078
+ - Button `id`: a machine identifier (e.g. `accept-all`), **not** display text — UI templates own layout/behavior only. The visitor-facing label for each button is authored per-locale in Step 4, shown there as `id (action)` so authors know which button they're labeling.
815
1079
  - Button `type`: `primary | secondary | text | reject | submit | manage | close`
816
1080
  - Button `cookies`: `*` (grant all) · `!` (deny all) · comma-separated IDs
817
1081
 
1082
+ Additional UI template fields:
1083
+
1084
+ | Field | Scope | Type | Description |
1085
+ |-------|-------|------|-------------|
1086
+ | `stackButtonsOnBreakpoint` | Main Banner, GPC Banner | `number` | Below this viewport width (px) banner buttons stack vertically and stretch full-width. `0` or absent = disabled. Default when enabled: `576`. |
1087
+ | `trapFocus` | Preference Modal | `boolean` | Confine Tab / Shift+Tab keyboard navigation within the modal while it is open. `Escape` closes and restores prior focus. |
1088
+
818
1089
  **Step 4 — Content:** Enter localised copy with live preview:
819
1090
 
820
1091
  - Main Banner: heading, body HTML
821
1092
  - GPC Banner: heading, body HTML
822
- - Preference Modal: heading, subheading, intro HTML, per-category heading + description
1093
+ - Preference Modal: heading, subheading, intro HTML, per-category heading + description, plus an optional legitimate-interest description (`legitimateInterestDescription`) shown only for categories with `legalBasis: 'legitimate_interest'` — the GDPR balancing-test justification, localized per profile alongside the rest of the category content
1094
+ - Consent receipt checkbox label + description (only shown when **Allow consent receipt** is enabled in Step 1) — `PreferenceModal.receiptLabel`/`receiptDescription`, authored and localized per profile instead of the widget's built-in default text
823
1095
 
824
1096
  **Adding locales:** Click **+ Add locale** to open a searchable `CountrySelecter` dropdown showing all BCP 47 locale options. Select a locale to add its tab instantly.
825
1097
 
826
1098
  **Default-locale placeholders:** When editing a non-default locale tab, all heading, subheading, and category-heading inputs show the default locale's value as placeholder text — so translators see the source copy without switching tabs.
827
1099
 
828
- **Import / Export:**
1100
+ **Import / Export:** available above the wizard card on every content step (Main Banner / GPC Banner / Preference Modal) — not scoped to whichever step is active, since each covers the whole profile. `Load Defaults` stays inside each step's own card, scoped to that section only, and is sourced from `packages/utils/src/profiles` for whichever compliance group and active locale tab you're on (a custom, non-preset compliance group falls back to `general-privacy-consent`'s content); category text is matched to your Consent Template's own categories by cookie purpose, not by id.
829
1101
 
830
1102
  | Button | Action |
831
1103
  |--------|--------|
832
- | **JSON** | Exports *all* locale tabs as a single `locales.json` file (`{ "en": {...}, "fr-FR": {...} }`) |
833
- | **CSV** | Exports the *current* locale tab as a `locale-{locale}.csv` file |
834
- | **Import** | Accepts either format — multi-locale JSON auto-creates missing locale tabs; skipped locales (invalid BCP 47 key) are listed in an amber callout after import |
1104
+ | **JSON** | Exports every locale the profile has content for as `locales.json`: `{ "en": { "mainBanner.heading": "...", "category.necessary.heading": "...", ... }, "fr-FR": {...} }` — one flat dot-path key/value map per locale. Button columns are keyed by the UI template's button id (e.g. `mainBanner.button.accept-all`); category columns by the consent template's category id. |
1105
+ | **CSV** | Exports `locales.csv` — one row per *every* BCP 47 locale in the `CountrySelecter` list (not just ones this profile already has), so translators never hand-type a locale code. Column A is the locale; the rest are the same dot-path keys as the JSON export, as column headers. Rows for locales the profile already has content for are pre-filled; the rest are blank, ready to fill in. |
1106
+ | **Import** | Accepts either format. The header row (JSON: none; CSV: row 1) is not user data. For CSV, rows where every content column is blank are skipped — so importing the full 70+ row template back doesn't create empty locale entries for locales you didn't fill in. Skipped/invalid locale keys are listed in an amber callout after import. |
835
1107
 
836
1108
  **Readability callouts:** Inline advisory warnings (yellow) appear beneath `heading` and body HTML fields when:
837
1109
  - A heading exceeds 80 characters
@@ -840,35 +1112,76 @@ Served at `{basePath}/` when `dashboard: true`.
840
1112
 
841
1113
  These are informational only — the profile can still be saved.
842
1114
 
1115
+ ### Mandatory Content Validation
1116
+
1117
+ `POST /profiles` and `PUT /profiles/:id` reject (`422`) a save where any locale is missing required
1118
+ content — this is what guarantees a profile can never end up with a blank banner. The dashboard
1119
+ wizard already blocks this client-side (hard stop on Next/Save, jumping to the offending locale
1120
+ tab); the backend check is the enforcement backstop for bulk CSV/JSON import, which bypasses the
1121
+ wizard's step-by-step gating entirely. Both use the same `hasVisibleText` check
1122
+ (`@consenti/utils`) so a rich-text field that *looks* non-empty by string length but renders no
1123
+ actual text (an empty paragraph node, for example) is still correctly caught as blank.
1124
+
1125
+ Mandatory per locale (heading is always optional, everywhere):
1126
+
1127
+ | Section | Field | Rule |
1128
+ |---|---|---|
1129
+ | Main Banner / GPC Banner | Body text (`htmlText`) | Required, non-blank |
1130
+ | Main Banner / GPC Banner | Every button's label | Required, non-blank |
1131
+ | Preference Modal | Heading | Required, non-blank |
1132
+ | Preference Modal | Every button's label | Required, non-blank |
1133
+ | Preference Modal | Every category's heading | Required, non-blank |
1134
+
1135
+ GPC Banner rules only apply to a locale that actually submitted `gpcBanner` content — a profile
1136
+ with `gpcMode: 'ignore'`, or a locale nobody's authored a GPC variant for, simply omits it. A
1137
+ failing save returns:
1138
+ ```json
1139
+ { "error": "Profile content is missing required fields", "details": { "errors": [
1140
+ { "locale": "hi", "section": "mainBanner", "field": "htmlText", "message": "Body text is required" }
1141
+ ] } }
1142
+ ```
1143
+ (`details` is only included outside `NODE_ENV=production`, same as the compliance-validation 422 above.)
1144
+
843
1145
  ### Profile Activation
844
1146
 
845
1147
  Profiles are **inactive** after creation. To serve them via geo-routing:
846
1148
 
847
1149
  1. Click **Activate** in the profile list or use `POST /admin/profiles/:id/activate`
848
- 2. Consenti writes locale JSON files to `${storage.path}/profiles/${tenantId}/${complianceGroup}/`
1150
+ 2. Consenti atomically repoints `${storage.path}/profiles/${tenantId}/${complianceGroup}/` (a symlink) at the profile's version directory
849
1151
  3. The static file route starts serving these files immediately (zero DB on the hot path)
850
1152
 
851
1153
  Only one profile per `complianceGroup` can be active at a time. Activating a new profile automatically deactivates the existing one.
852
1154
 
853
- ### Profile Version History
1155
+ ### Profile Edit History
854
1156
 
855
- Every profile save creates an immutable snapshot at `${profileId}/${version}/`. The dashboard version history page (`#/banners/profiles/:id/history`) shows:
856
- - Left panel: list of all versions with dates
1157
+ `id` is stable across every edit — each save mutates the row in place and increments `version`, and writes a new resolved-JSON snapshot immutably to `${id}/${version}/`. There's no separate DB-level archive; that on-disk snapshot tree is the audit trail. The dashboard history page (`#/banners/profiles/:id/history`) shows:
1158
+ - Left panel: every version, newest first, by date
857
1159
  - Right panel: prettified JSON for the selected version; locale switcher dropdown
858
1160
 
859
1161
  Via API:
860
1162
  ```http
861
- GET /consenti/admin/profiles/:id/versions
862
- GET /consenti/admin/profiles/:id/versions/3?locale=fr-FR
1163
+ GET /consenti/admin/v1/profiles/:id/versions
1164
+ GET /consenti/admin/v1/profiles/:id/versions/:entryId?locale=fr-FR
1165
+ ```
1166
+
1167
+ `:id` is the profile's stable id. `:entryId` is one specific version's number, as returned by the `versions` list.
1168
+
1169
+ ### Archived Profiles
1170
+
1171
+ `ProfileService.delete()` removes the DB row but never deletes the on-disk version-snapshot tree, so deleted profiles' history is still readable — it's just no longer reachable by browsing the Profiles list. The **Archived Profiles** page (`#/banners/profiles/archived`, linked from the Profiles list) lists every profile-id directory found on disk with no matching DB row, built from a directory listing only — id, version count, last-modified date — no file content is read until a specific version is opened. Clicking one opens the same version-history viewer used for active profiles.
1172
+
1173
+ ```http
1174
+ GET /consenti/admin/v1/profiles/archived
863
1175
  ```
1176
+ Returns `{ id, versionCount, lastModified }[]`. `GET /profiles/:id/versions` and `GET /profiles/:id/versions/:entryId` (above) both work for archived ids too — they read the version-directory tree directly and were never actually gated on the DB row existing.
864
1177
 
865
1178
  ### Template Save Safety
866
1179
 
867
- When saving a **Cookie Template** or **UI Template** that is used by one or more profiles, a confirmation dialog appears listing the affected profiles. You must confirm before changes are applied. Uses `GET /admin/cookie-templates/:id/profile-usage` or `GET /admin/ui-templates/:id/profile-usage` internally.
1180
+ When saving a **Consent Template** or **UI Template** that is used by one or more profiles, a confirmation dialog appears listing the affected profiles. You must confirm before changes are applied. Uses `GET /admin/consent-templates/:id/profile-usage` or `GET /admin/ui-templates/:id/profile-usage` internally.
868
1181
 
869
- When **deleting** a Cookie Template used by active profiles, the delete is blocked with a 422 error listing those profiles. Deactivate the affected profiles before deleting the template.
1182
+ When **deleting** a Consent Template used by active profiles, the delete is blocked with a 422 error listing those profiles. Deactivate the affected profiles before deleting the template.
870
1183
 
871
- When **removing cookies** from a Cookie Template, the backend checks whether the reduced cookie set satisfies the `complianceGroup` rules for every profile using the template. If any profile's compliance would break, the save is blocked with a 422 listing the `blockingProfiles` and `removedCookieIds` — see [Cookie Template Safety Guards](#cookie-template-safety-guards).
1184
+ When **changing parameters or categories** on a Consent Template, the backend re-runs compliance validation against every profile using the template. If any profile's compliance would break, the save is blocked with a 422 listing `blockingProfiles` — see [Consent Template Safety Guards](#consent-template-safety-guards).
872
1185
 
873
1186
  ---
874
1187
 
@@ -879,33 +1192,109 @@ When **removing cookies** from a Cookie Template, the backend checks whether the
879
1192
  | Event | Payload | Description |
880
1193
  |---|---|---|
881
1194
  | `ready` | none | Storage connected and admin user bootstrapped |
882
- | `consent.created` | `ConsentDbRecord` | New consent record saved |
883
- | `consent.updated` | `{ previous, current: ConsentDbRecord }` | Consent record updated |
884
- | `consent.erased` | `{ visitorId: string }` | Visitor data erased (GDPR erasure) |
885
- | `visitor.created` | `Visitor` | New visitor record created |
886
- | `profile.created` | `Profile` | Profile created |
887
- | `profile.updated` | `{ previous, current: Profile }` | Profile updated (version bumped) |
888
- | `profile.deleted` | `{ id: string, previous: Profile }` | Profile deleted |
889
- | `cache:warm` | `{ paths: string[], version: number }` | Fired after locale JSON files are written; paths to warm in CDN/cache |
890
- | `cache:purge` | `{ paths: string[], version: number }` | Fired after locale JSON files are removed; paths to purge from CDN/cache |
1195
+ | `consent:created` | `ConsentDbRecord` | New consent record saved |
1196
+ | `consent:updated` | `{ previous, current: ConsentDbRecord }` | Consent record updated |
1197
+ | `consent:erased` | `{ visitorId: string }` | Visitor data erased (GDPR erasure) |
1198
+ | `visitor:created` | `Visitor` | New visitor record created |
1199
+ | `profile:created` | `Profile` | Profile created |
1200
+ | `profile:updated` | `{ previous, current: Profile }` | Profile edited — `current.id` matches `previous.id`; `current.version` is incremented |
1201
+ | `profile:deleted` | `{ id: string, previous: Profile }` | Profile deleted |
1202
+ | `profile.activated` | `Profile` | Profile activated (locale JSON files written to the hot-serve path) |
1203
+ | `profile.deactivated` | `Profile` | Profile deactivated (locale JSON files removed from the hot-serve path) |
1204
+ | `cache:warm` | `{ paths: string[], profileId: string }` | Fired after locale JSON files are written; paths to warm in CDN/cache |
1205
+ | `cache:purge` | `{ paths: string[], profileId: string }` | Fired after locale JSON files are removed; paths to purge from CDN/cache |
1206
+ | `consent.parentalConsentRequired` | `{ visitorId, profileId, token, issuedAt }` | Age gate declined with `requireParentalConsent: true` — see [Parental consent request/resolve](#parental-consent-request-resolve-stateless-hook) below |
1207
+ | `consent.parentalConsentGranted` | `{ visitorId, profileId }` | `POST /consent/parental-consent-resolve` succeeded — see the same section |
1208
+
1209
+ Note the inconsistent separator: most events use `:` (`profile:created`), but the activation and
1210
+ parental-consent events use `.` (`profile.activated`, `consent.parentalConsentRequired`) — listen
1211
+ for the exact string shown above, not a normalized form.
891
1212
 
892
1213
  `cache:warm` and `cache:purge` mirror the `handleCache` config callback — use whichever suits your integration.
893
1214
 
894
1215
  ```ts
895
1216
  const { eventBus } = createConsenti({ /* ... */ })
896
1217
 
897
- eventBus.on('consent.created', (record) => {
1218
+ eventBus.on('consent:created', (record) => {
898
1219
  // send to analytics, trigger webhook, etc.
899
1220
  console.log('New consent from visitor:', record.visitorId)
900
1221
  })
901
1222
 
902
- eventBus.on('consent.erased', ({ visitorId }) => {
1223
+ eventBus.on('consent:erased', ({ visitorId }) => {
903
1224
  myDmpClient.deleteVisitor(visitorId)
904
1225
  })
905
1226
  ```
906
1227
 
907
1228
  ---
908
1229
 
1230
+ ## ConsentAction / CategoryAction
1231
+
1232
+ Server-side counterparts to `@consenti/ui`'s `ConsentAction`/`CategoryAction` — thin wrappers around `consent:created`/`consent:updated` that fire `onGrant`/`onDeny` only on an actual status transition (not on every event), across every visitor's submissions. Prefer these over listening to the raw events directly when you only care about one parameter or one category — they handle the previous-vs-current comparison for you.
1233
+
1234
+ There is no server equivalent of the widget's `ConsentScript`/`CategoryScript` — injecting a `<script>` tag only makes sense in a browser DOM the server doesn't have. `services.consent.create()`/`.update()` are already the server-side submit/update methods these hooks react to (see [ConsentService](#services)).
1235
+
1236
+ ### `ConsentAction`
1237
+
1238
+ Watches a single cookie parameter.
1239
+
1240
+ ```ts
1241
+ import { createConsenti, ConsentAction } from '@consenti/api'
1242
+
1243
+ const { eventBus } = createConsenti({ /* ... */ })
1244
+
1245
+ new ConsentAction({
1246
+ id: 'analytics_storage', // cookie id to watch
1247
+ eventBus,
1248
+ onGrant: ({ visitorId, cookieId, status, record }) => crm.optIn(visitorId),
1249
+ onDeny: ({ visitorId, cookieId, status, record }) => crm.optOut(visitorId),
1250
+ })
1251
+ ```
1252
+
1253
+ **Options** (`ConsentActionOptions`):
1254
+
1255
+ | Option | Type | Description |
1256
+ |---|---|---|
1257
+ | `id` | `string` | The consent parameter (cookie) ID to watch |
1258
+ | `eventBus` | `EventEmitter` | The `eventBus` returned by `createConsenti()` |
1259
+ | `onGrant` | `(params: ConsentActionParams) => void` | Called when the parameter transitions to `'granted'` |
1260
+ | `onDeny` | `(params: ConsentActionParams) => void` | Called when the parameter transitions away from `'granted'` (`'denied'` or `'objected'`) |
1261
+
1262
+ **Callback params** (`ConsentActionParams`): `{ visitorId: string, cookieId: string, status: ConsentStatus, record: ConsentDbRecord }`
1263
+
1264
+ ### `CategoryAction`
1265
+
1266
+ Watches a whole category's rollup consent state — granted only when *every* cookie in the category is granted, matching the preference modal's tri-state rollup rule.
1267
+
1268
+ ```ts
1269
+ import { createConsenti, CategoryAction } from '@consenti/api'
1270
+
1271
+ const { eventBus, services } = createConsenti({ /* ... */ })
1272
+
1273
+ new CategoryAction({
1274
+ categoryId: 'marketing',
1275
+ eventBus,
1276
+ profiles: services.profile, // resolves the category's cookie ids per profile
1277
+ onGrant: ({ visitorId, categoryId, status, record }) => adsPlatform.optIn(visitorId),
1278
+ onDeny: ({ visitorId, categoryId, status, record }) => adsPlatform.optOut(visitorId),
1279
+ })
1280
+ ```
1281
+
1282
+ **Options** (`CategoryActionOptions`):
1283
+
1284
+ | Option | Type | Description |
1285
+ |---|---|---|
1286
+ | `categoryId` | `string` | The category ID to watch |
1287
+ | `eventBus` | `EventEmitter` | The `eventBus` returned by `createConsenti()` |
1288
+ | `profiles` | `ProfileService` | The `services.profile` returned by `createConsenti()` — resolves which cookies belong to this category for the record's profile (categories are defined on the consent template) |
1289
+ | `onGrant` | `(params: CategoryActionParams) => void` | Called when every cookie in the category transitions to `'granted'` |
1290
+ | `onDeny` | `(params: CategoryActionParams) => void` | Called when the category transitions away from fully granted |
1291
+
1292
+ **Callback params** (`CategoryActionParams`): `{ visitorId: string, categoryId: string, status: ConsentStatus, record: ConsentDbRecord }`
1293
+
1294
+ Both classes expose `destroy()` to remove their event listeners when the hook is no longer needed.
1295
+
1296
+ ---
1297
+
909
1298
  ## Plugins (Server-side)
910
1299
 
911
1300
  ```ts
@@ -974,13 +1363,58 @@ await consenti.destroy()
974
1363
 
975
1364
  | Concern | Approach |
976
1365
  |---------------------------|------------------------------------------------------------------------------|
977
- | IP addresses | Never stored raw — SHA-256 hashed: `createHash('sha256').update(ip).digest('hex')` |
1366
+ | IP addresses | Never stored raw — masked (last IPv4 octet / last 80 bits of IPv6 zeroed), salted with `compliance.dataSigningHash`, then SHA-256 hashed |
978
1367
  | Passwords | `scrypt` via `node:crypto` — no external bcrypt |
979
1368
  | JWTs | `createHmac` via `node:crypto` — no jsonwebtoken package |
980
1369
  | Admin routes | Always require `Authorization: Bearer <jwt>` |
981
1370
  | Stack traces | Suppressed in `NODE_ENV=production` responses |
982
1371
  | CORS | Explicit allowlist — never use `origins: '*'` in production |
1372
+ | Tamper-evident consent | `compliance.dataSigningHash` signs server-stored records — the key never reaches the browser, unlike the widget's own `core.cookieSigningKey`, which is spoofable in standalone (no-backend) mode since it must ship inside the browser bundle |
983
1373
  | Rate limiting | Applied to all public API routes by default |
1374
+ | Signed consent records | `compliance.dataSigningHash` — HMAC-SHA256 (`node:crypto` `createHmac`) over each record's core fields, hex-encoded into `signature`. Auto-generated in memory if you don't set it, so this is always active. |
1375
+
1376
+ ### Signed consent records
1377
+
1378
+ `compliance.dataSigningHash` signs every consent record at create/update time — tamper-evidence for server-stored records, independent of the widget's own cookie-signing (`core.cookieSigningKey`, which protects the visitor's local cookie). The same key also signs ownership cookies and parental-consent request/resolve tokens (see "Parental consent" below), and salts hashed IPs. It's auto-generated in memory if you don't set it — every restart then invalidates outstanding ownership cookies and in-flight parental-consent tokens, since the generated value isn't persisted. Set it explicitly (`CONSENTI_DATA_SIGNING_HASH`) for anything beyond local dev.
1379
+
1380
+ ```ts
1381
+ createConsenti({
1382
+ compliance: {
1383
+ dataSigningHash: process.env.CONSENTI_DATA_SIGNING_HASH,
1384
+ },
1385
+ })
1386
+ ```
1387
+
1388
+ - The signature covers `tenantId`, `visitorId`, `profileId`, `locale`, and the sorted `consentJson` entries — not the DB-generated `id`/`createdAt`/`updatedAt`.
1389
+ - `GET /consent/:visitorId/verify` checks the signature when both a key is configured and the record has one, adding `hmac_invalid` to `reasons` on mismatch. Records signed before the key was configured (or never signed) never fail this check for its absence.
1390
+ - `signature` is included in CSV/JSON/XLSX exports (`GET /export/consents`) and shown in the dashboard's Consents detail view.
1391
+ - Hash-chaining between records (a full tamper-evident log, not just per-record integrity) is not implemented — out of scope for now.
1392
+
1393
+ ### Parental consent request/resolve (stateless hook)
1394
+
1395
+ When a profile's `ageGate.requireParentalConsent` is true and a visitor declines the age gate, the widget submits a deny-all consent and calls `POST /consent/:visitorId/parental-consent-request` to mint a token, then dispatches `consenti:parentalConsentRequired` carrying it. There's no email-sending infra in this package — your own `eventBus` listener owns actually notifying the parent (email, SMS, whatever your product uses):
1396
+
1397
+ ```ts
1398
+ const { eventBus } = createConsenti({ /* ... */ })
1399
+ eventBus.on('consent.parentalConsentRequired', ({ visitorId, profileId, token }) => {
1400
+ const resolveUrl = `https://your-site.example/verify-parent?token=${encodeURIComponent(token)}`
1401
+ // send resolveUrl to the parent however your product does that (email, SMS, ...)
1402
+ })
1403
+ ```
1404
+
1405
+ The parent's own click/flow eventually calls `POST /consent/parental-consent-resolve` with `{ token }` — either directly, or via the widget's `resolveParentalConsent(token)` export (`@consenti/ui`) if the parent lands on a page with the widget script loaded. On success the server emits `consent.parentalConsentGranted`; your backend listener decides what "granted" actually means for stored consent (this package doesn't update it automatically):
1406
+
1407
+ ```ts
1408
+ eventBus.on('consent.parentalConsentGranted', async ({ visitorId, profileId }) => {
1409
+ // e.g. call ConsentService/PUT /consent/:visitorId yourself to update the real consent record
1410
+ })
1411
+ ```
1412
+
1413
+ **Signing**: the request/resolve tokens are signed with `compliance.dataSigningHash` — the same key used for consent-record signing above. It's auto-generated in memory if unset, so tokens are always signed — but with an ephemeral, unpersisted key unless you set `dataSigningHash` explicitly, meaning a server restart between issuing and resolving a token invalidates it. Set `dataSigningHash` (`CONSENTI_DATA_SIGNING_HASH`) before using this in production. Token expiry defaults to 7 days, configurable via `compliance.parentalConsentTokenTtlDays`.
1414
+
1415
+ **Important caveats**:
1416
+ - Token **replay isn't prevented** — there's no persistence to mark a token "used," an accepted tradeoff of staying fully stateless (no new DB entity, no `StorageAdapter` changes across all 7 storage adapters for what's meant to be a thin plumbing hook).
1417
+ - This mechanism is **not by itself sufficient verifiable parental consent** under COPPA or similar regimes. Mainstream CMPs (OneTrust, Cookiebot, Usercentrics, TrustArc, Osano) don't build verifiable-parental-consent workflows into the widget either — a CMP has no way to actually verify the clicker is the parent. This is plumbing to let you wire up your own verification method (signed form, ID check, "email plus," video call — whatever your legal counsel requires), not a claim that clicking a link constitutes legally-sufficient VPC.
984
1418
 
985
1419
  ---
986
1420
 
@@ -994,7 +1428,8 @@ All config fields can be set via environment variables. Code config takes preced
994
1428
  | `CONSENTI_BASE_PATH` | `basePath` | `'/consenti'` |
995
1429
  | `CONSENTI_ADMIN_EMAIL` | `auth.adminEmail` | `'user@consenti.dev'` |
996
1430
  | `CONSENTI_ADMIN_PASSWORD` | `auth.adminPassword` | `'Consenti@123'` |
997
- | `CONSENTI_ADMIN_JWT_SECRET` | `auth.jwtSecret` | random per restart |
1431
+ | `CONSENTI_ADMIN_MASTER_SECRET` | `auth.masterSecret` | random per restart |
1432
+ | `CONSENTI_DATA_SIGNING_HASH` | `compliance.dataSigningHash` | random per restart |
998
1433
  | `CONSENTI_DB_DRIVER` | `storage.driver` | `'json'` |
999
1434
  | `CONSENTI_DB_PATH` | `storage.path` | `'./consenti-data'` |
1000
1435
  | `CONSENTI_DB_URI` | `storage.uri` | — |
@@ -1020,7 +1455,7 @@ OpenAPI JSON at `{basePath}/api/openapi.json`.
1020
1455
  ## Demo
1021
1456
  For detailed API docs, Guides and Demo, visit [consenti.dev](https://consenti.dev)
1022
1457
 
1023
- Visit author's profile at [santosh.top](https://santosh.top)
1458
+ An open-source project by [Santosh Ojha](https://santosh.top)
1024
1459
 
1025
1460
 
1026
1461
  ---