@consenti/api 0.1.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 (74) hide show
  1. package/README.md +917 -207
  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 +129 -9089
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +963 -254
  65. package/dist/index.d.ts +963 -254
  66. package/dist/index.js +129 -9043
  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 -0
  71. package/dist/widget/widget.js +1 -2304
  72. package/package.json +19 -12
  73. package/dist/dashboard/assets/index-BBbhHyc_.js +0 -2637
  74. package/dist/dashboard/assets/index-DYK73WL5.css +0 -1
package/README.md CHANGED
@@ -8,12 +8,12 @@ Zero-dependency, GDPR-compliant Consent Management Platform backend for Node.js.
8
8
 
9
9
  ---
10
10
 
11
- ## Why this package?
11
+ ## Why Consenti?
12
12
 
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** — GDPR, CCPA, CPRA, GPC, TCF v2.2, DPDPA, LGPD, PDPA, PIPEDA, APPI, KVKK, POPIA, UK-GDPR
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
17
17
  - **Pluggable storage** — SQLite (zero-config, default) · PostgreSQL · MySQL · MongoDB
18
18
 
19
19
  ---
@@ -22,7 +22,45 @@ Zero-dependency, GDPR-compliant Consent Management Platform backend for Node.js.
22
22
 
23
23
  - **Node.js ≥ 20.0.0** — Node 22 or 24 LTS recommended for production
24
24
 
25
- ## Which SQLite driver should I use?
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ npm install @consenti/api
30
+ ```
31
+
32
+ ### Database / Storage
33
+ Consenti by default comes with JSON file based storage, to get started without any installation.
34
+ We have inbuilt SQLite, PostgreSQL, MySQL and MongoDB as storage adaptors, all are optional peer dependencies, only install any one of them that you need. Thats not all, you can create an adaptor for any other database you want to use by using [adapter.interface.ts](apps/api/src/storage/adapter.interface.ts)
35
+
36
+ > **Note:** Default JSON file based storage is only for development and small scale deployments, for production it is recommended to use other database options.
37
+
38
+ ```bash
39
+ # Databases (choose any one )
40
+
41
+ # 1. node:sqlite - Node native SQLite,
42
+ # needs no install on Node 22.5+
43
+
44
+ # OR 2. WASM SQLite — Node 20+, zero compilation (optional, install only if storage.driver: "node-sqlite3-wasm")
45
+ npm install node-sqlite3-wasm
46
+
47
+ # OR 3. native SQLite — Node 20+, faster but needs a C++ toolchain (optional, install only if storage.driver: "better-sqlite3")
48
+ npm install better-sqlite3
49
+
50
+ # OR 4. PostgreSQL (optional, install only if storage.driver: "postgresql")
51
+ npm install pg
52
+
53
+ # OR 5. MySQL (optional, install only if storage.driver: "mysql")
54
+ npm install mysql2
55
+
56
+ # OR 6. MongoDB (optional, install only if storage.driver: "mongodb")
57
+ npm install mongodb
58
+
59
+ # OR 7. Create your own custom database Adaptor using "apps/api/src/storage/adapter.interface.ts"
60
+
61
+ ```
62
+
63
+ ### Which SQLite driver should I use?
26
64
 
27
65
  | Driver | Config | Node requirement | Extra install? |
28
66
  |---|---|---|---|
@@ -34,29 +72,192 @@ Zero-dependency, GDPR-compliant Consent Management Platform backend for Node.js.
34
72
  `node:sqlite` is the only driver that requires no extra install — it ships with Node 22.5+.
35
73
  On Node 20/21, choose between `node-sqlite3-wasm` (pure WASM, no compilation) or `better-sqlite3` (native binary, faster but may fail on Alpine/musl/ARM).
36
74
 
37
- ---
38
75
 
39
- ## Installation
76
+ ### Geo resolvers - compliance routing
40
77
 
41
- ```bash
42
- npm install @consenti/api
43
- ```
78
+ Consenti ships with four geo resolver options. The default requires zero installation; the others are opt-in.
44
79
 
45
- All SQLite drivers and other database adapters are optional peer dependencies — only install what you need:
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 }`.
81
+
82
+ > **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
+
84
+ ### Which geo resolver should I use?
85
+
86
+ | Resolver | Config | Extra install? | Notes |
87
+ |---|---|---|---|
88
+ | `default` | `geoDataProvider: 'default'` | **No** | Timezone + Accept-Language heuristic; zero deps; less accurate |
89
+ | `hosted-geoip-lite` | `geoDataProvider: 'hosted-geoip-lite'` | **No** | Calls `ipinfo.io` via `node:https`; requires outbound internet |
90
+ | `geoip` | `geoDataProvider: 'geoip'` | Yes — `npm install geoip-lite` | Local MaxMind GeoLite2 DB via `geoip-lite`; fast, no outbound traffic |
91
+ | `maxmind` | `geoDataProvider: 'maxmind'` | Yes — `npm install maxmind` | Official MaxMind SDK; most accurate; requires `.mmdb` file |
92
+ | `CountryResolverFn` | `geoDataProvider: myFn` | Depends on your impl | Bring your own resolver — see custom example below |
46
93
 
47
94
  ```bash
48
- # SQLite (choose one — node:sqlite needs no install on Node 22.5+)
49
- npm install node-sqlite3-wasm # WASM SQLite — Node 20+, zero compilation
50
- npm install better-sqlite3 # native SQLite — Node 20+, faster but needs a C++ toolchain
95
+ # Geo resolvers (choose any one )
51
96
 
52
- # Other databases
53
- npm install pg # PostgreSQL
54
- npm install mysql2 # MySQL
55
- npm install mongodb # MongoDB
56
- ```
97
+ # 1. Default - language+timezone based
98
+ # No install needed
99
+
100
+ # OR 2. Hosted Geo-IP (optional — only needed when geoDataProvider: 'hosted-geoip-lite')
101
+ # No install needed — calls ipinfo.io using Node's built-in node:https
102
+ # Requires outbound internet access from your server
103
+
104
+ # OR 3. Geo-IP lite (optional — only needed when geoDataProvider: 'geoip')
105
+ npm install geoip-lite
106
+
107
+ # OR 4. Maxmind (optional — only needed when geoDataProvider: 'maxmind')
108
+ npm install maxmind
57
109
 
