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