@consenti/api 0.2.0 → 0.3.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 +387 -90
  2. package/dist/chunk-IY4L6MNB.js +2 -0
  3. package/dist/chunk-IY4L6MNB.js.map +1 -0
  4. package/dist/chunk-O5UG4OYI.js +2 -0
  5. package/dist/chunk-O5UG4OYI.js.map +1 -0
  6. package/dist/dashboard/assets/ApiConfig-CE-Noelz.js +1 -0
  7. package/dist/dashboard/assets/ArchivedProfileList-Bw-fx-BD.js +1 -0
  8. package/dist/dashboard/assets/AuditLog-D6HiTQ1o.js +1 -0
  9. package/dist/dashboard/assets/ChangePassword-D2rPkzD1.js +1 -0
  10. package/dist/dashboard/assets/ConfirmDialog-DTtw7VJ-.js +1 -0
  11. package/dist/dashboard/assets/ConsentList-BWOvPOMG.js +1 -0
  12. package/dist/dashboard/assets/ConsentTemplateEditor-BKIEnLmA.js +1 -0
  13. package/dist/dashboard/assets/ConsentTemplateList-DCys2nM3.js +1 -0
  14. package/dist/dashboard/assets/HeadingTagInput-BTjrdzlS.js +1 -0
  15. package/dist/dashboard/assets/HowItWorks-AeyjfQGO.js +1 -0
  16. package/dist/dashboard/assets/PermissionGate-Bhhf7SMh.js +1 -0
  17. package/dist/dashboard/assets/PreviewPane-Dq5K1h2L.js +35 -0
  18. package/dist/dashboard/assets/ProfileEditor-BXtaE0tL.js +2 -0
  19. package/dist/dashboard/assets/ProfileList-CWKSAgu0.js +1 -0
  20. package/dist/dashboard/assets/ProfileVersionHistory-1h8sUgog.js +3 -0
  21. package/dist/dashboard/assets/RecordDetailModal-FaFhzQXi.js +1 -0
  22. package/dist/dashboard/assets/Reports-DWEjQOKh.js +1 -0
  23. package/dist/dashboard/assets/RoleList-BSGoqlzp.js +1 -0
  24. package/dist/dashboard/assets/Select-DjJNwlbY.js +1 -0
  25. package/dist/dashboard/assets/Settings-CVYrlyLm.js +1 -0
  26. package/dist/dashboard/assets/SetupWizard-4NTb5bZj.js +1 -0
  27. package/dist/dashboard/assets/Table-DH_jh8_N.js +1 -0
  28. package/dist/dashboard/assets/TenantList-5AwFtoHf.js +1 -0
  29. package/dist/dashboard/assets/UITemplateEditor-9A55scBe.js +1 -0
  30. package/dist/dashboard/assets/UITemplateList-BS7X4u4F.js +1 -0
  31. package/dist/dashboard/assets/UserList-SPkMyFRy.js +1 -0
  32. package/dist/dashboard/assets/VendorList-C5fPhr6U.js +1 -0
  33. package/dist/dashboard/assets/VisitorList-COP1SyNd.js +1 -0
  34. package/dist/dashboard/assets/check-o5cp1s2J.js +1 -0
  35. package/dist/dashboard/assets/client-ymc9nwWr.js +1 -0
  36. package/dist/dashboard/assets/copy-C01KoUUH.js +1 -0
  37. package/dist/dashboard/assets/createLucideIcon-MdjI3m3u.js +1 -0
  38. package/dist/dashboard/assets/de-DET92Jb5.js +5 -0
  39. package/dist/dashboard/assets/debounce-DlvAAT2V.js +1 -0
  40. package/dist/dashboard/assets/dist-DlhLroYC.js +1 -0
  41. package/dist/dashboard/assets/es-CJNunrnU.js +5 -0
  42. package/dist/dashboard/assets/external-link-DVe00ZBK.js +1 -0
  43. package/dist/dashboard/assets/fr-akfTuf5x.js +5 -0
  44. package/dist/dashboard/assets/history-CHTts_m2.js +1 -0
  45. package/dist/dashboard/assets/index-DW3qOrry.css +2 -0
  46. package/dist/dashboard/assets/index-H_Zm0X-u.js +6 -0
  47. package/dist/dashboard/assets/ja-B0nLWkOd.js +5 -0
  48. package/dist/dashboard/assets/profiles-DmrdGv7t.js +1 -0
  49. package/dist/dashboard/assets/roles-C0dJPFl4.js +1 -0
  50. package/dist/dashboard/assets/search-Bd2RdDRh.js +1 -0
  51. package/dist/dashboard/assets/templates-B2xiivh4.js +1 -0
  52. package/dist/dashboard/assets/templates-CnMoLavN.js +1 -0
  53. package/dist/dashboard/assets/trash-2-BBnkdgm_.js +1 -0
  54. package/dist/dashboard/assets/trash-Dj1jhzHJ.js +1 -0
  55. package/dist/dashboard/assets/useHashQuery-B5ytMIMp.js +1 -0
  56. package/dist/dashboard/assets/users-czK8LRVh.js +1 -0
  57. package/dist/dashboard/assets/x-BRuyBEQP.js +1 -0
  58. package/dist/dashboard/assets/zh-CN-Dxg3G-BL.js +5 -0
  59. package/dist/dashboard/index.html +4 -2
  60. package/dist/dist-Z5RVTVBT.js +2 -0
  61. package/dist/dist-Z5RVTVBT.js.map +1 -0
  62. package/dist/index.cjs +89 -100
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +663 -128
  65. package/dist/index.d.ts +663 -128
  66. package/dist/index.js +90 -101
  67. package/dist/index.js.map +1 -1
  68. package/dist/profiles-XTYPWLVC.js +2 -0
  69. package/dist/profiles-XTYPWLVC.js.map +1 -0
  70. package/dist/widget/widget.css +1 -1
  71. package/dist/widget/widget.js +1 -1
  72. package/package.json +14 -14
  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