110
+ # OR 5. Custom (optional — only needed when geoDataProvider: CountryResolverFn)
111
+ # Do what it requires, just return `{ country, region, locale }` in supported format
112
+
113
+ ```
58
114
  ---
59
115
 
116
+
117
+ ## Full Configuration
118
+ Selected values are default
119
+
120
+ ```ts
121
+ import { createConsenti } from '@consenti/api'
122
+
123
+ const consenti = createConsenti({
124
+ // ── Storage ─────────────────────────────────────────────────────────────────
125
+ storage: {
126
+ driver: 'json', // 'json' | 'node:sqlite' | 'node-sqlite3-wasm' | 'better-sqlite3' | 'sqlite' | 'postgresql' | 'mysql' | 'mongodb'
127
+ path: './consenti-data', // base directory — Consenti creates db/, profiles/, logs/ subdirectories automatically
128
+
129
+ // Optional
130
+ uri: 'mongodb://localhost:27017/consenti', // db connection string/uri, only for (driver: 'postgresql' | 'mysql' | 'mongodb')
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
138
+ },
139
+
140
+ // ── Auth ─────────────────────────────────────────────────────────────────────
141
+ auth: {
142
+ mode: 'local', // 'local' | 'jwt' | 'oidc' | 'saml' | 'custom'
143
+ adminEmail: 'user@consenti.dev',
144
+ adminPassword: process.env.CONSENTI_ADMIN_PASSWORD ?? "Consenti@123",
145
+ jwtSecret: process.env.CONSENTI_ADMIN_JWT_SECRET, // auto-generated if omitted (sessions expire on restart)
146
+
147
+ // OIDC (Auth0, Keycloak, Google, etc.)
148
+ // mode: 'oidc',
149
+ // oidc: {
150
+ // issuer: 'https://your-idp.example.com',
151
+ // clientId: 'your-client-id',
152
+ // clientSecret: process.env.OIDC_SECRET,
153
+ // redirectUri: 'https://app.example.com/consenti/admin/auth/oidc/callback',
154
+ // claimsMapping: { email: 'email', roles: 'consenti_roles' },
155
+ // },
156
+
157
+ // SAML
158
+ // mode: 'saml',
159
+ // saml: {
160
+ // issuer: 'https://idp.example.com',
161
+ // entryPoint: 'https://idp.example.com/sso/saml',
162
+ // cert: process.env.SAML_CERT!, // IdP signing cert (PEM, no headers)
163
+ // callbackUrl: 'https://app.example.com/consenti/admin/auth/saml/callback',
164
+ // },
165
+
166
+ // Custom — bring your own authentication function
167
+ // mode: 'custom',
168
+ // validateUser: async (req: Request) => AdminUser | null,
169
+ },
170
+
171
+ // ── Routing ──────────────────────────────────────────────────────────────────
172
+ basePath: '/consenti', // URL prefix for all routes (default: '/consenti')
173
+ dashboard: true, // serve built-in admin SPA at basePath (default: true)
174
+
175
+ // ── Branding ─────────────────────────────────────────────────────────────────
176
+ branding: {
177
+ appName: 'Consenti', // shown in dashboard header, login page, browser tab
178
+ appLogoPath: './logo-dark.svg', // local file path or https:// URL; auto-served as static asset
179
+ hidePoweredBy: false, // true = hide "Powered by Consenti" badge
180
+ },
181
+
182
+ // ── Rate limiting ─────────────────────────────────────────────────────────────
183
+ rateLimit: {
184
+ enabled: true,
185
+ windowMs: 60_000, // rolling window in ms (default: 60 000)
186
+ maxRequests: 60, // max requests per IP per window (default: 60)
187
+ },
188
+
189
+ // ── Compliance ───────────────────────────────────────────────────────────────
190
+ compliance: {
191
+ // Geo-based auto routing (recommended)
192
+ type: 'auto', // 'auto' = geo-resolve per visitor | ComplianceGroupId = one fixed group globally
193
+ 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'
196
+ },
197
+
198
+ // ── S3 profile file sync (optional) ─────────────────────────────────────────
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.
205
+ s3Api: {
206
+ enabled: false,
207
+ region: 'us-east-1',
208
+ bucketName: 'consenti-profiles',
209
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
210
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
211
+ // sessionToken: process.env.AWS_SESSION_TOKEN, // for temporary credentials
212
+ },
213
+
214
+ // ── Cache invalidation hook (optional) ──────────────────────────────────────
215
+ // Called after every profile creation, edit, activation, deactivation, or delete.
216
+ // isPurge=false = warm the cache at these paths; isPurge=true = purge.
217
+ handleCache: (paths: string[], profileId: string, isPurge: boolean) => {
218
+ // paths: array of file paths that were written or removed
219
+ // profileId: id of the profile that triggered this write (stable across edits — see Profile Edit History)
220
+ // isPurge: true when files were removed (deactivate/delete); false when written (activate/create)
221
+ // Example: purge CDN edge nodes, update nginx cache keys, etc.
222
+ },
223
+
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
+ // ── Multi-tenant ─────────────────────────────────────────────────────────────
248
+ multiTenant: {
249
+ enabled: false, // scope all data to tenant from X-Tenant-ID header
250
+ },
251
+
252
+ // ── Proxies & body ───────────────────────────────────────────────────────────
253
+ trustedProxies: [], // IP / CIDR of reverse proxies (reads X-Forwarded-For)
254
+ maxBodySize: 1_048_576, // request body size limit in bytes (default: 1 MB)
255
+
256
+ // ── Plugins ──────────────────────────────────────────────────────────────────
257
+ plugins: [], // ConsentiServerPlugin instances
258
+ })
259
+ ```
260
+
60
261
  ## Quick Start
61
262
 
62
263
  ### Express
@@ -68,7 +269,7 @@ import { createConsenti } from '@consenti/api'
68
269
  const app = express()
69
270
 
70
271
  const consenti = createConsenti({
71
- storage: { driver: 'sqlite', path: './consenti.db' },
272
+ storage: { driver: 'node:sqlite', path: './consenti-data' },
72
273
  auth: {
73
274
  mode: 'local',
74
275
  adminEmail: 'admin@example.com',
@@ -89,10 +290,11 @@ app.listen(3000, () => {
89
290
  ### Standalone (no framework)
90
291
 
91
292
  ```ts
