@consenti/api 0.3.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 (113) hide show
  1. package/README.md +212 -74
  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-Bw-fx-BD.js → ArchivedProfileList-BlQ2gEWz.js} +1 -1
  8. package/dist/dashboard/assets/AuditLog-BpZoAFOe.js +1 -0
  9. package/dist/dashboard/assets/{ChangePassword-D2rPkzD1.js → ChangePassword-BZkP2dXQ.js} +1 -1
  10. package/dist/dashboard/assets/{ConfirmDialog-DTtw7VJ-.js → ConfirmDialog-BDlnfTdx.js} +1 -1
  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-AeyjfQGO.js → HowItWorks-CJCrdr3o.js} +1 -1
  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-1h8sUgog.js → ProfileVersionHistory-CX5-Oupy.js} +2 -2
  21. package/dist/dashboard/assets/{RecordDetailModal-FaFhzQXi.js → RecordDetailModal-DIpGxfqA.js} +1 -1
  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-DjJNwlbY.js → Select-C_JusbT5.js} +1 -1
  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-C01KoUUH.js → copy-DEi_W2TM.js} +1 -1
  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/dist-GoiMJCTt.js +1 -0
  40. package/dist/dashboard/assets/es-B6Jp0OPe.js +25 -0
  41. package/dist/dashboard/assets/{external-link-DVe00ZBK.js → external-link-CFZxxe3W.js} +1 -1
  42. package/dist/dashboard/assets/fr-CshYcQPW.js +25 -0
  43. package/dist/dashboard/assets/index-Cr5M1Qy_.css +2 -0
  44. package/dist/dashboard/assets/index-Tr_BcjkN.js +26 -0
  45. package/dist/dashboard/assets/ja-CgjZNBrc.js +25 -0
  46. package/dist/dashboard/assets/{profiles-DmrdGv7t.js → profiles-C_SuoLJD.js} +1 -1
  47. package/dist/dashboard/assets/{roles-C0dJPFl4.js → roles-D7L936FV.js} +1 -1
  48. package/dist/dashboard/assets/rotate-ccw-clock-6y2NDrJ7.js +1 -0
  49. package/dist/dashboard/assets/search-DKwfLBOv.js +1 -0
  50. package/dist/dashboard/assets/templates-CIyMjskA.js +1 -0
  51. package/dist/dashboard/assets/{templates-B2xiivh4.js → templates-DY68xNys.js} +1 -1
  52. package/dist/dashboard/assets/{trash-2-BBnkdgm_.js → trash-2-eX9s6x8V.js} +1 -1
  53. package/dist/dashboard/assets/{trash-Dj1jhzHJ.js → trash-O30ycMcz.js} +1 -1
  54. package/dist/dashboard/assets/{useHashQuery-B5ytMIMp.js → useHashQuery-BDjUPXDR.js} +1 -1
  55. package/dist/dashboard/assets/{users-czK8LRVh.js → users-BRrNOK0W.js} +1 -1
  56. package/dist/dashboard/assets/x-BhZiH8AL.js +1 -0
  57. package/dist/dashboard/assets/zh-CN-BnLZkfBC.js +25 -0
  58. package/dist/dashboard/index.html +4 -4
  59. package/dist/dist-QJYW5KUK.js +2 -0
  60. package/dist/index.cjs +68 -68
  61. package/dist/index.cjs.map +1 -1
  62. package/dist/index.d.cts +214 -52
  63. package/dist/index.d.ts +214 -52
  64. package/dist/index.js +65 -65
  65. package/dist/index.js.map +1 -1
  66. package/dist/profiles-FCX454HS.js +2 -0
  67. package/dist/widget/widget.css +1 -1
  68. package/dist/widget/widget.js +1 -1
  69. package/package.json +24 -9
  70. package/dist/chunk-IY4L6MNB.js +0 -2
  71. package/dist/chunk-IY4L6MNB.js.map +0 -1
  72. package/dist/chunk-O5UG4OYI.js +0 -2
  73. package/dist/chunk-O5UG4OYI.js.map +0 -1
  74. package/dist/dashboard/assets/ApiConfig-CE-Noelz.js +0 -1
  75. package/dist/dashboard/assets/AuditLog-D6HiTQ1o.js +0 -1
  76. package/dist/dashboard/assets/ConsentList-BWOvPOMG.js +0 -1
  77. package/dist/dashboard/assets/ConsentTemplateEditor-BKIEnLmA.js +0 -1
  78. package/dist/dashboard/assets/ConsentTemplateList-DCys2nM3.js +0 -1
  79. package/dist/dashboard/assets/HeadingTagInput-BTjrdzlS.js +0 -1
  80. package/dist/dashboard/assets/PermissionGate-Bhhf7SMh.js +0 -1
  81. package/dist/dashboard/assets/PreviewPane-Dq5K1h2L.js +0 -35
  82. package/dist/dashboard/assets/ProfileEditor-BXtaE0tL.js +0 -2
  83. package/dist/dashboard/assets/ProfileList-CWKSAgu0.js +0 -1
  84. package/dist/dashboard/assets/Reports-DWEjQOKh.js +0 -1
  85. package/dist/dashboard/assets/RoleList-BSGoqlzp.js +0 -1
  86. package/dist/dashboard/assets/Settings-CVYrlyLm.js +0 -1
  87. package/dist/dashboard/assets/SetupWizard-4NTb5bZj.js +0 -1
  88. package/dist/dashboard/assets/Table-DH_jh8_N.js +0 -1
  89. package/dist/dashboard/assets/TenantList-5AwFtoHf.js +0 -1
  90. package/dist/dashboard/assets/UITemplateEditor-9A55scBe.js +0 -1
  91. package/dist/dashboard/assets/UITemplateList-BS7X4u4F.js +0 -1
  92. package/dist/dashboard/assets/UserList-SPkMyFRy.js +0 -1
  93. package/dist/dashboard/assets/VendorList-C5fPhr6U.js +0 -1
  94. package/dist/dashboard/assets/VisitorList-COP1SyNd.js +0 -1
  95. package/dist/dashboard/assets/check-o5cp1s2J.js +0 -1
  96. package/dist/dashboard/assets/client-ymc9nwWr.js +0 -1
  97. package/dist/dashboard/assets/createLucideIcon-MdjI3m3u.js +0 -1
  98. package/dist/dashboard/assets/de-DET92Jb5.js +0 -5
  99. package/dist/dashboard/assets/dist-DlhLroYC.js +0 -1
  100. package/dist/dashboard/assets/es-CJNunrnU.js +0 -5
  101. package/dist/dashboard/assets/fr-akfTuf5x.js +0 -5
  102. package/dist/dashboard/assets/history-CHTts_m2.js +0 -1
  103. package/dist/dashboard/assets/index-DW3qOrry.css +0 -2
  104. package/dist/dashboard/assets/index-H_Zm0X-u.js +0 -6
  105. package/dist/dashboard/assets/ja-B0nLWkOd.js +0 -5
  106. package/dist/dashboard/assets/search-Bd2RdDRh.js +0 -1
  107. package/dist/dashboard/assets/templates-CnMoLavN.js +0 -1
  108. package/dist/dashboard/assets/x-BRuyBEQP.js +0 -1
  109. package/dist/dashboard/assets/zh-CN-Dxg3G-BL.js +0 -5
  110. package/dist/dist-Z5RVTVBT.js +0 -2
  111. package/dist/profiles-XTYPWLVC.js +0 -2
  112. /package/dist/{dist-Z5RVTVBT.js.map → dist-QJYW5KUK.js.map} +0 -0
  113. /package/dist/{profiles-XTYPWLVC.js.map → profiles-FCX454HS.js.map} +0 -0
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
  ---