@@ -129,6 +129,12 @@ const consenti = createConsenti({
129
129
  // Optional
130
130
  uri: 'mongodb://localhost:27017/consenti', // db connection string/uri, only for (driver: 'postgresql' | 'mysql' | 'mongodb')
131
131
  database: 'consenti', // database name when not in above uri
132
+
133
+ // Postgres/MySQL only — connection pool tuning
134
+ poolMax: 10, // max pool connections (default: 10, both drivers' own default)
135
+ statementTimeoutMs: 30_000, // abort a query after N ms — unset by default; a forced
136
+ // default could break a legitimate long-running export
137
+ idleInTransactionTimeoutMs: 30_000, // Postgres only: kill a connection idle inside an open transaction after N ms
132
138
  },
133
139
 
134
140
  // ── Auth ─────────────────────────────────────────────────────────────────────
@@ -190,8 +196,12 @@ const consenti = createConsenti({
190
196
  },
191
197
 
192
198
  // ── 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.
199
+ // When enabled, every locale JSON written to disk is also PUT to S3, plus a small
200
+ // pointer.json per compliance group ({ profileId, version }) written on activate —
201
+ // S3 has no symlink equivalent to the local hot-serve swap, so this pointer plays
202
+ // the same role. Consenti's own /resolve-profile route does NOT read from S3 or
203
+ // resolve this pointer — it's a contract for an external CDN/edge function to use
204
+ // if you want reads to bypass the Node process entirely.
195
205
  s3Api: {
196
206
  enabled: false,
197
207
  region: 'us-east-1',
@@ -204,9 +214,9 @@ const consenti = createConsenti({
204
214
  // ── Cache invalidation hook (optional) ──────────────────────────────────────
205
215
  // Called after every profile creation, edit, activation, deactivation, or delete.
206
216
  // isPurge=false = warm the cache at these paths; isPurge=true = purge.
207
- handleCache: (paths: string[], version: number, isPurge: boolean) => {
217
+ handleCache: (paths: string[], profileId: string, isPurge: boolean) => {
208
218
  // paths: array of file paths that were written or removed
209
- // version: current profile version
219
+ // profileId: id of the profile that triggered this write (stable across edits — see Profile Edit History)
210
220
  // isPurge: true when files were removed (deactivate/delete); false when written (activate/create)
211
221
  // Example: purge CDN edge nodes, update nginx cache keys, etc.
212
222
  },
@@ -219,6 +229,9 @@ const consenti = createConsenti({
219
229
  },
220
230
 
221
231
  // ── Age gate ─────────────────────────────────────────────────────────────────
232
+ // The actual gating prompt is widget-side (@consenti/ui README, "Age Gate" section) — this
233
+ // block only needs to exist so `ageVerified`/`parentalConsentToken` land in consent records;
234
+ // there's a matching `compliance.ageGate` block in the widget config with the same shape.
222
235
  ageGate: {
223
236
  enabled: false,
224
237
  minimumAge: 13, // COPPA = 13; GDPR Article 8 = 16
@@ -227,7 +240,8 @@ const consenti = createConsenti({
227
240
 
228
241
  // ── Data retention ───────────────────────────────────────────────────────────
229
242
  dataRetention: {
230
- purgeAfterDays: 365, // delete consent records older than N days; runs nightly
243
+ purgeAfterDays: 365, // delete consent records older than N days; runs nightly
244
+ auditLogPurgeAfterDays: 730, // optional, independent from purgeAfterDays — unset = keep forever
231
245
  },
232
246
 
233
247
  // ── Multi-tenant ─────────────────────────────────────────────────────────────
@@ -373,6 +387,46 @@ On first start with JSON, Consenti:
373
387
 
374
388
  Subsequent starts run only pending migrations.
375
389
 
390
+ ### Default Profile Seeding
391
+
392
+ `ProfileService` exposes two idempotent helpers for seeding the 8 built-in compliance groups:
393
+
394
+ ```ts
395
+ // Seed one group (skips if an active profile already exists for that group)
396
+ await profileService.seedDefaultProfile('opt-in')
397
+
398
+ // Seed all 8 groups in parallel
399
+ await profileService.seedAllDefaults()
400
+ ```
401
+
402
+ 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.
403
+
404
+ Locale coverage per compliance group:
405
+
406
+ | Group | en | de | es | fr | ja |
407
+ |-----------------------------|----|----|----|----|----|
408
+ | `opt-in` (GDPR) | ✓ | ✓ | ✓ | ✓ | ✓ |
409
+ | `opt-out` (CCPA) | ✓ | ✓ | ✓ | ✓ | ✓ |
410
+ | `opt-out-strict` (CPRA) | ✓ | ✓ | ✓ | ✓ | ✓ |
411
+ | `opt-in-dpdpa` (India) | ✓ | ✓ | ✓ | ✓ | ✓ |
412
+ | `opt-in-china` (PIPL) | ✓ | ✓ | ✓ | ✓ | ✓ |
413
+ | `opt-in-brazil` (LGPD) | ✓ | ✓ | ✓ | ✓ | ✓ |
414
+ | `general-privacy-consent` | ✓ | ✓ | ✓ | ✓ | ✓ |
415
+ | `notice-only` | ✓ | ✓ | ✓ | ✓ | ✓ |
416
+
417
+ 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).
418
+
419
+ ### First-Run Setup Wizard
420
+
421
+ 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.):
422
+
423
+ 1. **Welcome** — links to documentation and the in-dashboard "How Consenti Works" page. Skippable.
424
+ 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.
425
+ 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.
426
+ 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**.
427
+
428
+ 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)).
429
+
376
430
  ---
377
431
 
378
432
  ## Storage Directory Structure
@@ -393,19 +447,17 @@ ${storage.path}/
393
447
  default.json # copy of defaultLocale content (fallback)
394
448
  2/ # version 2 written on next save
395
449
  …
396
- ${complianceGroup}/ # hot-serve path (active profile only)
397
- en.json
398
- fr-FR.json
399
- default.json
450
+ ${complianceGroup}/ # → symlink to the active profile's version dir (hot-serve path)
400
451
  logs/ # reserved for future structured logging
401
452
  ```
402
453
 
403
454
  **Key rules:**
404
455
  - 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
456
+ - `${complianceGroup}/` only exists when a profile is **active** for that group — it's a directory symlink (junction on Windows), not a copy
457
+ - 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
458
+ - On **deactivate** / **delete**: the `${complianceGroup}/` symlink is removed (the version directory it pointed to is untouched)
408
459
  - `default.json` = full resolved profile for `defaultLocale`; used as locale fallback (303 redirect)
460
+ - 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
461
 
410
462
  **Backward compat:** If `storage.path` has a file extension (`.json`, `.db`), the parent directory is used and a deprecation warning is logged.
411
463
 
@@ -444,23 +496,35 @@ Cache headers: `public, max-age=3600, stale-while-revalidate=60` — suitable fo
444
496
  ### Resolve profile (for `compliance.type: 'auto'`)
445
497
 
446
498
  ```http
447
- GET /consenti/api/v1/resolve-profile?tz=Europe/Paris&lang=fr-FR&locale=fr-FR
499
+ GET /consenti/api/v1/resolve-profile?data=<base64-encoded-GeoHints>
500
+ ```
501
+
502
+ 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:
503
+
504
+ ```js
505
+ // Widget encodes automatically via encodeGeoHints()
506
+ const data = btoa(JSON.stringify({
507
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
508
+ languages: navigator.languages,
509
+ language: navigator.language,
510
+ locale: 'fr-FR',
511
+ }))
448
512
  ```
449
513
 
450
- For widgets using `compliance.type: 'auto'` — call this once on page load to discover which compliance group and locale the visitor should receive:
514
+ Response:
451
515
 
452
516
  ```json
453
517
  {
454
518
  "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
519
+ "complianceGroup": "opt-in",
520
+ "locale": "fr-FR",
521
+ "found": true
460
522
  }
461
523
  ```
462
524
 
463
- Query params: `tz` (IANA timezone), `lang` (Accept-Language value), `locale` (preferred BCP 47 locale).
525
+ 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.
526
+
527
+ Legacy query params `?tz=`, `?lang=`, `?locale=` are still accepted for backward compatibility — the `data` param takes priority when both are present.
464
528
 
465
529
  ### Submit consent
466
530
 
@@ -476,13 +540,16 @@ Content-Type: application/json
476
540
  "marketing": "denied"
477
541
  },
478
542
  "visitorId": "uuid-v4",
479
- "profileVersion": 3,
480
543
  "locale": "en",
481
544
  "gpcDetected": false,
482
- "source": "banner"
545
+ "source": "banner",
546
+ "ageVerified": true,
547
+ "parentalConsentToken": "pcon_..."
483
548
  }
484
549
  ```
485
550
 
551
+ `ageVerified`/`parentalConsentToken` are optional — only sent by the widget when `compliance.ageGate.enabled: true` (see the `@consenti/ui` README's "Age Gate" section). Both are stored as-is; this endpoint doesn't validate or enforce them.
552
+
486
553
  Response `201`:
487
554
 
488
555
  ```json
@@ -490,7 +557,6 @@ Response `201`:
490
557
  "id": "record-uuid",
491
558
  "visitorId": "visitor-uuid",
492
559
  "profileId": "my-profile-id",
493
- "profileVersion": 3,
494
560
  "locale": "en",
495
561
  "consentJson": { "necessary": "granted", "analytics": "granted", "marketing": "denied" },
496
562
  "gpcDetected": false,
@@ -510,10 +576,12 @@ GET /consenti/api/v1/consent/visitor-uuid/verify
510
576
  { "valid": true }
511
577
 
512
578
  // or, when stale:
513
- { "valid": false, "reason": "profile_version_mismatch" }
514
- // other reasons: "consent_expired" | "missing_cookies"
579
+ { "valid": false, "reason": "profile_changed" }
580
+ // other reasons: "consent_expired" | "hmac_invalid"
515
581
  ```
516
582
 
583
+ `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).
584
+
517
585
  ---
518
586
 
519
587
  ## Admin REST API
@@ -529,6 +597,7 @@ Obtain a token via `POST /consenti/admin/auth/login`.
529
597
  | `POST` | `/auth/login` | Authenticate — returns a JWT |
530
598
  | `GET` | `/auth/me` | Get current authenticated user |
531
599
  | `POST` | `/auth/logout` | Invalidate session |
600
+ | `POST` | `/auth/refresh` | Reissue a fresh token from the current one — extends the session |
532
601
 
533
602
  ```http
534
603
  POST /consenti/admin/auth/login
@@ -541,21 +610,27 @@ Content-Type: application/json
541
610
  { "token": "eyJhbGciOiJIUzI1NiJ9..." }
542
611
  ```
543
612
 
613
+ **Session length**: admin tokens are valid for 30 minutes from issuance, not a flat expiry from
614
+ login — the dashboard calls `POST /auth/refresh` on user activity (mouse/keyboard/scroll/touch) to
615
+ extend it another 30 minutes, and proactively logs the user out the moment 30 minutes pass with no
616
+ activity at all, rather than leaving the session to silently lapse in the background.
617
+
544
618
  ### Profiles
545
619
 
546
620
  | Method | Path | Description |
547
621
  |------------|----------------------------------|-------------------------------------------------------------------------|
548
622
  | `GET` | `/profiles` | List all profiles |
549
623
  | `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` |
624
+ | `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
625
  | `GET` | `/profiles/:id` | Get a profile |
552
- | `PUT` | `/profiles/:id` | Update a profile (auto-bumps version) — same compliance validation + conflict detection |
626
+ | `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
627
  | `DELETE` | `/profiles/:id` | Delete a profile |
628
+ | `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
629
  | `POST` | `/profiles/:id/activate` | Activate a profile — writes locale JSON files to `${complianceGroup}/` |
555
630
  | `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) |
631
+ | `GET` | `/profiles/:id/versions` | List every version snapshot on disk, newest first (`{ version, createdAt, locales[] }[]`) |
632
+ | `GET` | `/profiles/:id/versions/:entryId` | Read a specific version's locale file, `:entryId` is its `version` number (query: `?locale=en`) |
633
+ | `POST` | `/profiles/validate` | Validate a consent template (`cookies` + `categories`) against a compliance group (no save) |
559
634
  | `GET` | `/compliance-coverage` | Active profile (or null) per compliance group — powers coverage panel |
560
635
 
561
636
  #### Profile Save Conflict Detection
@@ -586,41 +661,44 @@ The dashboard profile editor shows a three-button popup when `requiresChoice: tr
586
661
  name: string
587
662
  defaultLocale: string
588
663
  complianceGroup: ComplianceGroupId | null
589
- version: number
664
+ customComplianceGroup: string | null
590
665
  isActive: boolean
591
- cookieTemplateName: string | null
666
+ consentTemplateName: string | null
592
667
  uiTemplateName: string | null
593
668
  createdAt: string
594
669
  updatedAt: string
595
670
  }
596
671
  ```
597
672
 
598
- ### Cookie Templates
673
+ ### Consent Templates
674
+
675
+ 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
676
 
600
677
  | Method | Path | Description |
601
678
  |------------|---------------------------------------------|----------------------------------------------------------|
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[]`) |
679
+ | `GET` | `/consent-templates` | List consent templates |
680
+ | `GET` | `/consent-templates/:id` | Get a consent template |
681
+ | `POST` | `/consent-templates` | Create a consent template — `{ name, cookies: CookieMap, categories: CategoryMap }` |
682
+ | `PUT` | `/consent-templates/:id` | Update a consent template — 422 if the change breaks compliance for a dependent profile |
683
+ | `DELETE` | `/consent-templates/:id` | Delete a consent template — 422 if any active profiles use it |
684
+ | `POST` | `/consent-templates/:id/copy` | Duplicate a consent template |
685
+ | `GET` | `/consent-templates/:id/profile-usage` | List profiles using this template (`ProfileSummary[]`) |
686
+
687
+ `CookieMap`/`CategoryMap` are keyed by id (`Record<string, Cookie>` / `Record<string, Category>`) — the id is the map key, not a field on the value.
609
688
 
610
- #### Cookie Template Safety Guards
689
+ #### Consent Template Safety Guards
611
690
 
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.
691
+ **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.
613
692
 
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:
693
+ **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
694
 
616
695
  ```json
617
696
  {
618
- "blockingProfiles": [{ "id": "...", "name": "...", "complianceGroup": "opt-in" }],
619
- "removedCookieIds": ["analytics", "marketing"]
697
+ "blockingProfiles": [{ "id": "...", "name": "...", "complianceGroup": "opt-in" }]
620
698
  }
621
699
  ```
622
700
 
623
- Profiles listed in `blockingProfiles` must be updated (switch template or change complianceGroup) before the cookie removal can proceed.
701
+ Profiles listed in `blockingProfiles` must be updated (switch template or change complianceGroup) before the change can be saved.
624
702
 
625
703
  ### UI Templates
626
704
 
@@ -660,7 +738,7 @@ Query params: `tenantId`, `profileId`, `complianceGroup`, `from` (ISO date), `to
660
738
  | `GET` | `/consents/:visitorId` | Get consent record for a visitor |
661
739
  | `GET` | `/consents/:visitorId/history` | Get consent change history |
662
740
 
663
- Query params for `GET /consents`: `page`, `limit`, `profileId`, `from`, `to`.
741
+ 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
742
 
665
743
  ### Visitors
666
744
 
@@ -668,6 +746,8 @@ Query params for `GET /consents`: `page`, `limit`, `profileId`, `from`, `to`.
668
746
  |--------|----------------|-------------------------------------|
669
747
  | `GET` | `/visitors` | List visitor records (paginated) |
670
748
 
749
+ Query params for `GET /visitors`: `page`, `limit` (max 500), `from`, `to`, `q` (**prefix** search across visitorId, country). Response: `{ items, total, page, limit }`.
750
+
671
751
  IPs are never stored raw — only SHA-256 hashes appear in the `ipHash` field.
672
752
 
673
753
  ### Users & Roles
@@ -706,11 +786,57 @@ The **Users** dashboard page includes an **Edit** button on each user row (visib
706
786
 
707
787
  ### API Keys
708
788
 
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 |
789
+ | Method | Path | Description |
790
+ |------------|----------------------------|------------------------------------------------------------------|
791
+ | `GET` | `/apikeys` | List API keys |
792
+ | `POST` | `/apikeys` | Create an API key (raw key returned once — save it!) |
793
+ | `DELETE` | `/apikeys/:id` | Revoke an API key (soft — can be undone with `reactivate`) |
794
+ | `POST` | `/apikeys/:id/reactivate` | Re-enable a revoked key — same hash, no new secret to distribute |
795
+ | `DELETE` | `/apikeys/:id/permanent` | Permanently delete the key row — cannot be undone |
796
+
797
+ ### Settings
798
+
799
+ Tenant-wide dashboard settings — the Public and Admin API origin allowlists shown on the API
800
+ Config page (split into two panels, one per API).
801
+
802
+ | Method | Path | Description |
803
+ |----------|-------------|-------------------------------------|
804
+ | `GET` | `/settings` | Get this tenant's settings (`{}` if none set yet) |
805
+ | `PATCH` | `/settings` | Update settings (partial — only sends fields being changed) |
806
+
807
+ ```ts
808
+ // GET /consenti/admin/settings
809
+ { "allowedOrigins": ["https://example.com"], "adminAllowedOrigins": ["https://dashboard.example.com"] }
810
+
811
+ // PATCH /consenti/admin/settings
812
+ { "allowedOrigins": ["https://example.com", "https://foo.example.com"] }
813
+ ```
814
+
815
+ **`allowedOrigins`** is the **fallback** used by `POST /consenti/api/v1/consent`'s origin check
816
+ when the specific profile being submitted to doesn't set its own `profileJson.allowedOrigins`
817
+ (profile-level, if present, always takes precedence). The public API has no auth token, so this
818
+ is its only access gate.
819
+
820
+ **`adminAllowedOrigins`** is an additional CORS-layer check on top of Bearer-token auth for
821
+ browser-originated `/consenti/admin/*` requests (server-to-server callers without an `Origin`
822
+ header are unaffected). Unauthenticated static assets (`widget.js`/`widget.css`) are exempt —
823
+ they must stay embeddable from any origin. **Be careful**: if you configure this, include the
824
+ dashboard's own origin or you'll lock yourself out of the dashboard along with everyone else.
825
+
826
+ Both lists accept full origins (`https://example.com`) or a wildcard subdomain pattern
827
+ (`*.example.com`). Empty/unset means no restriction — every origin is allowed.
828
+
829
+ ### Setup Wizard
830
+
831
+ 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.
832
+
833
+ | Method | Path | Description |
834
+ |--------|-------------------------------|--------------------------------------------------------------------|
835
+ | `GET` | `/setup/status` | `{ completed: boolean }` — whether this tenant has finished/skipped the wizard |
836
+ | `GET` | `/setup/config` | Resolved `ConsentiServerConfig` (secrets redacted) plus `usingJsonStorage`/`usingDefaultCredentials` readiness flags |
837
+ | `GET` | `/setup/compliance-groups` | The 8 built-in compliance groups with label/description/regulation metadata |
838
+ | `POST` | `/setup/seed-profiles` | `{ groups: string[] }` — seeds default profiles for the given groups (subset of the 8 ids; 400 on an unknown id) |
839
+ | `POST` | `/setup/complete` | Marks the wizard complete — called on both finish and skip; never reset from the dashboard afterward |
714
840
 
715
841
  ### Stats & Reporting
716
842
 
@@ -728,9 +854,10 @@ The **Users** dashboard page includes an **Edit** button on each user row (visib
728
854
 
729
855
  ### Audit Log
730
856
 
731
- | Method | Path | Description |
732
- |--------|-----------|---------------------------------------------------------|
733
- | `GET` | `/audit` | Paginated audit log; filter by `action`, `resourceType`, date range |
857
+ | Method | Path | Description |
858
+ |--------|--------------|---------------------------------------------------------|
859
+ | `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. |
860
+ | `GET` | `/audit/:id` | Full audit log entry (`oldData`/`newData`) — list rows omit these for performance; fetch on demand |
734
861
 
735
862
  ### Multi-Tenant
736
863
 
@@ -750,7 +877,10 @@ Only active when `multiTenant.enabled: true`.
750
877
  | `GET` | `/tcf/vendors` | List IAB TCF vendors |
751
878
  | `GET` | `/tcf/purposes` | List IAB TCF purposes |
752
879
 
753
- Only active when `tcf.enabled: true`.
880
+ Only active when `tcf.enabled: true`. `cmpId`/`cmpVersion` here must match the widget's own
881
+ `compliance.tcf` config (`@consenti/ui` README, "TCF" section) — both sides encode the same
882
+ simplified TC string. The widget installs `window.__tcfapi` itself; these two admin-only routes
883
+ are for the dashboard's vendor/purpose pickers, not consumed by the public widget.
754
884
 
755
885
  ---
756
886
 
@@ -761,23 +891,27 @@ Served at `{basePath}/` when `dashboard: true`.
761
891
  | Section | Description |
762
892
  |---------------------|--------------------------------------------------------------------------|
763
893
  | Dashboard | Consent overview, timeline chart, country breakdown, GPC stats |
894
+ | Reports | Opt-in trend and category/locale breakdowns, date-range filter, requires `stats:view` |
764
895
  | 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) |
896
+ | Profile History | Edit history viewer — browse every past snapshot in a profile's lineage |
897
+ | Consent Templates | Reusable parameter + category definitions (blank by default + Load Defaults) |
767
898
  | 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 |
899
+ | Consents | Browse, filter, search, paginate, export consent records; per-visitor history; row click opens a detail modal with signature/consent-data breakdown |
900
+ | Visitors | Visitor list with geographic data; search, paginate; row click opens a detail modal including proof-of-notice history |
901
+ | Users | Admin user management with allowed-tenant scoping; search; local-auth password reset (`super_admin` only) |
771
902
  | Roles | RBAC roles and fine-grained permission assignment |
772
903
  | Sites | Multi-tenant site management **(superadmin only)** |
773
904
  | TCF Vendors | IAB Global Vendor List |
774
- | Audit Log | Immutable log of all admin actions |
905
+ | Audit Log | Immutable log of all admin actions; search, paginate; row click opens a detail modal with old/new data diff |
775
906
  | Settings / API | API keys, branding, OpenAPI docs **(superadmin only)** |
907
+ | Setup Wizard | One-time first-run welcome / config / default-profiles / confirmation flow — see [First-Run Setup Wizard](#first-run-setup-wizard) |
776
908
 
777
909
  ### Dashboard RBAC
778
910
 
779
- - **Sites** and **API** sections are visible only to users with `role: 'superadmin'`
911
+ - **Sites** and **API** sections are visible only to users with the `super_admin` role
780
912
  - **TCF Vendors** is visible to all authenticated users
913
+ - **Reports** requires the `stats:view` permission (granted by default to `super_admin`, `admin`, `compliance_officer`, and `viewer` roles)
914
+ - **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
915
 
782
916
  ### Profile Creation Wizard
783
917
 
@@ -788,50 +922,88 @@ Served at `{basePath}/` when `dashboard: true`.
788
922
  | `name` | `string` | Human-readable profile name |
789
923
  | `defaultLocale` | `string` (BCP 47) | Locale served when visitor locale is unavailable; written as `default.json` |
790
924
  | `complianceGroup` | `ComplianceGroupId` | Regulation group for geo-routing (e.g. `opt-in`, `opt-out-strict`) |
925
+ | `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
926
  | `gpcMode` | `'ignore' \| 'honor' \| 'strict'` | GPC signal handling. Overrides group default. |
927
+ | `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
928
  | `darkMode` | `boolean` | Enable dark mode in the consent banner |
793
- | `hidePoweredBy` | `boolean` | Hide "Powered by Consenti" branding link |
929
+ | `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
930
  | `allowReceipt` | `boolean` | Allow visitors to download a PDF consent receipt |
795
931
  | `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. |
932
+ | `complianceConfig` | `Record<string, string>` | Per-compliance extra config (e.g. DPDPA data fiduciary name). Only shown when the selected `complianceGroup` requires it; otherwise omitted. |
933
+ | `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. |
934
+ | `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. |
935
+
936
+ **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`:
797
937
 
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`:
938
+ - **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).
939
+ - **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).
799
940
 
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.
941
+ Parameter fields:
802
942
 
803
943
  | Field | Type | Description |
804
944
  |---------------------|-------------------------------------------------|---------------------------------------------------------------------------|
805
- | `id` | `string` | Unique identifier referenced in button arrays |
806
- | `legalBasis` | `'mandatory' \| 'consent' \| 'legitimate_interest'` | Legal basis (replaces deprecated `mandatory`+`type` fields) |
945
+ | `id` | `string` (map key) | Unique identifier referenced in button arrays and category `cookies[]` |
946
+ | `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
947
  | `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 |
948
+ | `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. |
949
+ | `tcfVendorId` / `tcfPurposes` | `number` / `number[]` | IAB TCF vendor + purpose IDs associated with this parameter |
950
+ | `cpraCategory` | `'sale' \| 'sharing' \| 'sensitive'` | CPRA data category |
951
+
952
+ Category fields (own the legal basis for every parameter listed in `cookies[]` — a parameter must belong to **exactly one** category):
953
+
954
+ | Field | Type | Description |
955
+ |---------------------|--------------------------------------------------------|--------------------------------------------------------------------|
956
+ | `id` | `string` (map key) | Unique identifier |
957
+ | `legalBasis` | `'mandatory' \| 'consent' \| 'legitimate_interest'` | Legal basis for every parameter in `cookies[]` — replaces the old per-parameter `legalBasis` field |
958
+ | `cookies` | `string[]` | Parameter IDs belonging to this category |
959
+
960
+ Purpose defaults (applied to the parameter when a purpose is selected, all overridable; the category's `legalBasis` is authored separately):
961
+
962
+ | `purpose` | Suggested category `legalBasis` | `listenGpc` | `cpraCategory` |
963
+ |---------------|-----------------------------------|-------------|----------------|
964
+ | `necessary` | `mandatory` | `false` | — |
965
+ | `functional` | `legitimate_interest` | `false` | — |
966
+ | `preferences` | `legitimate_interest` | `false` | — |
967
+ | `analytics` | `consent` | `true` | — |
968
+ | `marketing` | `consent` | `true` | `sharing` |
810
969
 
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.
970
+ **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.
971
+
972
+ 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.
973
+
974
+ **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
975
 
813
976
  - **Main Banner / GPC Banner:** `position`, `overlayOpacity`, `showClose`, `headingTag`, `buttons`
814
- - **Preference Modal:** `position`, `overlayOpacity`, `showClose`, `persistent`, `buttons`, `categories`
977
+ - **Preference Modal:** `position`, `overlayOpacity`, `showClose`, `persistent`, `buttons` — categories are no longer edited here; they come from the Consent Template selected in Step 2.
978
+ - 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
979
  - Button `type`: `primary | secondary | text | reject | submit | manage | close`
816
980
  - Button `cookies`: `*` (grant all) · `!` (deny all) · comma-separated IDs
817
981
 
982
+ Additional UI template fields:
983
+
984
+ | Field | Scope | Type | Description |
985
+ |-------|-------|------|-------------|
986
+ | `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`. |
987
+ | `trapFocus` | Preference Modal | `boolean` | Confine Tab / Shift+Tab keyboard navigation within the modal while it is open. `Escape` closes and restores prior focus. |
988
+
818
989
  **Step 4 — Content:** Enter localised copy with live preview:
819
990
 
820
991
  - Main Banner: heading, body HTML
821
992
  - GPC Banner: heading, body HTML
822
- - Preference Modal: heading, subheading, intro HTML, per-category heading + description
993
+ - 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
994
+ - 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
995
 
824
996
  **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
997
 
826
998
  **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
999
 
828
- **Import / Export:**
1000
+ **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
1001
 
830
1002
  | Button | Action |
831
1003
  |--------|--------|
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 |
1004
+ | **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. |
1005
+ | **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. |
1006
+ | **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
1007
 
836
1008
  **Readability callouts:** Inline advisory warnings (yellow) appear beneath `heading` and body HTML fields when:
837
1009
  - A heading exceeds 80 characters
@@ -840,35 +1012,76 @@ Served at `{basePath}/` when `dashboard: true`.
840
1012
 
841
1013
  These are informational only — the profile can still be saved.
842
1014
 
1015
+ ### Mandatory Content Validation
1016
+
1017
+ `POST /profiles` and `PUT /profiles/:id` reject (`422`) a save where any locale is missing required
1018
+ content — this is what guarantees a profile can never end up with a blank banner. The dashboard
1019
+ wizard already blocks this client-side (hard stop on Next/Save, jumping to the offending locale
1020
+ tab); the backend check is the enforcement backstop for bulk CSV/JSON import, which bypasses the
1021
+ wizard's step-by-step gating entirely. Both use the same `hasVisibleText` check
1022
+ (`@consenti/utils`) so a rich-text field that *looks* non-empty by string length but renders no
1023
+ actual text (an empty paragraph node, for example) is still correctly caught as blank.
1024
+
1025
+ Mandatory per locale (heading is always optional, everywhere):
1026
+
1027
+ | Section | Field | Rule |
1028
+ |---|---|---|
1029
+ | Main Banner / GPC Banner | Body text (`htmlText`) | Required, non-blank |
1030
+ | Main Banner / GPC Banner | Every button's label | Required, non-blank |
1031
+ | Preference Modal | Heading | Required, non-blank |
1032
+ | Preference Modal | Every button's label | Required, non-blank |
1033
+ | Preference Modal | Every category's heading | Required, non-blank |
1034
+
1035
+ GPC Banner rules only apply to a locale that actually submitted `gpcBanner` content — a profile
1036
+ with `gpcMode: 'ignore'`, or a locale nobody's authored a GPC variant for, simply omits it. A
1037
+ failing save returns:
1038
+ ```json
1039
+ { "error": "Profile content is missing required fields", "details": { "errors": [
1040
+ { "locale": "hi", "section": "mainBanner", "field": "htmlText", "message": "Body text is required" }
1041
+ ] } }
1042
+ ```
1043
+ (`details` is only included outside `NODE_ENV=production`, same as the compliance-validation 422 above.)
1044
+
843
1045
  ### Profile Activation
844
1046
 
845
1047
  Profiles are **inactive** after creation. To serve them via geo-routing:
846
1048
 
847
1049
  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}/`
1050
+ 2. Consenti atomically repoints `${storage.path}/profiles/${tenantId}/${complianceGroup}/` (a symlink) at the profile's version directory
849
1051
  3. The static file route starts serving these files immediately (zero DB on the hot path)
850
1052
 
851
1053
  Only one profile per `complianceGroup` can be active at a time. Activating a new profile automatically deactivates the existing one.
852
1054
 
853
- ### Profile Version History
1055
+ ### Profile Edit History
854
1056
 
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
1057
+ `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:
1058
+ - Left panel: every version, newest first, by date
857
1059
  - Right panel: prettified JSON for the selected version; locale switcher dropdown
858
1060
 
859
1061
  Via API:
860
1062
  ```http
861
1063
  GET /consenti/admin/profiles/:id/versions
862
- GET /consenti/admin/profiles/:id/versions/3?locale=fr-FR
1064
+ GET /consenti/admin/profiles/:id/versions/:entryId?locale=fr-FR
863
1065
  ```
864
1066
 
1067
+ `:id` is the profile's stable id. `:entryId` is one specific version's number, as returned by the `versions` list.
1068
+
1069
+ ### Archived Profiles
1070
+
1071
+ `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.
1072
+
1073
+ ```http
1074
+ GET /consenti/admin/profiles/archived
1075
+ ```
1076
+ 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.
1077
+
865
1078
  ### Template Save Safety
866
1079
 
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.
1080
+ 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
1081
 
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.
1082
+ 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
1083
 
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).
1084
+ 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
1085
 
873
1086
  ---
874
1087
 
@@ -884,10 +1097,10 @@ When **removing cookies** from a Cookie Template, the backend checks whether the
884
1097
  | `consent.erased` | `{ visitorId: string }` | Visitor data erased (GDPR erasure) |
885
1098
  | `visitor.created` | `Visitor` | New visitor record created |
886
1099
  | `profile.created` | `Profile` | Profile created |
887
- | `profile.updated` | `{ previous, current: Profile }` | Profile updated (version bumped) |
1100
+ | `profile.updated` | `{ previous, current: Profile }` | Profile edited — `current.id` matches `previous.id`; `current.version` is incremented |
888
1101
  | `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 |
1102
+ | `cache:warm` | `{ paths: string[], profileId: string }` | Fired after locale JSON files are written; paths to warm in CDN/cache |
1103
+ | `cache:purge` | `{ paths: string[], profileId: string }` | Fired after locale JSON files are removed; paths to purge from CDN/cache |
891
1104
 
892
1105
  `cache:warm` and `cache:purge` mirror the `handleCache` config callback — use whichever suits your integration.
893
1106
 
@@ -906,6 +1119,74 @@ eventBus.on('consent.erased', ({ visitorId }) => {
906
1119
 
907
1120
  ---
908
1121
 
1122
+ ## ConsentAction / CategoryAction
1123
+
1124
+ 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.
1125
+
1126
+ 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)).
1127
+
1128
+ ### `ConsentAction`
1129
+
1130
+ Watches a single cookie parameter.
1131
+
1132
+ ```ts
1133
+ import { createConsenti, ConsentAction } from '@consenti/api'
1134
+
1135
+ const { eventBus } = createConsenti({ /* ... */ })
1136
+
1137
+ new ConsentAction({
1138
+ id: 'analytics_storage', // cookie id to watch
1139
+ eventBus,
1140
+ onGrant: ({ visitorId, cookieId, status, record }) => crm.optIn(visitorId),
1141
+ onDeny: ({ visitorId, cookieId, status, record }) => crm.optOut(visitorId),
1142
+ })
1143
+ ```
1144
+
1145
+ **Options** (`ConsentActionOptions`):
1146
+
1147
+ | Option | Type | Description |
1148
+ |---|---|---|
1149
+ | `id` | `string` | The consent parameter (cookie) ID to watch |
1150
+ | `eventBus` | `EventEmitter` | The `eventBus` returned by `createConsenti()` |
1151
+ | `onGrant` | `(params: ConsentActionParams) => void` | Called when the parameter transitions to `'granted'` |
1152
+ | `onDeny` | `(params: ConsentActionParams) => void` | Called when the parameter transitions away from `'granted'` (`'denied'` or `'objected'`) |
1153
+
1154
+ **Callback params** (`ConsentActionParams`): `{ visitorId: string, cookieId: string, status: ConsentStatus, record: ConsentDbRecord }`
1155
+
1156
+ ### `CategoryAction`
1157
+
1158
+ 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.
1159
+
1160
+ ```ts
1161
+ import { createConsenti, CategoryAction } from '@consenti/api'
1162
+
1163
+ const { eventBus, services } = createConsenti({ /* ... */ })
1164
+
1165
+ new CategoryAction({
1166
+ categoryId: 'marketing',
1167
+ eventBus,
1168
+ profiles: services.profile, // resolves the category's cookie ids per profile
1169
+ onGrant: ({ visitorId, categoryId, status, record }) => adsPlatform.optIn(visitorId),
1170
+ onDeny: ({ visitorId, categoryId, status, record }) => adsPlatform.optOut(visitorId),
1171
+ })
1172
+ ```
1173
+
1174
+ **Options** (`CategoryActionOptions`):
1175
+
1176
+ | Option | Type | Description |
1177
+ |---|---|---|
1178
+ | `categoryId` | `string` | The category ID to watch |
1179
+ | `eventBus` | `EventEmitter` | The `eventBus` returned by `createConsenti()` |
1180
+ | `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) |
1181
+ | `onGrant` | `(params: CategoryActionParams) => void` | Called when every cookie in the category transitions to `'granted'` |
1182
+ | `onDeny` | `(params: CategoryActionParams) => void` | Called when the category transitions away from fully granted |
1183
+
1184
+ **Callback params** (`CategoryActionParams`): `{ visitorId: string, categoryId: string, status: ConsentStatus, record: ConsentDbRecord }`
1185
+
1186
+ Both classes expose `destroy()` to remove their event listeners when the hook is no longer needed.
1187
+
1188
+ ---
1189
+
909
1190
  ## Plugins (Server-side)
910
1191
 
911
1192
  ```ts
@@ -981,6 +1262,22 @@ await consenti.destroy()
981
1262
  | Stack traces | Suppressed in `NODE_ENV=production` responses |
982
1263
  | CORS | Explicit allowlist — never use `origins: '*'` in production |
983
1264
  | Rate limiting | Applied to all public API routes by default |
1265
+ | Signed consent records | Opt-in via `consentSigningKey` — HMAC-SHA256 (`node:crypto` `createHmac`) over each record's core fields, hex-encoded into `signature`. No-op and no schema burden when unset. |
1266
+
1267
+ ### Signed consent records (opt-in)
1268
+
1269
+ Set `consentSigningKey` to have every consent record signed 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).
1270
+
1271
+ ```ts
1272
+ createConsenti({
1273
+ consentSigningKey: process.env.CONSENTI_CONSENT_SIGNING_KEY,
1274
+ })
1275
+ ```
1276
+
1277
+ - The signature covers `tenantId`, `visitorId`, `profileId`, `locale`, and the sorted `consentJson` entries — not the DB-generated `id`/`createdAt`/`updatedAt`.
1278
+ - `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.
1279
+ - `signature` is included in CSV/JSON/XLSX exports (`GET /export/consents`) and shown in the dashboard's Consents detail view.
1280
+ - Hash-chaining between records (a full tamper-evident log, not just per-record integrity) is not implemented — out of scope for now.
984
1281
 
985
1282
  ---
986
1283