293
+ import { createServer } from 'node:http'
92
294
  import { createConsenti } from '@consenti/api'
93
295
 
94
296
  const consenti = createConsenti({
95
- storage: { driver: 'sqlite', path: './consenti.db' },
297
+ storage: { driver: 'node:sqlite', path: './consenti-data' },
96
298
  auth: {
97
299
  mode: 'local',
98
300
  adminEmail: 'admin@example.com',
@@ -101,9 +303,13 @@ const consenti = createConsenti({
101
303
  dashboard: true,
102
304
  })
103
305
 
104
- await consenti.start(3001)
105
- // Admin dashboard → http://localhost:3001/consenti/
106
- // REST API → http://localhost:3001/consenti/api/v1/
306
+ await consenti.ready // wait for DB + bootstrap
307
+
308
+ const server = createServer(consenti.handler)
309
+ server.listen(3001, () => {
310
+ console.log('Admin dashboard → http://localhost:3001/consenti/')
311
+ console.log('REST API → http://localhost:3001/consenti/api/v1/')
312
+ })
107
313
  ```
108
314
 
109
315
  ---
@@ -122,14 +328,13 @@ app.use(consenti.handler) // as a terminal handler (no next())
122
328
 
123
329
  ```ts
124
330
  import Fastify from 'fastify'
125
- const app = Fastify()
331
+ import { createConsenti } from '@consenti/api'
126
332
 
333
+ const fastify = Fastify()
127
334
  const consenti = createConsenti({ /* ... */ })
128
335
 
129
- app.all('/consenti/*', async (req, reply) => {
130
- reply.hijack()
131
- consenti.handler(req.raw, reply.raw)
132
- })
336
+ await fastify.register(consenti.fastifyHandler)
337
+ await fastify.listen({ port: 3000 })
133
338
  ```
134
339
 
135
340
  ### Next.js App Router
@@ -144,7 +349,7 @@ let consenti: Awaited<ReturnType<typeof createConsenti>>
144
349
  async function getConsenti() {
145
350
  if (!consenti) {
146
351
  consenti = createConsenti({
147
- storage: { driver: 'sqlite', path: './consenti.db' },
352
+ storage: { driver: 'sqlite', path: './consenti-data' },
148
353
  auth: {
149
354
  mode: 'local',
150
355
  adminEmail: process.env.CONSENTI_ADMIN_EMAIL!,
@@ -155,12 +360,12 @@ async function getConsenti() {
155
360
  return consenti
156
361
  }
157
362
 
158
- export async function GET(req: NextRequest) {
363
+ async function consentiHandler(req: NextRequest) {
159
364
  const c = await getConsenti()
160
365
  return c.handleRequest(req)
161
366
  }
162
367
 
163
- export { GET as POST, GET as PUT, GET as DELETE, GET as PATCH }
368
+ export { consentiHandler as POST, consentiHandler as PUT, consentiHandler as DELETE, consentiHandler as PATCH, consentiHandler as GET }
164
369
  ```
165
370
 
166
371
  ### Hono / Fetch-based
@@ -171,117 +376,90 @@ return consenti.honoApp.fetch(request)
171
376
 
172
377
  ---
173
378
 
174
- ## Full Configuration
379
+ ## First Boot
175
380
 
176
- ```ts
177
- import { createConsenti } from '@consenti/api'
381
+ On first start with JSON, Consenti:
178
382
 
179
- const consenti = createConsenti({
180
- // ── Required ────────────────────────────────────────────────────────────────
181
- storage: {
182
- driver: 'node-sqlite3-wasm', // 'node-sqlite3-wasm' | 'node:sqlite' | 'better-sqlite3' | 'pg' | 'mongodb' | 'mysql'
183
- path: './consenti.db', // SQLite only — file path
184
-
185
- // PostgreSQL
186
- // driver: 'pg',
187
- // url: 'postgresql://user:pass@localhost:5432/consenti',
188
- // pool: { max: 10, idleTimeoutMillis: 30000 },
189
-
190
- // MongoDB
191
- // driver: 'mongodb',
192
- // url: 'mongodb://localhost:27017',
193
- // dbName: 'consenti',
194
-
195
- // MySQL
196
- // driver: 'mysql',
197
- // url: 'mysql://user:pass@localhost:3306/consenti',
198
- },
199
- auth: {
200
- mode: 'local', // 'local' | 'oauth' | 'saml' | 'none'
201
- adminEmail: 'admin@example.com',
202
- adminPassword: process.env.CONSENTI_ADMIN_PASSWORD!,
203
- jwtSecret: process.env.CONSENTI_ADMIN_JWT_SECRET, // auto-generated if omitted (changes on restart)
204
- sessionTtl: 86400, // JWT TTL in seconds (default: 24 h)
383
+ 1. Creates the storage directory at the configured path (see [Storage Directory Structure](#storage-directory-structure))
384
+ 2. Runs all migrations (creates `visitors`, `consent_records`, `consent_history`, `profiles`, `admin_users`, `audit_logs` tables, and DB indexes)
385
+ 3. Creates the admin user from `auth.adminEmail` + `auth.adminPassword`
386
+ 4. Seeds the default profile (`profileId: '0'`)
205
387
 
206
- // OAuth / OIDC (e.g. Auth0, Keycloak)
207
- // mode: 'oauth',
208
- // oidc: {
209
- // issuer: 'https://your-idp.example.com',
210
- // clientId: 'your-client-id',
211
- // clientSecret: process.env.OIDC_SECRET,
212
- // redirectUri: 'https://app.example.com/consenti/admin/auth/callback',
213
- // },
388
+ Subsequent starts run only pending migrations.
214
389
 
215
- // SAML
216
- // mode: 'saml',
217
- // saml: {
218
- // issuer: 'consenti',
219
- // entryPoint: 'https://idp.example.com/sso/saml',
220
- // cert: process.env.SAML_CERT,
221
- // callbackUrl: 'https://app.example.com/consenti/admin/auth/saml/callback',
222
- // },
390
+ ### Default Profile Seeding
223
391
 
224
- // Custom — bring your own validator
225
- // mode: 'custom',
226
- // validateUser: async (req) => ({ id, email, roles, permissions } | null),
227
- },
392
+ `ProfileService` exposes two idempotent helpers for seeding the 8 built-in compliance groups:
228
393
 
229
- // ── Optional ────────────────────────────────────────────────────────────────
230
- basePath: '/consenti', // URL prefix (default: '/consenti')
231
- dashboard: true, // serve admin SPA (default: false)
232
- trustProxy: false, // true when behind nginx / Cloudflare
394
+ ```ts
395
+ // Seed one group (skips if an active profile already exists for that group)
396
+ await profileService.seedDefaultProfile('opt-in')
233
397
 
234
- rateLimit: {
235
- enabled: true,
236
- windowMs: 60_000, // rolling window (default: 60 s)
237
- maxRequests: 60, // max requests per IP per window (default: 60)
238
- },
398
+ // Seed all 8 groups in parallel
399
+ await profileService.seedAllDefaults()
400
+ ```
239
401
 
240
- cors: {
241
- origins: ['https://yoursite.com'], // never use '*' in production
242
- credentials: true,
243
- },
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.
244
403
 
245
- compliance: {
246
- gdpr: true, // opt-in enforcement
247
- ccpa: false, // opt-out model
248
- gpc: true, // store gpc_detected on records
249
- tcf: {
250
- enabled: false, // IAB TCF v2.2
251
- cmpId: 123, // your registered CMP ID
252
- },
253
- },
404
+ Locale coverage per compliance group:
254
405
 
255
- ageGate: {
256
- enabled: false,
257
- minimumAge: 13,
258
- requireParentalConsent: false,
259
- },
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` | ✓ | ✓ | ✓ | ✓ | ✓ |
260
416
 
261
- dataRetention: {
262
- purgeAfterDays: 365, // auto-purge old records (0 = disabled)
263
- },
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).
264
418
 
265
- multiTenant: {
266
- enabled: false, // isolate data per tenant
267
- },
419
+ ### First-Run Setup Wizard
268
420
 
269
- plugins: [], // ConsentiServerPlugin instances
270
- })
271
- ```
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)).
272
429
 
273
430
  ---
274
431
 
275
- ## First Boot
432
+ ## Storage Directory Structure
276
433
 
277
- On first start with SQLite, Consenti:
434
+ `storage.path` is a **directory path**. Consenti creates the following layout automatically:
278
435
 
279
- 1. Creates the database file at the configured path
280
- 2. Runs all migrations (creates `visitors`, `consent_records`, `consent_history`, `profiles`, `admin_users`, `audit_logs` tables)
281
- 3. Creates the admin user from `auth.adminEmail` + `auth.adminPassword`
282
- 4. Seeds the default profile (`profileId: '0'`)
436
+ ```
437
+ ${storage.path}/
438
+ db/
439
+ consenti-data.json # JSON adapter
440
+ consenti.db # SQLite adapters
441
+ profiles/
442
+ ${tenantId}/
443
+ ${profileId}/
444
+ 1/ # version 1 (immutable once written)
445
+ en.json
446
+ fr-FR.json
447
+ default.json # copy of defaultLocale content (fallback)
448
+ 2/ # version 2 written on next save
449
+ …
450
+ ${complianceGroup}/ # → symlink to the active profile's version dir (hot-serve path)
451
+ logs/ # reserved for future structured logging
452
+ ```
283
453
 
284
- Subsequent starts run only pending migrations.
454
+ **Key rules:**
455
+ - Every profile save writes to a new immutable version directory under `${profileId}/${version}/`
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)
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
461
+
462
+ **Backward compat:** If `storage.path` has a file extension (`.json`, `.db`), the parent directory is used and a deprecation warning is logged.
285
463
 
286
464
  ---
287
465
 
@@ -289,15 +467,64 @@ Subsequent starts run only pending migrations.
289
467
 
290
468
  Base path: `/consenti/api/v1` (change with `basePath` in config). No authentication required.
291
469
 
292
- | Method | Path | Description |
293
- |------------|-------------------------------|-------------------------------------------------|
294
- | `GET` | `/profiles/:id` | Get profile resolved in its default locale |
295
- | `GET` | `/profiles/:id/:locale` | Get profile resolved for a specific locale |
296
- | `POST` | `/consent` | Submit a consent record |
297
- | `GET` | `/consent/:visitorId` | Get current consent for a visitor |
298
- | `PUT` | `/consent/:visitorId` | Update consent for a visitor |
299
- | `DELETE` | `/consent/:visitorId` | GDPR right-to-erasure — delete all visitor data |
300
- | `GET` | `/consent/:visitorId/verify` | Check if consent is still valid |
470
+ | Method | Path | Description |
471
+ |------------|---------------------------------------------|------------------------------------------------------------------------|
472
+ | `GET` | `/profiles/:tenantId/:complianceGroup/:locale` | **Hot-serve** — serve locale JSON directly from disk (zero DB) |
473
+ | `GET` | `/profiles/:tenantId/:profileId/:version/:locale` | Serve a specific version locale JSON (dashboard preview) |
474
+ | `GET` | `/resolve-profile` | Geo-resolve compliance group, return file path + locale |
475
+ | `GET` | `/profiles/:id` | get profile by ID (resolved, DB-backed, default tenant, default locale)|
476
+ | `GET` | `/profiles/:id/:locale` | get profile by ID & locale (resolved, DB-backed, default tenant) |
477
+ | `GET` | `/profiles/auto/:locale` | Legacy: geo-resolve then return matching active profile |
478
+ | `POST` | `/consent` | Submit a consent record |
479
+ | `GET` | `/consent/:visitorId` | Get current consent for a visitor |
480
+ | `PUT` | `/consent/:visitorId` | Update consent for a visitor |
481
+ | `DELETE` | `/consent/:visitorId` | GDPR right-to-erasure — delete all visitor data |
482
+ | `GET` | `/consent/:visitorId/verify` | Check if consent is still valid |
483
+
484
+ ### Static file serving (hot path)
485
+
486
+ ```http
487
+ GET /consenti/api/v1/profiles/default/opt-in/en
488
+ ```
489
+
490
+ Serves `${storage.path}/profiles/default/opt-in/en.json` directly from disk. No DB involved.
491
+
492
+ If the locale file doesn't exist, returns `303 See Other → .../opt-in/default` (which serves `default.json` = `defaultLocale` content).
493
+
494
+ Cache headers: `public, max-age=3600, stale-while-revalidate=60` — suitable for CDN caching.
495
+
496
+ ### Resolve profile (for `compliance.type: 'auto'`)
497
+
498
+ ```http
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
+ }))
512
+ ```
513
+
514
+ Response:
515
+
516
+ ```json
517
+ {
518
+ "path": "/consenti/api/v1/profiles/default/opt-in/fr-FR",
519
+ "complianceGroup": "opt-in",
520
+ "locale": "fr-FR",
521
+ "found": true
522
+ }
523
+ ```
524
+
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.
301
528
 
302
529
  ### Submit consent
303
530
 
@@ -313,13 +540,16 @@ Content-Type: application/json
313
540
  "marketing": "denied"
314
541
  },
315
542
  "visitorId": "uuid-v4",
316
- "profileVersion": 3,
317
543
  "locale": "en",
318
544
  "gpcDetected": false,
319
- "source": "banner"
545
+ "source": "banner",
546
+ "ageVerified": true,
547
+ "parentalConsentToken": "pcon_..."
320
548
  }
321
549
  ```
