[0.4.0] - 2026-08-05
Summary
A compliance-and-governance-focused batch: TCF v2.3 with cmpId/cmpVersion registration
governance (fails closed against IAB's CMP List), new GPP (US National) support with the same
self-attestation governance shape, per-profile/per-locale age gate with a stateless signed
parental-consent hook, a compliance.complianceMap override that's now actually wired up on both
apps, real getUserId()/setUserId() identity with reconsent-on-change, Intl.Locale-based
auto text direction, and full ThemeConfig CSS-variable parity (a breaking rename). Alongside
that: a re-audit of COMPLIANCE_VALIDATION_RULES against the actual regulations (two validation
gaps promoted from warning to hard error — PIPL legitimate-interest, CPRA/GPC on sale-or-sharing
parameters), salted/masked hashIp(), a configurable cookie name, a synchronous pre-mount GPC
snippet, a Colorado sensitive-data opt-in carve-out, and the removal of auditLogPurgeAfterDays —
audit logs are now unconditionally append-only. A new internal apps/test-runner QA tool (driving
the real built widget across every jurisdiction/state) caught a real correctness bug: every
built-in profile's Accept-All button silently denied optional cookies instead of granting them.
Also: a production boot guard for unset secrets, a wave of dashboard error-handling and
"Load Defaults" locale-correctness fixes, and a broad marketing/docs claim-precision sweep
("GDPR-compliant" → "GDPR-style", corrected regulation-to-compliance-group mappings, softened
unverified competitor claims).
Added
@consenti/ui
- Age gate now reads
profile.ageGate/profile.ageGateModaldirectly off the resolved profile — per-profile, per-locale, translatable — instead of a single globalConsentiConfig.compliance.ageGatesetting; falls back to the old hardcoded English text when a profile enablesageGatewithout authoring modal text. StandaloneregisterProfile()authors can setageGate/ageGateModaldirectly on theirEmbeddedProfile/EmbeddedTranslations. resolveAgeGate()now requests a signed parental-consent token from the server instead of minting one client-side; new standaloneresolveParentalConsent()export for the parent's own page/session, dispatchingconsenti:parentalConsentResolved.compliance.complianceMap—'default'(embedded map), a URL string, or an inlineComplianceMapDataobject, replacing the previously-unwiredautoComplianceMap/complianceMapUrl. Only meaningful in standalone mode; ignored with a warning when the server owns resolution. Invalid data falls back to'default'— never throws.getUserId()/setUserId(userId, reConsent = true)— get/set the logged-in application user ID after init; reconsents by default when the stored consent record's user differs from the new value (deletes the record, re-shows the banner), or passreConsent: falseto update identity silently. No-ops when there's no prior consent record to compare against. Wired into thereact/vue/angularwrapper exports.consenti:listener:identifyevent — inbound equivalent ofsetUserId(), the first event in a newconsenti:listener:*namespace reserved for inbound (host page → widget) events.core.dir's'auto'mode now derives text direction viaIntl.Locale(locale).getTextInfo()where supported, falling back to the existing RTL-language-prefix table otherwise.core.cookieName— configurable consent cookie/localStorage key (default'consenti_data');'euconsent-v2'documented as an opt-in choice for the spec-correct binary TCF encoder.buildSyncGpcSnippet()— a tiny dependency-free<head>script that freezes Google Consent Mode v2 to denied the instantnavigator.globalPrivacyControlis detected, before any tag-loading script (including Consenti's own bundle) has loaded.requiresSensitiveOptInregion carve-out applied in the widget'sopt-outdefault-consent branch (see@consenti/utils).- GPP (US National) support —
window.__gppstub (gpp-stub.ts) using a simplified base64url-JSON encoder; same fail-closed self-attestation governance shape as TCF (GET /gpp/status), but without a CMP-List equivalent to validate against. ComplianceWidgetConfig,AgeGateWidgetConfig,TcfWidgetConfig,ParentalConsentRequiredDetailcontinue to be exported from the package root;compliance.gppwidget config added (cmpId,cmpVersion, MSPA covered-transaction/opt-out-option/ service-provider-mode fields).
@consenti/api
- Per-profile age gate —
ProfileConfig/PublicProfileResponse/ResolvedProfilegainageGate?: AgeGateConfigandageGateModal?: AgeGateModalContent, mirroringdpdpa. Dashboard: Step 1 toggle + minimum-age/parental-consent fields, per-locale modal text authored in the Main Banner content step, mandatory-content validation. - Stateless parental-consent request/resolve hook —
POST /consent/:visitorId/parental-consent-requestandPOST /consent/parental-consent-resolve, signed withcompliance.dataSigningHashwhen configured (unsigned otherwise, matching existing opt-in tamper-evidence behavior). Newcompliance.parentalConsentTokenTtlDays(default 7). No new DB entity, noStorageAdapterchanges — replay isn't prevented, an accepted tradeoff of staying fully stateless. ConsentCookieData.krecords the compliance group at consent time, letting a returning, already-decided visitor'sresolveProfile()skip the/resolve-profilegeo round-trip entirely and fetch/profiles/{tenant}/{group}/{locale}directly — keyed oncomplianceGroup, never a since-deactivated/replaced profile id.compliance.complianceMap(server side of the widget feature above) — a URL value is fetched and refreshed in the background per the response'sCache-Control/Expiresheader (24h fallback).compliance.dataSigningHash— per-deployment salt mixed intohashIp()before SHA-256;hashIp()now also masks the IP (last IPv4 octet / last 80 bits of IPv6 zeroed) before hashing.requiresSensitiveOptInthreaded throughGeoResolverService→/resolve-profileresponse →ResolvedProfile.requiresSensitiveOptIn.- TCF
cmpId/cmpVersionregistration governance —cmp-list-cache.tscaches IAB's public CMP List;TenantSettings.tcfConfirmationstores a hash of the last-confirmed{cmpId, cmpVersion, publisherCC}.GET /tcf/registration-statusandPOST /tcf/confirm-registration(admin,settings:update) validate against the cached CMP List — hard error on deregistration, soft-block with "Refresh Status" when not found yet. A hash mismatch (including never confirmed) fails closed:ConsentServiceskipstcfStringgeneration and the widget skips installingwindow.__tcfapi, checked via a new publicGET /tcf/statusendpoint that runs in parallel with profile resolution. TcfRegistrationPanelon the dashboard Vendors page — non-dismissible while unconfirmed, required checkbox, hard error on deregistration, "Refresh Status" button. Translated across all 6 locales.- GPP (Global Privacy Platform) support, US National section only —
compliance.gppserver config; sale/sharing/targeted-advertising opt-out and notice fields derived from cookies taggedcpraCategory: 'sale'/'sharing'. Real, spec-correct GPP string encoding via the optional@iabgpp/cmpapipeer dependency, falling back to a simplified base64url-JSON encoder when it isn't installed — same fallback shape as TCF'sreal-tc-string.ts. GppRegistrationPanelon the dashboard Vendors page, same shape as the TCF panel minus the CMP-List lookup (IAB doesn't publish a CMP-List equivalent for GPP). Translated across all 6 locales.- Setup-wizard hardening —
POST /setup/completenow rejects (409) unlessPOST /setup/seed-profileshas been called at least once for the tenant. - Production boot guard —
createConsenti()now throws whenNODE_ENV=productionand any ofauth.masterSecret,compliance.dataSigningHash, or a non-defaultauth.adminPasswordwere left unset/default, instead of silently auto-generating/defaulting them. Unaffected outside production. GeoResolverServicenow warns once at construction when the effectivegeoDataProvideris the timezone/language heuristic, since it cannot resolve US state-level regions.ConsentiServerConfigconfig surface reorganized —tcf,dataSigningHash, anddataRetentionmoved from top-level intocompliance(alongsidetype/geoDataProvider/complianceMap/gpc), all being compliance-program settings.
@consenti/types
AgeGateConfig,AgeGateModalContent,GppConfig,TenantSettings.{profilesSeeded, tcfConfirmation,gppConfirmation},ConsentDbRecord.gppString.requiresSensitiveOptInonoverriddenRegionsentries (embedded map and customComplianceMapData).CountryComplianceEntry.default/description, andoverriddenRegions[].description, are now optional —defaultfalls back tocomplianceGroupwhen omitted.ThemeConfigrenamed to match its--consenti-*CSS variables literally (see Breaking changes) and gains 18 previously-unreachable fields, each mapping 1:1 to its CSS variable.
@consenti/utils
requiresSensitiveOptInset for Colorado (US.overriddenRegions.CO) — cookies taggedcpraCategory: 'sensitive'default to denied for that region even though the rest of theopt-outgroup defaults to granted. Only takes effect with a geoip/maxmindgeoDataProvider.encodeGppString— simplified GPP fallback encoder, mirroring the existingencodeTcStringpattern.
apps/test-runner / packages/browser-engine
apps/test-runner: new internal, never-published Playwright-based QA tool that boots the realapps/uibundle in a fixture page and checks it visually (screenshot + pixel diff against a checked-in baseline) and functionally (button/toggle clicks, consent-script gating,reConsent()/forgetMe()/deleteConsent()). Wired into CI on every PR. Every run writes to its own datedresults/<ISO_DATE>[-V2...]/directory;--results-dir <dir>pins a fixed location.packages/browser-engine: shared, unpublished Playwright launch/capture/screenshot core used byapps/test-runner(and, going forward,apps/scanner). Gained areducedMotionSessionOptionsfield.
Docs / Infra
- Homepage: highlighted section for Google Consent Mode v2 (already fully built, previously unsurfaced).
- Added the "open-source implementation of the technical spec, not a registered CMP" disclaimer to
the root/api/ui READMEs and the TCF guide; documented
core.cookieSigningKey's spoofability in standalone mode; added a PIPL/FZ-152 data-residency note to the self-hosting guide. - Extended docs coverage for the Colorado
requiresSensitiveOptIncarve-out (Jurisdiction Coverage Map, Compliance Groups page, CPRA Implementation Guide) and the sync-GPC snippet (CPRA guide, consent-flow guide). compliance-docs/: agent-research kit for the quarterly compliance-review cadence (README.md,AGENT-BRIEF.md,jurisdiction-registry.md, a quarterly-report template).
Changed
@consenti/utils:opt-in-china'slegalBasis: 'legitimate_interest'usage promoted from a warning to a hardcategoryError— PIPL Art. 13 is a closed list of lawful bases that doesn't include legitimate interest, matching howopt-in-dpdpaalready treats DPDPA.@consenti/utils:opt-out-strict'sgpc-strict-requiredpromoted from acookieWarningto acookieError, scoped tocpraCategory === 'sale' || 'sharing'(excludessensitive) — 11 CCR §7025 makes honoring GPC as a Do-Not-Sell/Share signal mandatory for those parameters.@consenti/utils: added alegitimate-interest-balancing-test-recommendedwarning toopt-in,opt-in-brazil, andgeneral-privacy-consent— fires whenlegalBasis: 'legitimate_interest'has nolegitimateInterestDescription(GDPR Art. 6(1)(f), LGPD Art. 10).@consenti/ui: defaultopt-inprofile'sreject-optional/confirm-settingsbuttons changed fromstyle: 'secondary'to'primary', matchingaccept-all's visual weight (equal prominence, per CNIL guidance).@consenti/apidashboard: the "Load Defaults" starter UI template'sreject-optionalbutton changed fromtype: 'accent'("destructive red") to'primary'.- TCF version references updated from v2.2 to v2.3 throughout code, docs, and READMEs (mandatory since 2026-03-01); the simplified stub encoder's policy-version constant bumped 4→5.
- Auto-detection docs corrected: the
/resolve-profilesessionStorage cache is actually a 60-minute TTL, not "the tab's lifetime." - Broad doc/marketing claim-precision sweep: "GDPR-compliant" → "GDPR-style" across 10+ files; "WCAG 2.2 AAA" → "WCAG 2.x AA / accessibility-focused"; TCF/GPP "Partial" qualifiers added; dropped the bare "76 regulations" claim; "190+ jurisdictions routed" reworded to "190+ countries/territories mapped to 8 consent UX groups."
- GDPR/PIPEDA/POPIA/APPI regulation pages: fixed an auto-resolve-vs-recommended-override
mismatch (these regulations auto-resolve to
general-privacy-consent, withopt-inas each one's recommended stronger override, not the default) and a self-contradiction between a page's top Callout and its own code sample further down. - Homepage comparison table: corrected two unverified claims (Cookiebot does auto-honour GPC;
OneTrust does publish WCAG 2.2 VPAT work) and softened five other competitor WCAG cells to
'unclear'/'partial'rather than leave unverifiedfalseclaims.
Removed
@consenti/api:compliance.dataRetention.auditLogPurgeAfterDaysand thepurgeExpiredAuditLogsStorageAdaptermethod, across all seven storage adapters —audit_logsis now unconditionally append-only, never deleted by Consenti under any configuration. Operators needing shorter retention must do so manually against their own database.compliance.dataRetention.purgeAfterDays(consent records) is unaffected.ConsentiServerConfig.ageGateandConsentiConfig.compliance.ageGate(widget) — age gate is per-profile now.ComplianceConfig.gdpr/ComplianceConfig.ccpadeprecated booleans — usecompliance.type.ComplianceConfig.autoComplianceMap/complianceMapUrl(api) andComplianceWidgetConfig.autoComplianceMap/complianceMapUrl(ui) — usecomplianceMap.- Six orphaned, unlinked flat docs files (
apps/docs/{gdpr,ccpa,cpra,tcf,coppa,dpdpa}.md) — confirmed zero references anywhere, carrying the same stale claims already fixed on their Next.js-page equivalents.
Fixed
- Activate/deactivate conflict bug — saving a profile via "Deactivate {name} and activate this one" left both profiles inactive instead of activating the one being saved.
- Setup wizard never linked seeded profiles to templates —
seedDefaultProfile()built bareProfilerows bypassing theconsentTemplateId/uiTemplateIdrequirement every dashboard-created profile is held to; now seeds a matchingConsentTemplate+UITemplateper compliance group. packages/types/src/api.ts'sgpcconfig flag was accidentally removed alongside an unrelated, half-finishedgdpr/ccpadeprecation cleanup — restoredgpc, fixed all broken references.- Fixed a latent bug:
--consenti-font-family-monowas referenced with a hardcoded fallback but never actually defined in:root. @consenti/api:errorResponse()no longer strips thedetailsfield in production — it only ever carried deliberate, non-sensitive, user-facing structure.- Dashboard:
ProfileEditorsave failures now surface the server's actual message and jump to the offending locale tab/step, instead of a generic "Failed to save profile." - Dashboard: fixed the root cause of spurious "Profile content is missing required fields" saves —
resolveLocaleContent()always built agpcBannerpayload even when the GPC step was disabled. - Dashboard: GPC banner and age-gate modal "Load Defaults" now load locale-correct, content-specific default text instead of reusing the main banner's copy or the admin's own dashboard language.
- Dashboard: "Cannot delete: template is used by profiles" now actually surfaces for UI Templates and Consent Templates (client was reading the wrong response field).
- Dashboard: replaced generic/silent error handling with the server's actual message across API Config, Consent/UI Template editors, Setup Wizard, Users, Roles, Sites/Tenants, and the Profile list's copy/activate/deactivate/delete actions.
apps/test-runner: checked-in baselines captured mid-animation now render deterministically — every session launches withreducedMotion: 'reduce', which the widget's CSS already respects.@consenti/utils: every built-in profile's Accept-All/Reject-Optional/Deny-All buttons usedaction: 'submit'instead ofaction: 'custom'— sincesubmitignores a button'scookiesfield entirely, clicking "Accept All" in the default GDPR banner did not actually grant any optional cookie. Found viaapps/test-runner's functional checks.@consenti/utils: theopt-out-strict(CPRA) default profile'sfunctionality_storage/personalization_storagecookies had nocpraCategoryset despite the category's own copy disclosing sharing — addedcpraCategory: 'sharing'to both.@consenti/utils:general-privacy-consentandopt-in-brazilusedlegalBasis: 'legitimate_interest'without ever filling inlegitimateInterestDescription— added the balancing-test justification text for both.@consenti/api:hashIp()previously SHA-256'd the raw IP with no masking or salt — now masks and salts withcompliance.dataSigningHash.- Caught and fixed a bug in
node-sqlite3-wasm.adapter.ts'screateConsent()where thegpp_stringcolumn was added to the INSERT list but the bound parameter was missed. SECURITY.md/THREATMODEL.md: corrected the standalone-HMAC "planned for a future phase" claim (server-side verification already exists viadataSigningHash) and updated the audit-log sections/mitigation map for the purge removal above; fixed two stale file-path references.- Root
README.md's "Repository Structure" tree updated to includeapps/test-runner,packages/utils, andpackages/browser-engine.
Breaking changes
-
ConsentiServerConfig.tcf→compliance.tcf;.dataSigningHash→compliance.dataSigningHash;.dataRetention→compliance.dataRetention. -
ThemeConfigfields renamed to match their--consenti-*CSS variable literally:| Old | New | |---|---| |
bgColor|colorBg| |textColor|colorText| |primaryColor|colorPrimary| |primaryTextColor|colorPrimaryText| |secondaryColor|colorSecondary| |secondaryTextColor|colorSecondaryText| |borderColor|colorBorder| |buttonBorderRadius|borderRadiusBtn| |accentColor|colorAccent| |accentTextColor|colorAccentText| -
compliance.dataRetention.auditLogPurgeAfterDaysandStorageAdapter.purgeExpiredAuditLogsremoved from@consenti/types— a type-level breaking change for anyone referencing either directly; no known operator-visible behavior changes (never exposed in any dashboard UI, docs page, or README). -
opt-in-chinalegitimate-interest usage andopt-out-strictGPC-on-sale/sharing are now blocking compliance errors instead of warnings — no shipped default profile trips either (all 8 re-verified against their own group's rules), but a tenant's own customized profile in either situation will now block save. -
createConsenti()now throws instead of booting whenNODE_ENV=productionand required secrets are unset/default — a deploy that previously booted with a warning will now fail fast.
Migration
- Move
tcf/dataSigningHash/dataRetentionfrom the top level of yourcreateConsenti()config into a nestedcompliance: { ... }object. - If you were setting
compliance.ageGateon eithercreateConsenti()ornew ConsentiSetup(): move age-gate configuration to the profile itself in the dashboard (Profile Editor Step 1), or setageGate/ageGateModalon your standaloneEmbeddedProfile/EmbeddedTranslations. - Replace
compliance.autoComplianceMap/complianceMapUrlwithcompliance.complianceMapon both apps —'default'stays'default', acomplianceMapUrlstring or inlineautoComplianceMapobject becomes thecomplianceMapvalue directly. - Rename any
core.themefields onnew ConsentiSetup()/setTheme()per the table above. - If you were relying on
gdpr/ccpaboolean flags anywhere inComplianceConfig: usecompliance.typeinstead. - Operators deploying with
NODE_ENV=productionmust ensureauth.masterSecret,compliance.dataSigningHash, and a non-defaultauth.adminPasswordare explicitly set before upgrading, or the process will now refuse to start. - Operators wanting TCF/GPP active must confirm registration in the dashboard once (or after any
cmpId/cmpVersionchange) — both fail closed until confirmed. - No real installations predate any of this batch's schema changes — every new/changed column
ships directly in the fresh-install schema path, never a retroactive
ALTER TABLE/backfill. A tenant with an existing customopt-in-chinaprofile using Legitimate Interest, or anopt-out-strictprofile with a Sale/Sharing-tagged parameter not listening for GPC, will see a blocking compliance error next time that profile is edited — fix by switching the category's legal basis (China) or enabling "Listen for GPC signal" on the parameter (CPRA). An already-seededopt-out-strictprofile predating the CPRA-category fix won't pick it up automatically (seeding is idempotent) — add the category in the dashboard or re-seed.