Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/cmp-necessary-no-consent-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@walkeros/web-source-cmp-cookiefirst': patch
'@walkeros/web-source-cmp-cookiepro': patch
'@walkeros/web-source-cmp-usercentrics': patch
---

Strictly necessary cookies no longer grant `functional`. CookieFirst's
`necessary` category and CookiePro's `C0001` are now reported as `necessary`, so
`functional` follows the visitor's choice for functional cookies. Steps that
require `functional`, such as the session source with `consent: 'functional'`,
now wait for that choice. When several categories map to the same group, the
group is now granted only if all of them are, as in the Usercentrics source.
CookiePro maps `C0005` (Social Media) to its own `social` group instead of
`marketing`, so a step for social media cookies requires `social`. CookieFirst
and Usercentrics match `categoryMap` keys case-insensitively, like the other CMP
sources.
15 changes: 15 additions & 0 deletions .changeset/consent-mode-analytics-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@walkeros/web-core': patch
'@walkeros/web-destination-gtag': patch
'@walkeros/web-destination-d8a': patch
---

The gtag and d8a destinations now set `analytics_storage` from `analytics`
consent instead of `functional` by default, matching what the Cookiebot,
CookieFirst and CookiePro sources report. With the Usercentrics source, map your
measurement category to `analytics` in its `categoryMap`. If your consent
commands report measurement consent as `functional`, map it yourself, like
`como: { functional: 'analytics_storage', marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'] }`.
When several consent keys map to one parameter, the parameter is granted only if
all of them are granted in the current consent state; before, the key that came
last won.
9 changes: 9 additions & 0 deletions .changeset/cookiebot-cmp-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@walkeros/web-source-cmp-cookiebot': patch
---