322
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
+
323
553
  Response `201`:
324
554
 
325
555
  ```json
@@ -327,7 +557,6 @@ Response `201`:
327
557
  "id": "record-uuid",
328
558
  "visitorId": "visitor-uuid",
329
559
  "profileId": "my-profile-id",
330
- "profileVersion": 3,
331
560
  "locale": "en",
332
561
  "consentJson": { "necessary": "granted", "analytics": "granted", "marketing": "denied" },
333
562
  "gpcDetected": false,
@@ -347,10 +576,12 @@ GET /consenti/api/v1/consent/visitor-uuid/verify
347
576
  { "valid": true }
348
577
 
349
578
  // or, when stale:
350
- { "valid": false, "reason": "profile_version_mismatch" }
351
- // other reasons: "consent_expired" | "missing_cookies"
579
+ { "valid": false, "reason": "profile_changed" }
580
+ // other reasons: "consent_expired" | "hmac_invalid"
352
581
  ```
353
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
+
354
585
  ---
355
586
 
356
587
  ## Admin REST API
@@ -366,6 +597,7 @@ Obtain a token via `POST /consenti/admin/auth/login`.
366
597
  | `POST` | `/auth/login` | Authenticate — returns a JWT |
367
598
  | `GET` | `/auth/me` | Get current authenticated user |