@@ -142,7 +146,7 @@ const consenti = createConsenti({
142
146
  mode: 'local', // 'local' | 'jwt' | 'oidc' | 'saml' | 'custom'
143
147
  adminEmail: 'user@consenti.dev',
144
148
  adminPassword: process.env.CONSENTI_ADMIN_PASSWORD ?? "Consenti@123",
145
- 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)
146
150
 
147
151
  // OIDC (Auth0, Keycloak, Google, etc.)
148
152
  // mode: 'oidc',
@@ -150,7 +154,7 @@ const consenti = createConsenti({
150
154
  // issuer: 'https://your-idp.example.com',
151
155
  // clientId: 'your-client-id',
152
156
  // clientSecret: process.env.OIDC_SECRET,
153
- // redirectUri: 'https://app.example.com/consenti/admin/auth/oidc/callback',
157
+ // redirectUri: 'https://app.example.com/consenti/admin/v1/auth/oidc/callback',
154
158
  // claimsMapping: { email: 'email', roles: 'consenti_roles' },
155
159
  // },
156
160
 
@@ -160,7 +164,7 @@ const consenti = createConsenti({
160
164
  // issuer: 'https://idp.example.com',
161
165
  // entryPoint: 'https://idp.example.com/sso/saml',
162
166
  // cert: process.env.SAML_CERT!, // IdP signing cert (PEM, no headers)
163
- // callbackUrl: 'https://app.example.com/consenti/admin/auth/saml/callback',
167
+ // callbackUrl: 'https://app.example.com/consenti/admin/v1/auth/saml/acs',
164
168
  // },
165
169
 
166
170
  // Custom — bring your own authentication function
@@ -187,12 +191,54 @@ const consenti = createConsenti({
187
191
  },
188
192
 
189
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`.)
190
197
  compliance: {
191
198
  // Geo-based auto routing (recommended)
192
199
  type: 'auto', // 'auto' = geo-resolve per visitor | ComplianceGroupId = one fixed group globally
193
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
194
- autoComplianceMap: 'default',// 'default' = embedded 240-country map | operator object | 'auto' = fetch from complianceMapUrl
195
- // 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
+ },
196
242
  },
197
243
 
198
244
  // ── S3 profile file sync (optional) ─────────────────────────────────────────
@@ -221,29 +267,6 @@ const consenti = createConsenti({
221
267
  // Example: purge CDN edge nodes, update nginx cache keys, etc.
222
268
  },
223
269
 
224
- // ── IAB TCF v2.2 ─────────────────────────────────────────────────────────────
225
- tcf: {
226
- enabled: false,
227
- cmpId: 123, // your IAB-registered CMP ID (required when enabled)
228
- cmpVersion: 1, // your CMP software version
229
- },
230
-
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.
235
- ageGate: {
236
- enabled: false,
237
- minimumAge: 13, // COPPA = 13; GDPR Article 8 = 16
238
- requireParentalConsent: false,
239
- },
240
-
241
- // ── Data retention ───────────────────────────────────────────────────────────
242
- dataRetention: {
243
- purgeAfterDays: 365, // delete consent records older than N days; runs nightly
244
- auditLogPurgeAfterDays: 730, // optional, independent from purgeAfterDays — unset = keep forever
245
- },
246
-
247
270
  // ── Multi-tenant ─────────────────────────────────────────────────────────────
248
271
  multiTenant: {
249
272
  enabled: false, // scope all data to tenant from X-Tenant-ID header
@@ -319,7 +342,7 @@ server.listen(3001, () => {
319
342
  ### Express / Connect
320
343
 
321
344
  ```ts
322
- 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)
323
346
  // — or —
324
347
  app.use(consenti.handler) // as a terminal handler (no next())
325
348
  ```
@@ -548,7 +571,7 @@ Content-Type: application/json
548
571
  }
549
572
  ```
550
573
 
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.
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.
552
575
 
553
576
  Response `201`:
554
577
 
@@ -582,25 +605,60 @@ GET /consenti/api/v1/consent/visitor-uuid/verify
582
605
 
583
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).
584
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)" }
634
+ ```
635
+
585
636
  ---
586
637
 
587
638
  ## Admin REST API
588
639
 
589
- 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>`.
590
641
 
591
- Obtain a token via `POST /consenti/admin/auth/login`.
642
+ Obtain a token via `POST /consenti/admin/v1/auth/login`.
592
643
 
593
644
  ### Auth
594
645
 
595
- | Method | Path | Description |
596
- |--------|-------------------|-----------------------------------|
597
- | `POST` | `/auth/login` | Authenticate — returns a JWT |
598
- | `GET` | `/auth/me` | Get current authenticated user |
599
- | `POST` | `/auth/logout` | Invalidate session |
600
- | `POST` | `/auth/refresh` | Reissue a fresh token from the current one — extends the 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` |
601
659
 
602
660
  ```http
603
- POST /consenti/admin/auth/login
661
+ POST /consenti/admin/v1/auth/login
604
662
  Content-Type: application/json
605
663
 
606
664
  { "email": "admin@example.com", "password": "your-password" }
@@ -615,6 +673,19 @@ login — the dashboard calls `POST /auth/refresh` on user activity (mouse/keybo
615
673
  extend it another 30 minutes, and proactively logs the user out the moment 30 minutes pass with no
616
674
  activity at all, rather than leaving the session to silently lapse in the background.
617
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
+
618
689
  ### Profiles
619
690
 
620
691
  | Method | Path | Description |
@@ -630,6 +701,7 @@ activity at all, rather than leaving the session to silently lapse in the backgr
630
701
  | `POST` | `/profiles/:id/deactivate` | Deactivate a profile — removes `${complianceGroup}/` locale files |
631
702
  | `GET` | `/profiles/:id/versions` | List every version snapshot on disk, newest first (`{ version, createdAt, locales[] }[]`) |
632
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[]` |
633
705
  | `POST` | `/profiles/validate` | Validate a consent template (`cookies` + `categories`) against a compliance group (no save) |
634
706
  | `GET` | `/compliance-coverage` | Active profile (or null) per compliance group — powers coverage panel |
635
707
 
@@ -748,17 +820,23 @@ Query params for `GET /consents`: `page`, `limit` (max 500), `profileId`, `from`
748
820
 
749
821
  Query params for `GET /visitors`: `page`, `limit` (max 500), `from`, `to`, `q` (**prefix** search across visitorId, country). Response: `{ items, total, page, limit }`.
750
822
 
751
- IPs are never stored raw — only SHA-256 hashes appear in the `ipHash` field.
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.
752
824
 
753
825
  ### Users & Roles
754
826
 
755
827
  | Method | Path | Description |
756
828
  |------------|-----------------------------------|------------------------------------------------------|
757
829
  | `GET` | `/users` | List admin users |
830
+ | `GET` | `/users/:id` | Get an admin user |
758
831
  | `POST` | `/users` | Create an admin user |
759
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 |
760
836
  | `GET` | `/roles` | List roles |
761
837
  | `POST` | `/roles` | Create a role |
838
+ | `PUT` | `/roles/:id` | Update a role |
839
+ | `DELETE` | `/roles/:id` | Delete a role |
762
840
  | `GET` | `/roles/:id/permissions` | Get permissions for a role |
763
841
  | `POST` | `/roles/:id/permissions` | Assign a permission to a role |
764
842
  | `DELETE` | `/roles/:id/permissions/:permId` | Revoke a permission from a role |
@@ -805,10 +883,10 @@ Config page (split into two panels, one per API).
805
883
  | `PATCH` | `/settings` | Update settings (partial — only sends fields being changed) |
806
884
 
807
885
  ```ts
808
- // GET /consenti/admin/settings
886
+ // GET /consenti/admin/v1/settings
809
887
  { "allowedOrigins": ["https://example.com"], "adminAllowedOrigins": ["https://dashboard.example.com"] }
810
888
 
811
- // PATCH /consenti/admin/settings
889
+ // PATCH /consenti/admin/v1/settings
812
890
  { "allowedOrigins": ["https://example.com", "https://foo.example.com"] }
813
891
  ```
814
892
 
@@ -818,7 +896,7 @@ when the specific profile being submitted to doesn't set its own `profileJson.al
818
896
  is its only access gate.
819
897
 
820
898
  **`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`
899
+ browser-originated `/consenti/admin/v1/*` requests (server-to-server callers without an `Origin`
822
900
  header are unaffected). Unauthenticated static assets (`widget.js`/`widget.css`) are exempt —
823
901
  they must stay embeddable from any origin. **Be careful**: if you configure this, include the
824
902
  dashboard's own origin or you'll lock yourself out of the dashboard along with everyone else.
@@ -872,15 +950,37 @@ Only active when `multiTenant.enabled: true`.
872
950
 
873
951
  ### IAB TCF
874
952
 
875
- | Method | Path | Description |
876
- |--------|-------------------|---------------------------|
877
- | `GET` | `/tcf/vendors` | List IAB TCF vendors |
878
- | `GET` | `/tcf/purposes` | List IAB TCF purposes |
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 |
879
959
 
880
960
  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.
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.
884
984
 
885
985
  ---
886
986
 
@@ -902,7 +1002,7 @@ Served at `{basePath}/` when `dashboard: true`.
902
1002
  | Roles | RBAC roles and fine-grained permission assignment |
903
1003
  | Sites | Multi-tenant site management **(superadmin only)** |
904
1004
  | TCF Vendors | IAB Global Vendor List |
905
- | Audit Log | Immutable log of all admin actions; search, paginate; row click opens a detail modal with old/new data diff |
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 |
906
1006
  | Settings / API | API keys, branding, OpenAPI docs **(superadmin only)** |
907
1007
  | Setup Wizard | One-time first-run welcome / config / default-profiles / confirmation flow — see [First-Run Setup Wizard](#first-run-setup-wizard) |
908
1008
 
@@ -1060,8 +1160,8 @@ Only one profile per `complianceGroup` can be active at a time. Activating a new
1060
1160
 
1061
1161
  Via API:
1062
1162
  ```http
1063
- GET /consenti/admin/profiles/:id/versions
1064
- GET /consenti/admin/profiles/:id/versions/:entryId?locale=fr-FR
1163
+ GET /consenti/admin/v1/profiles/:id/versions
1164
+ GET /consenti/admin/v1/profiles/:id/versions/:entryId?locale=fr-FR
1065
1165
  ```
1066
1166
 
1067
1167
  `:id` is the profile's stable id. `:entryId` is one specific version's number, as returned by the `versions` list.
@@ -1071,7 +1171,7 @@ GET /consenti/admin/profiles/:id/versions/:entryId?locale=fr-FR
1071
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.
1072
1172
 
1073
1173
  ```http
1074
- GET /consenti/admin/profiles/archived
1174
+ GET /consenti/admin/v1/profiles/archived
1075
1175
  ```
1076
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.
1077
1177
 
@@ -1092,27 +1192,35 @@ When **changing parameters or categories** on a Consent Template, the backend re
1092
1192
  | Event | Payload | Description |
1093
1193
  |---|---|---|
1094
1194
  | `ready` | none | Storage connected and admin user bootstrapped |
1095
- | `consent.created` | `ConsentDbRecord` | New consent record saved |
1096
- | `consent.updated` | `{ previous, current: ConsentDbRecord }` | Consent record updated |
1097
- | `consent.erased` | `{ visitorId: string }` | Visitor data erased (GDPR erasure) |
1098
- | `visitor.created` | `Visitor` | New visitor record created |
1099
- | `profile.created` | `Profile` | Profile created |
1100
- | `profile.updated` | `{ previous, current: Profile }` | Profile edited — `current.id` matches `previous.id`; `current.version` is incremented |
1101
- | `profile.deleted` | `{ id: string, previous: Profile }` | Profile deleted |
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) |
1102
1204
  | `cache:warm` | `{ paths: string[], profileId: string }` | Fired after locale JSON files are written; paths to warm in CDN/cache |
1103
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.
1104
1212
 
1105
1213
  `cache:warm` and `cache:purge` mirror the `handleCache` config callback — use whichever suits your integration.
1106
1214
 
1107
1215
  ```ts
1108
1216
  const { eventBus } = createConsenti({ /* ... */ })
1109
1217
 
1110
- eventBus.on('consent.created', (record) => {
1218
+ eventBus.on('consent:created', (record) => {
1111
1219
  // send to analytics, trigger webhook, etc.
1112
1220
  console.log('New consent from visitor:', record.visitorId)
1113
1221
  })
1114
1222
 
1115
- eventBus.on('consent.erased', ({ visitorId }) => {
1223
+ eventBus.on('consent:erased', ({ visitorId }) => {
1116
1224
  myDmpClient.deleteVisitor(visitorId)
1117
1225
  })
1118
1226
  ```
@@ -1121,7 +1229,7 @@ eventBus.on('consent.erased', ({ visitorId }) => {
1121
1229
 
1122
1230
  ## ConsentAction / CategoryAction
1123
1231
 
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.
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.
1125
1233
 
1126
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)).
1127
1235
 
@@ -1255,22 +1363,25 @@ await consenti.destroy()
1255
1363
 
1256
1364
  | Concern | Approach |
1257
1365
  |---------------------------|------------------------------------------------------------------------------|
1258
- | 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 |
1259
1367
  | Passwords | `scrypt` via `node:crypto` — no external bcrypt |
1260
1368
  | JWTs | `createHmac` via `node:crypto` — no jsonwebtoken package |
1261
1369
  | Admin routes | Always require `Authorization: Bearer <jwt>` |
1262
1370
  | Stack traces | Suppressed in `NODE_ENV=production` responses |
1263
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 |
1264
1373
  | 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. |
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. |
1266
1375
 
1267
- ### Signed consent records (opt-in)
1376
+ ### Signed consent records
1268
1377
 
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).
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.
1270
1379
 
1271
1380
  ```ts
1272
1381
  createConsenti({
1273
- consentSigningKey: process.env.CONSENTI_CONSENT_SIGNING_KEY,
1382
+ compliance: {
1383
+ dataSigningHash: process.env.CONSENTI_DATA_SIGNING_HASH,
1384
+ },
1274
1385
  })
1275
1386
  ```
1276
1387
 
@@ -1279,6 +1390,32 @@ createConsenti({
1279
1390
  - `signature` is included in CSV/JSON/XLSX exports (`GET /export/consents`) and shown in the dashboard's Consents detail view.
1280
1391
  - Hash-chaining between records (a full tamper-evident log, not just per-record integrity) is not implemented — out of scope for now.
1281
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.
1418
+
1282
1419
  ---
1283
1420
 
1284
1421
  ## Environment Variables
@@ -1291,7 +1428,8 @@ All config fields can be set via environment variables. Code config takes preced
1291
1428
  | `CONSENTI_BASE_PATH` | `basePath` | `'/consenti'` |
1292
1429
  | `CONSENTI_ADMIN_EMAIL` | `auth.adminEmail` | `'user@consenti.dev'` |
1293
1430
  | `CONSENTI_ADMIN_PASSWORD` | `auth.adminPassword` | `'Consenti@123'` |
1294
- | `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 |
1295
1433
  | `CONSENTI_DB_DRIVER` | `storage.driver` | `'json'` |
1296
1434
  | `CONSENTI_DB_PATH` | `storage.path` | `'./consenti-data'` |
1297
1435
  | `CONSENTI_DB_URI` | `storage.uri` | — |
@@ -1317,7 +1455,7 @@ OpenAPI JSON at `{basePath}/api/openapi.json`.
1317
1455
  ## Demo
1318
1456
  For detailed API docs, Guides and Demo, visit [consenti.dev](https://consenti.dev)
1319
1457
 
1320
- Visit author's profile at [santosh.top](https://santosh.top)
1458
+ An open-source project by [Santosh Ojha](https://santosh.top)
1321
1459
 
1322
1460
 
1323
1461
  ---