New Cookiebot consent source. It reads the visitor's choice from
`window.Cookiebot` and Cookiebot's accept and decline events, and maps
`preferences`, `statistics` and `marketing` to `functional`, `analytics` and
`marketing`. Strictly necessary cookies are reported as `necessary` and grant no
consent group. Implied consent is skipped unless `explicitOnly` is `false`.
27 changes: 26 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions packages/cli/examples/flow-complete.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@
"examples": {
"explicitDecision": {
"title": "Explicit decision",
"description": "The user accepts every category in the banner. categoryMap turns essential and functional into functional, so the collector receives exactly the consent keys the contract names.",
"description": "The user accepts every category in the banner. categoryMap folds essential into functional so the collector receives exactly the consent keys the contract names; because any deny denies, functional still follows the functional category, and essential never grants it alone.",
"public": true,
"trigger": {
"type": "consent",
Expand Down Expand Up @@ -931,7 +931,14 @@
"ga4": {
"measurementId": "$env.GA4_MEASUREMENT_ID:G-MAINSITE"
},
"como": true
"como": {
"marketing": [
"ad_storage",
"ad_user_data",
"ad_personalization"
],
"functional": "analytics_storage"
}
},
"mapping": {
"product": {
Expand Down Expand Up @@ -1419,6 +1426,14 @@
"settings": {
"gtm": {
"containerId": "$env.GTM_ID:GTM-SHOP01"
},
"como": {
"marketing": [
"ad_storage",
"ad_user_data",
"ad_personalization"
],
"functional": "analytics_storage"
}
},
"mapping": {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/examples/flow-complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ walkeros push packages/cli/examples/flow-complete.json -f web -e '{"name":"order
- **Features:**
- `cmp-category-map` at
`/flows/web/sources/usercentrics/config/settings/categoryMap`: categoryMap
maps CMP categories onto exactly the consent keys the contract names.
maps CMP categories onto exactly the consent keys the contract names; folded
in, essential never grants functional alone.
- `cmp-explicit-only` at
`/flows/web/sources/usercentrics/config/settings/explicitOnly`: explicitOnly
publishes consent only after a real decision, so consent.functional present
Expand Down Expand Up @@ -389,8 +390,9 @@ walkeros push packages/cli/examples/flow-complete.json -f web -e '{"name":"order
- `pubsub-before` at `/flows/server/destinations/pubsub/before`: A conditional
next on Pub/Sub runs pseudonymize only when an email exists; other events
pass untouched.
- `ga4-como` at `/flows/web/destinations/ga4/config/settings/como`: como turns
walker consent into Google Consent Mode calls.
- `ga4-como` at `/flows/web/destinations/ga4/config/settings/como`: como maps
this flow's consent keys to Google Consent Mode: functional to
analytics_storage, marketing to the ad parameters.
- **Consent keys:** there are no collector consent defaults, only granted keys
travel. `consent.functional` is set only by Usercentrics with `explicitOnly`,
so its presence means the user decided.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe('web source simulate', () => {
[
'elb',
'walker consent',
{ functional: true, analytics: true, marketing: true },
{ necessary: true, functional: true, analytics: true, marketing: true },
],
]);
});
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/examples/flow-complete.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ const teaching = {
chapter: 'consent-privacy',
level: 'intermediate',
useCases: ['consent'],
note: 'categoryMap maps CMP categories onto exactly the consent keys the contract names.',
note: 'categoryMap maps CMP categories onto exactly the consent keys the contract names; folded in, essential never grants functional alone.',
docs: [],
},
'cmp-explicit-only': {
Expand Down Expand Up @@ -895,7 +895,7 @@ const teaching = {
chapter: 'consent-privacy',
level: 'intermediate',
useCases: ['consent'],
note: 'como turns walker consent into Google Consent Mode calls.',
note: "como maps this flow's consent keys to Google Consent Mode: functional to analytics_storage, marketing to the ad parameters.",
docs: [],
example: { step: 'ga4', name: 'consentUpdate' },
},
Expand Down
120 changes: 120 additions & 0 deletions packages/web/core/src/__tests__/consentMode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
DEFAULT_CONSENT_MODE_MAPPING,
consentModeParameters,
resolveConsentMode,
} from '../consentMode';

describe('consentMode', () => {
test('the default maps measurement to analytics and ads to marketing', () => {
expect(DEFAULT_CONSENT_MODE_MAPPING).toEqual({
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
analytics: ['analytics_storage'],
});
});

test('lists every parameter of a mapping once', () => {
expect(
consentModeParameters({
marketing: ['ad_storage', 'ad_user_data'],
analytics: 'analytics_storage',
stats: ['analytics_storage'],
}),
).toEqual(['ad_storage', 'ad_user_data', 'analytics_storage']);
});

test('resolves every mapped parameter from the consent state', () => {
expect(
resolveConsentMode(DEFAULT_CONSENT_MODE_MAPPING, {
analytics: true,
marketing: false,
}),
).toEqual({
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'granted',
});
});

test('treats a group missing from the state as denied', () => {
expect(
resolveConsentMode(DEFAULT_CONSENT_MODE_MAPPING, { marketing: true }),
).toMatchObject({ ad_storage: 'granted', analytics_storage: 'denied' });
});

test('ignores a functional grant under the default mapping', () => {
expect(
resolveConsentMode(DEFAULT_CONSENT_MODE_MAPPING, {
functional: true,
analytics: false,
}),
).toMatchObject({ analytics_storage: 'denied' });
});

test.each([
[{ analytics: true, functional: true }, 'granted'],
[{ analytics: true, functional: false }, 'denied'],
[{ analytics: false, functional: true }, 'denied'],
[{ functional: true, analytics: false }, 'denied'],
[{ analytics: true }, 'denied'],
])(
'grants a parameter only when every group mapped to it is granted: %j',
(state, expected) => {
const mapping = {
analytics: 'analytics_storage',
functional: 'analytics_storage',
};
expect(resolveConsentMode(mapping, state)).toEqual({
analytics_storage: expected,
});
},
);

test('limits the result to the parameters of the changed groups', () => {
expect(
resolveConsentMode(
DEFAULT_CONSENT_MODE_MAPPING,
{ analytics: true, marketing: true },
{ marketing: true },
),
).toEqual({
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
});
});

test('decides the parameter of a changed group from the whole state', () => {
const mapping = {
analytics: 'analytics_storage',
functional: 'analytics_storage',
};
expect(
resolveConsentMode(
mapping,
{ analytics: false, functional: true },
{ functional: true },
),
).toEqual({ analytics_storage: 'denied' });
});

test('ignores consent keys that are names of object built-ins', () => {
expect(
resolveConsentMode(
DEFAULT_CONSENT_MODE_MAPPING,
{ constructor: true, toString: true },
{ constructor: true, toString: true },
),
).toEqual({});
});

test('resolves nothing for groups the mapping does not name', () => {
expect(
resolveConsentMode(
DEFAULT_CONSENT_MODE_MAPPING,
{ functional: true },
{ functional: true },
),
).toEqual({});
});
});
73 changes: 73 additions & 0 deletions packages/web/core/src/consentMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { WalkerOS } from '@walkeros/core';

/**
* walkerOS consent groups mapped to consent mode parameters, as Google
* Consent Mode and the tools compatible with it (d8a) name them.
*/
export interface ConsentMapping {
[walkerOSConsentGroup: string]: string | string[];
}

export type ConsentModeValue = 'granted' | 'denied';

/**
* Default mapping: measurement storage follows `analytics`, the ad
* parameters follow `marketing`.
*/
export const DEFAULT_CONSENT_MODE_MAPPING: ConsentMapping = {
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
analytics: ['analytics_storage'],
};

function parametersOf(value: string | string[]): string[] {
return Array.isArray(value) ? value : [value];
}

/** Every parameter a mapping names, once, in mapping order. */
export function consentModeParameters(mapping: ConsentMapping): string[] {
const parameters = new Set<string>();
Object.values(mapping).forEach((value) =>
parametersOf(value).forEach((parameter) => parameters.add(parameter)),
);
return [...parameters];
}

/**
* Resolve consent mode parameters from walkerOS consent.
*
* A parameter is granted only when every group that maps to it is granted in
* `state`, the whole consent state; a group missing from it counts as denied.
* With `changed`, the result holds only the parameters of those groups, as a
* consent update carries only what a consent command changed. Groups the
* mapping does not name resolve nothing.
*/
export function resolveConsentMode(
mapping: ConsentMapping,
state: WalkerOS.Consent,
changed?: WalkerOS.Consent,
): Record<string, ConsentModeValue> {
const groupsOf = new Map<string, string[]>();
Object.entries(mapping).forEach(([group, value]) =>
parametersOf(value).forEach((parameter) => {
groupsOf.set(parameter, [...(groupsOf.get(parameter) ?? []), group]);
}),
);

const wanted = changed
? new Set(
Object.keys(changed).flatMap((group) =>
Object.prototype.hasOwnProperty.call(mapping, group)
? parametersOf(mapping[group])
: [],
),
)
: new Set(groupsOf.keys());

const result: Record<string, ConsentModeValue> = {};
wanted.forEach((parameter) => {
const groups = groupsOf.get(parameter);
const granted = !!groups && groups.every((group) => state[group] === true);
result[parameter] = granted ? 'granted' : 'denied';
});
return result;
}
1 change: 1 addition & 0 deletions packages/web/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './attributes';
export * from './browser';
export * from './consentMode';
export * from './elb';
export * from './environment';
export * from './getHashWeb';
Expand Down
Loading
Loading