368
599
  | `POST` | `/auth/logout` | Invalidate session |
600
+ | `POST` | `/auth/refresh` | Reissue a fresh token from the current one — extends the session |
369
601
 
370
602
  ```http
371
603
  POST /consenti/admin/auth/login
@@ -378,15 +610,125 @@ Content-Type: application/json
378
610
  { "token": "eyJhbGciOiJIUzI1NiJ9..." }
379
611
  ```
380
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
+
381
618
  ### Profiles
382
619
 
383
- | Method | Path | Description |
384
- |------------|-------------------|------------------------------------------|
385
- | `GET` | `/profiles` | List all profiles |
386
- | `POST` | `/profiles` | Create a profile |
387
- | `GET` | `/profiles/:id` | Get a profile |
388
- | `PUT` | `/profiles/:id` | Update a profile (auto-bumps version) |
389
- | `DELETE` | `/profiles/:id` | Delete a profile |
620
+ | Method | Path | Description |
621
+ |------------|----------------------------------|-------------------------------------------------------------------------|
622
+ | `GET` | `/profiles` | List all profiles |
623
+ | `GET` | `/profiles?summary=1` | List profiles as `ProfileSummary[]` (no blob, with template names) |
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` |
625
+ | `GET` | `/profiles/:id` | Get a profile |
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 |
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}` |
629
+ | `POST` | `/profiles/:id/activate` | Activate a profile — writes locale JSON files to `${complianceGroup}/` |
630
+ | `POST` | `/profiles/:id/deactivate` | Deactivate a profile — removes `${complianceGroup}/` locale files |
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) |
634
+ | `GET` | `/compliance-coverage` | Active profile (or null) per compliance group — powers coverage panel |
635
+
636
+ #### Profile Save Conflict Detection
637
+
638
+ Only one profile per `complianceGroup` can be active at a time. If you attempt to create or update a profile with `isActive: true` and another active profile already exists for the same `complianceGroup`, the backend returns **200** (not an error) with:
639
+
640
+ ```json
641
+ {
642
+ "conflict": { "id": "existing-profile-uuid", "name": "GDPR Profile v1" },
643
+ "requiresChoice": true
644
+ }
645
+ ```
646
+
647
+ Re-submit the original request with a `choice` field to resolve:
648
+
649
+ ```json
650
+ { "choice": "deactivate" } // deactivate the conflicting profile, then save this one as active
651
+ { "choice": "inactive" } // save this profile as inactive (no conflict)
652
+ ```
653
+
654
+ The dashboard profile editor shows a three-button popup when `requiresChoice: true` — "Deactivate existing and activate this one", "Save as inactive", and "Cancel".
655
+
656
+ #### ProfileSummary shape
657
+
658
+ ```ts
659
+ {
660
+ id: string
661
+ name: string
662
+ defaultLocale: string
663
+ complianceGroup: ComplianceGroupId | null
664
+ customComplianceGroup: string | null
665
+ isActive: boolean
666
+ consentTemplateName: string | null
667
+ uiTemplateName: string | null
668
+ createdAt: string
669
+ updatedAt: string
670
+ }
671
+ ```
672
+
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).
676
+
677
+ | Method | Path | Description |
678
+ |------------|---------------------------------------------|----------------------------------------------------------|
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.
688
+
689
+ #### Consent Template Safety Guards
690
+
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.
692
+
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:
694
+
695
+ ```json
696
+ {
697
+ "blockingProfiles": [{ "id": "...", "name": "...", "complianceGroup": "opt-in" }]
698
+ }
699
+ ```
700
+
701
+ Profiles listed in `blockingProfiles` must be updated (switch template or change complianceGroup) before the change can be saved.
702
+
703
+ ### UI Templates
704
+
705
+ | Method | Path | Description |
706
+ |------------|---------------------------------------------|----------------------------------------------------------|
707
+ | `GET` | `/ui-templates` | List UI templates |
708
+ | `GET` | `/ui-templates/:id` | Get a UI template |
709
+ | `POST` | `/ui-templates` | Create a UI template |
710
+ | `PUT` | `/ui-templates/:id` | Update a UI template |
711
+ | `DELETE` | `/ui-templates/:id` | Delete a UI template |
712
+ | `POST` | `/ui-templates/:id/copy` | Duplicate a UI template |
713
+ | `GET` | `/ui-templates/:id/profile-usage` | List profiles using this template (`ProfileSummary[]`) |
714
+
715
+ ### Analytics
716
+
717
+ | Method | Path | Description |
718
+ |--------|-------------------------|----------------------------------------------------|
719
+ | `GET` | `/analytics/opt-in` | Opt-in rate stats aggregated by locale and date |
720
+
721
+ Query params: `tenantId`, `profileId`, `complianceGroup`, `from` (ISO date), `to` (ISO date), `locale`.
722
+
723
+ ```json
724
+ {
725
+ "total": 12400,
726
+ "granted": 6800, "denied": 4100, "managed": 1500,
727
+ "grantedPct": 54.8, "deniedPct": 33.1, "managedPct": 12.1,
728
+ "byLocale": { "en": { "total": 8000, "granted": 4400 } },
729
+ "byDate": [{ "date": "2026-07-01", "total": 400, "granted": 220 }]
730
+ }
731
+ ```
390
732
 
