@thg-altitude/didomi-consent
v0.1.3
Published
Shared Didomi CMP loader + OneTrust → Didomi consent migration for THG Astro storefronts.
Readme
@thg-altitude/didomi-consent
Shared Didomi CMP loader + OneTrust → Didomi consent migration for Astro storefronts. Extracted from a production storefront so every brand consumes one audited implementation instead of copy-pasting it.
For a returning visitor who already made a OneTrust choice, it replays that choice into Didomi so they are not re-prompted; everyone else sees the standard Didomi notice. The replay preserves the user's original OneTrust expiry (it never resets the 12-month clock), honours live GPC, and fails safe to opt-out.
How it works
DidomiCmp.astro emits four things into <head>, in this load-bearing order:
- Flash-guard CSS — hides the notice only while an eligible migration is in flight (a class set by the runtime, removed the instant the write resolves or fails). A non-migrating or failed-write visitor always sees the standard banner.
window.didomiConfig— set at page root before the loader.- The eligibility gate + migration runner — reads the legacy
OptanonConsentcookie, gates on GPC / staleness / reconsent / cookie-shape / real-interaction, then replays the choice via Didomi's standard API (setUserAgreeToAll,setUserDisagreeToAll, or a per-categorysetCurrentUserStatus). Once the SDK is ready it applies the optional regulation exclusion before writing anything. It also registers two expiry hooks that run on every load to enforce the original OneTrust expiry. - The Didomi loader (
spcloader) — targets the notice by id.
Each script is the storefront-audited runtime, shipped by stringifying a single
real function (src/runtime/*.js). That means the exact code running in the
browser is the exact code the tests import and drive — one source, no
drift. is:inline keeps the scripts verbatim (no Astro processing/hashing), matching
the CSP treatment storefronts already grant these Didomi scripts.
Config is passed as props, never read from Astro.locals, so the package is
decoupled from any repo's config shape. Each consumer keeps a tiny wrapper.
Install
Published on the public npm registry — no .npmrc, scope routing or auth token
needed in the consuming repo:
npm install @thg-altitude/didomi-consentOr as a git dependency (no registry step):
npm install github:THG-Commerce/didomi-consent#v0.1.3The package ships raw .astro/.ts/.js source (no build step). Astro's Vite
pipeline compiles the .astro component and transpiles the helpers in the consumer's
build — the same way @thg-altitude/* component packages work.
Fastest path: the auto-setup skill
This repo carries a Claude coding-agent skill, didomi-setup, that does the wiring
below for you — detecting this repo's head layout, footer, and tenant-config shape
(they differ per storefront), then self-verifying and handing off the compliance
steps it must not do itself.
The skill is deliberately NOT part of the published package. It is written against internal deployment conventions and is of no use outside them, so it is excluded from
filesinpackage.json:npm installdoes not deliver it and there is nonpxcommand. Install it from a clone of this repo:
git clone https://github.com/THG-Commerce/didomi-consent.git
node didomi-consent/bin/cli.mjs install-skill --dir ../my-storefront
# or, with no --dir, into the current repoThen ask your agent to "set up Didomi on this storefront" (or run /didomi-setup).
Re-running the command re-syncs the skill to that checkout. The skill source lives at
skills/didomi-setup/SKILL.md and is the single
source of truth for the procedure (it includes the self-verification and browser-test
recipes). The manual steps below are what it automates.
If you change the skill, keep it out of files. The npm pack check under
Testing fails the build if skills/ or bin/ reappears in the tarball.
Wiring a storefront (3 steps)
1. Wrapper. Add src/components/Consent/DidomiCmp.astro from
examples/consumer-wrapper.astro. It reads the
repo's tracking.didomi and forwards it as props. This keeps the existing
Layout.astro mount unchanged:
import DidomiCmp from '@components/Consent/DidomiCmp.astro';
{tenantConfig?.features?.hasDidomi && <DidomiCmp />}Mount it in <head> before any tracker / GTM / analytics so consent gates them.
2. Footer "Cookie settings" link. Wire the click handler to the shared helper so no repo re-implements it:
import { openCookieSettings } from '@thg-altitude/didomi-consent/cookie-settings';
document.querySelectorAll('.cookie-settings').forEach((el) =>
el.addEventListener('click', (e) => {
e.preventDefault();
openCookieSettings(); // Didomi.preferences.show(), OneTrust fallback
}),
);Show the link when features.hasDidomi (or the legacy OneTrust flag) is on.
3. Tenant config + feature flag. Add to the tenant config and gate on
features.hasDidomi:
{
"features": { "hasDidomi": true },
"tracking": {
"didomi": {
"apiKey": "<your Didomi public API key>", // from the Didomi console
"noticeId": "<brand notice id>",
"reconsentMonths": 12,
"migrateFromOneTrust": true,
"debug": false
}
}
}hasDidomi should be mutually exclusive with any hasOneTrust /
hasNativeCookieBanner flag. Type the config from the package if useful:
import type { DidomiConsentConfig } from '@thg-altitude/didomi-consent/types'.
Config reference
| Prop | Required | Default | Meaning |
|------|----------|---------|---------|
| apiKey | ✓ | — | Org-wide public Didomi API key (safe to commit; it's in the loader URL). |
| noticeId | ✓ | — | Brand/locale notice id (target_type=notice). |
| reconsentMonths | | 12 | Reconsent window; the migrated consent keeps the user's original expiry. |
| migrateFromOneTrust | | true | Replay legacy OneTrust choices. When false, only the expiry-enforcement of already-migrated users runs — safe to switch off once OneTrust is decommissioned. |
| reconsentRegulations | | — | Opt-in. Didomi regulation ids whose visitors must not have a legacy choice replayed — see Excluding a regulation. |
| debug | | false | Emit [didomi-migration] console diagnostics. Set false for go-live. |
| css | | — | Tenant CSS appended after the base notice styling (the flash-guard rules always ship). |
Excluding a regulation from migration
Some jurisdictions are not worth carrying a replayed consent into. reconsentRegulations
withholds the migration for those visitors only — they see the standard Didomi notice and
give fresh consent, while everyone else still migrates:
{ "reconsentRegulations": ["cpra", "ccpa"] } // US visitors under a state privacy lawThe signal is Didomi.getCurrentUserStatus().regulation — Didomi's own IP-derived answer,
read once the SDK is ready. Matching is case-insensitive, so the lower-case ids from the
Didomi console work as-is.
The list must mirror the regulations enabled on that brand's notice. Didomi supports
~20 US state laws (cpra, cpa, ctdpa, vcdpa, ucpa, tdpsa, ocpa, njdpa, …),
but a visitor only reports one if it is configured on the notice they were served — an
unconfigured geography reports NONE, not its state law. Check the notice in the console
rather than copying a list. Conversely, enabling a new regulation in the console does not
update this list: those visitors will migrate until the tenant config is changed too.
Two properties worth knowing:
- It can only ever subtract. Only an explicit match withholds the migration. An
unlisted regulation, or one the SDK cannot report, migrates exactly as it did before the
option existed — so switching this on can never mass-re-prompt a population that migrates
today. The trade is the other direction: if
getCurrentUserStatus()stops reporting a regulation, an excluded visitor is migrated rather than re-prompted. - The OneTrust cookies are cleaned up, not orphaned. A withheld visitor keeps them until
they answer the notice — until then they are the only consent record on the device — and
they are deleted the moment a real choice is made. If Didomi already holds a complete
status (no notice is coming, so no choice event will ever fire) they are deleted straight
away instead. Waiting requires positive confirmation that a notice is coming: if the SDK
cannot answer — an older build without
shouldUserStatusBeCollected, or a call that throws — the cookies are deleted immediately rather than left waiting for an event that may never arrive.
Note the regime differs from GDPR: under CPRA/CCPA a US notice may be an opt-out link rather
than a blocking banner, and a NONE visitor may see no notice at all. Confirm what the
notice actually does in the console before relying on "they'll be re-prompted".
Compliance invariants (locked by tests)
- Only a real choice is replayed — requires
OptanonAlertBoxClosedorinteractionCount > 0; the pre-answer default cookie is ignored. - Original expiry preserved — anchored on
OptanonAlertBoxClosed(OneTrust rollsOptanonConsent.datestampforward on every load), never re-extended. OneTrust cookies are deleted only after the expiry-anchor provenance is persisted. - Live GPC wins —
navigator.globalPrivacyControl === trueblocks replay even if the stored cookie predates the signal. - Fail-safe to opt-out — a truncated cookie takes the partial path (never agree-to-all); any purpose/vendor that can't be mapped deterministically is denied.
- Excluded regulations are never replayed, and never orphan a cookie — a visitor
withheld by
reconsentRegulationsgets no write of any kind (noreset()either, so an existing Didomi choice is untouched), and their OneTrust cookies are deleted once fresh consent exists — or immediately, if no notice is coming or the SDK cannot confirm one is.
Testing
Pure Node, no browser, no deps beyond the toolchain:
npm run test # node --test — drives the real shipped runtime against cookie fixtures
npm run typecheck # tsc --noEmit over the type surface
npm run check # bothThe suite (test/migration.test.mjs) installs a browser-global mock, imports the
same migrationRuntime that DidomiCmp.astro ships, seeds OptanonConsent /
OptanonAlertBoxClosed fixtures, runs it, and asserts the exact Didomi API calls and
cookie side-effects for accept / reject / partial / GPC / stale / no-interaction /
expiry-reset / persist-failure / malformed / truncated scenarios, plus the
reconsentRegulations cases (excluded / unlisted / unreported / opted-out, and both
cookie-cleanup paths).
test/packaging.test.mjs additionally asserts against the real npm pack output that
skills/ and bin/ never reach the tarball — they are written against internal
deployment conventions and are not published. Adding either back to files fails
npm run check.
Releasing
- Bump
versioninpackage.json. - Create a GitHub Release tagged
vX.Y.Z(matching the version). publish.ymlrunsnpm run checkthennpm publishto the public npm registry using theNPM_TOKENrepo secret — an npm automation token for an account with publish rights on the@thg-altitudescope.
To publish by hand instead, from a clean checkout with NPM_TOKEN exported:
npm run check && npm publishpublishConfig in package.json pins the registry and access: public, so no
--registry / --access flag is needed.
Renaming the package
The name/scope appear in exactly these places — change them together, and keep the scope one the publishing npm account actually owns:
package.json→name,repository.url,publishConfig- consuming repos → every
import/ dependency entry skills/didomi-setup/SKILL.md→ the install step and its examples- this README's examples
Nothing in src/runtime/ or test/ references the package name.