391
733
  ### Consents
392
734
 
@@ -396,7 +738,7 @@ Content-Type: application/json
396
738
  | `GET` | `/consents/:visitorId` | Get consent record for a visitor |
397
739
  | `GET` | `/consents/:visitorId/history` | Get consent change history |
398
740
 
399
- 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.
400
742
 
401
743
  ### Visitors
402
744
 
@@ -404,48 +746,118 @@ Query params for `GET /consents`: `page`, `limit`, `profileId`, `from`, `to`.
404
746
  |--------|----------------|-------------------------------------|
405
747
  | `GET` | `/visitors` | List visitor records (paginated) |
406
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
+
407
751
  IPs are never stored raw — only SHA-256 hashes appear in the `ipHash` field.
408
752
 
409
753
  ### Users & Roles
410
754
 
411
- | Method | Path | Description |
412
- |------------|-----------------------------------|----------------------------------|
413
- | `GET` | `/users` | List admin users |
414
- | `POST` | `/users` | Create an admin user |
415
- | `PUT` | `/users/:id` | Update an admin user |
416
- | `GET` | `/roles` | List roles |
417
- | `POST` | `/roles` | Create a role |
418
- | `GET` | `/roles/:id/permissions` | Get permissions for a role |
419
- | `POST` | `/roles/:id/permissions` | Assign a permission to a role |
420
- | `DELETE` | `/roles/:id/permissions/:permId` | Revoke a permission from a role |
421
- | `GET` | `/permissions` | List all available permissions |
755
+ | Method | Path | Description |
756
+ |------------|-----------------------------------|------------------------------------------------------|
757
+ | `GET` | `/users` | List admin users |
758
+ | `POST` | `/users` | Create an admin user |
759
+ | `PUT` | `/users/:id` | Update an admin user |
760
+ | `GET` | `/roles` | List roles |
761
+ | `POST` | `/roles` | Create a role |
762
+ | `GET` | `/roles/:id/permissions` | Get permissions for a role |
763
+ | `POST` | `/roles/:id/permissions` | Assign a permission to a role |
764
+ | `DELETE` | `/roles/:id/permissions/:permId` | Revoke a permission from a role |
765
+ | `GET` | `/permissions` | List all available permissions |
766
+
767
+ #### User `allowedTenants`
768
+
769
+ Admin users can be scoped to specific tenants. When `allowedTenants` is set, the user can only see and manage data for those tenants:
770
+
771
+ ```ts
772
+ // POST /admin/users or PUT /admin/users/:id
773
+ {
774
+ "name": "APAC Manager",
775
+ "email": "apac@example.com",
776
+ "password": "...",
777
+ "allowedTenants": ["tenant-uuid-1", "tenant-uuid-2"] // empty = access to all tenants
778
+ }
779
+ ```
780
+
781
+ Users with `role: 'superadmin'` always have access to all tenants regardless of `allowedTenants`.
782
+
783
+ **Enforcement:** The auth middleware passes `allowedTenants` to every list query. A user whose `allowedTenants` does not include the tenant being queried receives an empty result set (not a 403), so pagination works normally. A user with an empty `allowedTenants` array has no restrictions. Routes for individual resources (`GET /consents/:visitorId`, `GET /consents/:visitorId/history`) return `403 Forbidden` instead of an empty result when the tenant is not in `allowedTenants`.
784
+
785
+ The **Users** dashboard page includes an **Edit** button on each user row (visible to users with `user:update` permission) that opens a modal for updating `allowedTenants` without changing the user's password or role.
422
786
 
423
787
  ### API Keys
424
788
 
425
- | Method | Path | Description |
426
- |------------|-----------------|------------------------------------------------------|
427
- | `GET` | `/apikeys` | List API keys |
428
- | `POST` | `/apikeys` | Create an API key (raw key returned once — save it!) |
429
- | `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 |
430
840
 
431
841
  ### Stats & Reporting
432
842
 
433
- | Method | Path | Description |
434
- |--------|---------------------------|------------------------------------------|
435
- | `GET` | `/stats/overview` | Total consents, visitors, GPC count |
436
- | `GET` | `/stats/timeline` | Daily consent counts (`?days=30`) |
437
- | `GET` | `/stats/categories` | Per-category grant/deny breakdown |
438
- | `GET` | `/stats/countries` | Consent count by country |
439
- | `GET` | `/stats/gpc` | GPC detection statistics |
440
- | `GET` | `/export/consents` | Export consent records (CSV or JSON) |
441
- | `GET` | `/export/consents/xlsx` | Export as XLSX |
442
- | `GET` | `/export/audit` | Export audit log (CSV or JSON) |
843
+ | Method | Path | Description |
844
+ |--------|-----------------------------------|------------------------------------------|
845
+ | `GET` | `/stats/overview` | Total consents, visitors, GPC count |
846
+ | `GET` | `/stats/timeline` | Daily consent counts (`?days=30`) |
847
+ | `GET` | `/stats/categories` | Per-category grant/deny breakdown |
848
+ | `GET` | `/stats/countries` | Consent count by country |
849
+ | `GET` | `/stats/gpc` | GPC detection statistics |
850
+ | `GET` | `/export/consents` | Export consent records (CSV or JSON) |
851
+ | `GET` | `/export/consents/xlsx` | Export as XLSX |
852
+ | `GET` | `/export/audit` | Export audit log (CSV or JSON) |
853
+ | `GET` | `/export/translations/:profileId` | Export all translatable fields as CSV |
443
854
 
444
855
  ### Audit Log
445
856
 
446
- | Method | Path | Description |
447
- |--------|-----------|---------------------------------------------------------|
448
- | `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 |
449
861
 
450
862
  ### Multi-Tenant
451
863
 
@@ -465,7 +877,10 @@ Only active when `multiTenant.enabled: true`.
465
877
  | `GET` | `/tcf/vendors` | List IAB TCF vendors |
466
878
  | `GET` | `/tcf/purposes` | List IAB TCF purposes |
467
879
 
468
- 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.
469
884
 
470
885
  ---
471
886
 
@@ -473,45 +888,302 @@ Only active when `tcf.enabled: true`.
473
888
 
474
889
  Served at `{basePath}/` when `dashboard: true`.
475
890
 
476
- | Section | Description |
477
- |-------------------|--------------------------------------------------------------------------|
478
- | Dashboard | Consent overview, timeline chart, country breakdown, GPC stats |
479
- | Profiles | Create / edit / copy / delete consent profiles via 3-step wizard |
480
- | Cookie Templates | Reusable cookie/purpose definitions |
481
- | UI Templates | Reusable banner + modal layout settings |
482
- | Consents | Browse, filter, export consent records; per-visitor history |
483
- | Visitors | Visitor list with geographic data |
484
- | Users | Admin user management |
485
- | Roles | RBAC roles and fine-grained permission assignment |
486
- | Sites | Multi-tenant site management |
487
- | TCF Vendors | IAB Global Vendor List |
488
- | Audit Log | Immutable log of all admin actions |
489
- | Settings | API keys, branding |
891
+ | Section | Description |
892
+ |---------------------|--------------------------------------------------------------------------|
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` |
895
+ | Profiles | Create / edit / copy / delete / activate / deactivate consent profiles |
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) |
898
+ | UI Templates | Reusable banner + modal layout settings |
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) |
902
+ | Roles | RBAC roles and fine-grained permission assignment |
903
+ | Sites | Multi-tenant site management **(superadmin only)** |
904
+ | 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 |
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) |
908
+
909
+ ### Dashboard RBAC
910
+
911
+ - **Sites** and **API** sections are visible only to users with the `super_admin` role
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)
490
915
 
491
916
  ### Profile Creation Wizard
492
917
 
493
- **Step 1 — Cookie Template:** Define cookie IDs and their properties:
918
+ **Step 1 — Profile metadata:**
919
+
920
+ | Field | Type | Description |
921
+ |---------------------|------------------------------------------------------|-----------------------------------------------------------------------------|
922
+ | `name` | `string` | Human-readable profile name |
923
+ | `defaultLocale` | `string` (BCP 47) | Locale served when visitor locale is unavailable; written as `default.json` |
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. |
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. |
928
+ | `darkMode` | `boolean` | Enable dark mode in the consent banner |
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. |
930
+ | `allowReceipt` | `boolean` | Allow visitors to download a PDF consent receipt |
931
+ | `allowedOrigins` | `string[]` | Allowlisted domains for CORS on this profile's consent endpoints |
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`:
937
+
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).
940
+
941
+ Parameter fields:
494
942
 
495
- | Field | Type | Description |
496
- |---------------------|-----------------------------------|---------------------------------------------------|
497
- | `id` | `string` | Unique identifier referenced in button arrays |
498
- | `type` | `'consent' \| 'legitimate_interest'` | Legal basis |
499
- | `mandatory` | `boolean` | Always granted; toggle disabled in modal |
500
- | `listenGpc` | `boolean` | Auto-denied when GPC signal is active |
501
- | `expiry` | `number` | Days until re-consent is required (0 = session) |
943
+ | Field | Type | Description |
944
+ |---------------------|-------------------------------------------------|---------------------------------------------------------------------------|
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. |
947
+ | `listenGpc` | `boolean` | Auto-denied when GPC signal is active |
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 |
502
951
 
503
- **Step 2 — UI Template:** Configure banner and modal layout:
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` |
969
+
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.
504
975
 
505
976
  - **Main Banner / GPC Banner:** `position`, `overlayOpacity`, `showClose`, `headingTag`, `buttons`
506
- - **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.
507
979
  - Button `type`: `primary | secondary | text | reject | submit | manage | close`
508
980
  - Button `cookies`: `*` (grant all) · `!` (deny all) · comma-separated IDs
509
981
 
510
- **Step 3 — Content:** Enter localised copy with live preview:
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
+
989
+ **Step 4 — Content:** Enter localised copy with live preview:
511
990
 
512
991
  - Main Banner: heading, body HTML
513
992
  - GPC Banner: heading, body HTML
514
- - 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
995
+
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.
997
+
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.
999
+
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.
1001
+
1002
+ | Button | Action |
1003
+ |--------|--------|
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. |
1007
+
1008
+ **Readability callouts:** Inline advisory warnings (yellow) appear beneath `heading` and body HTML fields when:
1009
+ - A heading exceeds 80 characters
1010
+ - Average sentence length in the body exceeds 25 words
1011
+ - Total word count in the body exceeds 150 words
1012
+
1013
+ These are informational only — the profile can still be saved.
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
+
1045
+ ### Profile Activation
1046
+
1047
+ Profiles are **inactive** after creation. To serve them via geo-routing:
1048
+
1049
+ 1. Click **Activate** in the profile list or use `POST /admin/profiles/:id/activate`
1050
+ 2. Consenti atomically repoints `${storage.path}/profiles/${tenantId}/${complianceGroup}/` (a symlink) at the profile's version directory
1051
+ 3. The static file route starts serving these files immediately (zero DB on the hot path)
1052
+
1053
+ Only one profile per `complianceGroup` can be active at a time. Activating a new profile automatically deactivates the existing one.
1054
+
1055
+ ### Profile Edit History
1056
+
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
1059
+ - Right panel: prettified JSON for the selected version; locale switcher dropdown
1060
+
1061
+ Via API:
1062
+ ```http
1063
+ GET /consenti/admin/profiles/:id/versions
1064
+ GET /consenti/admin/profiles/:id/versions/:entryId?locale=fr-FR
1065
+ ```
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
+
1078
+ ### Template Save Safety
1079
+
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.
1081
+
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.
1083
+
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).
1085
+
1086
+ ---
1087
+
1088
+ ## EventBus
1089
+
1090
+ `createConsenti()` returns an `eventBus` — a standard Node.js `EventEmitter` — that fires events at every data lifecycle step.
1091
+
1092
+ | Event | Payload | Description |
1093
+ |---|---|---|
1094
+ | `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 |
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 |
1104
+
1105
+ `cache:warm` and `cache:purge` mirror the `handleCache` config callback — use whichever suits your integration.
1106
+
1107
+ ```ts
1108
+ const { eventBus } = createConsenti({ /* ... */ })
1109
+
1110
+ eventBus.on('consent.created', (record) => {
1111
+ // send to analytics, trigger webhook, etc.
1112
+ console.log('New consent from visitor:', record.visitorId)
1113
+ })
1114
+
1115
+ eventBus.on('consent.erased', ({ visitorId }) => {
1116
+ myDmpClient.deleteVisitor(visitorId)
1117
+ })
1118
+ ```
1119
+
1120
+ ---
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.
515
1187
 
516
1188
  ---
517
1189
 
@@ -590,18 +1262,47 @@ await consenti.destroy()
590
1262
  | Stack traces | Suppressed in `NODE_ENV=production` responses |
591
1263
  | CORS | Explicit allowlist — never use `origins: '*'` in production |
592
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.
593
1281
 
594
1282
  ---
595
1283
 
596
1284
  ## Environment Variables
597
1285
 
598
- | Variable | Description |
599
- |---------------------------|-----------------------------------------------------------------------------|
600
- | `NODE_ENV` | Set to `'production'` to suppress stack traces in error responses |
601
- | `CONSENTI_ADMIN_PASSWORD` | Override `auth.adminPassword` from config |
602
- | `CONSENTI_ADMIN_JWT_SECRET` | Override `auth.jwtSecret` from config |
603
- | `CONSENTI_DB_PATH` | Override `storage.path` (SQLite) |
604
- | `CONSENTI_BASE_PATH` | Override `basePath` |
1286
+ All config fields can be set via environment variables. Code config takes precedence over env vars.
1287
+
1288
+ | Variable | Config field | Default |
1289
+ |-------------------------------------|------------------------------------|----------------------------------|
1290
+ | `NODE_ENV` | — | Suppress stack traces when `'production'` |
1291
+ | `CONSENTI_BASE_PATH` | `basePath` | `'/consenti'` |
1292
+ | `CONSENTI_ADMIN_EMAIL` | `auth.adminEmail` | `'user@consenti.dev'` |
1293
+ | `CONSENTI_ADMIN_PASSWORD` | `auth.adminPassword` | `'Consenti@123'` |
1294
+ | `CONSENTI_ADMIN_JWT_SECRET` | `auth.jwtSecret` | random per restart |
1295
+ | `CONSENTI_DB_DRIVER` | `storage.driver` | `'json'` |
1296
+ | `CONSENTI_DB_PATH` | `storage.path` | `'./consenti-data'` |
1297
+ | `CONSENTI_DB_URI` | `storage.uri` | — |
1298
+ | `CONSENTI_DB_DATABASE` | `storage.database` | — |
1299
+ | `CONSENTI_DB_HOST` | `storage.host` | — |
1300
+ | `CONSENTI_DB_PORT` | `storage.port` | — |
1301
+ | `CONSENTI_DB_USER` | `storage.user` | — |
1302
+ | `CONSENTI_DB_PASSWORD` | `storage.password` | — |
1303
+ | `CONSENTI_RATE_LIMIT_WINDOW_MS` | `rateLimit.windowMs` | `60000` |
1304
+ | `CONSENTI_RATE_LIMIT_MAX_REQUESTS` | `rateLimit.maxRequests` | `60` |
1305
+ | `CONSENTI_MAX_BODY_SIZE` | `maxBodySize` | `1048576` (1 MB) |
605
1306
 
606
1307
  ---
607
1308
 
@@ -610,6 +1311,15 @@ await consenti.destroy()
610
1311
  When running, Swagger UI is available at `{basePath}/api/docs`.
611
1312
  OpenAPI JSON at `{basePath}/api/openapi.json`.
612
1313
 
1314
+
1315
+ ---
1316
+
1317
+ ## Demo
1318
+ For detailed API docs, Guides and Demo, visit [consenti.dev](https://consenti.dev)
1319
+
1320
+ Visit author's profile at [santosh.top](https://santosh.top)
1321
+
1322
+
613
1323
  ---
614
1324
 
615
1325
  ## Contributing