Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for GPC #13

Merged
merged 6 commits into from
May 7, 2024
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
2 changes: 2 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ jobs:
- name: Lint Check
# When fails, please run "yarn lint" to your code
run: yarn lint
- name: Unit tests
run: yarn build && yarn test
4 changes: 4 additions & 0 deletions .github/workflows/release_cookie_manager.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:
yarn install
yarn build

- name: Test cookie-manager
run: |
yarn test

- name: Publish cookie-manager
working-directory: ./packages/cookie-manager
run: |
Expand Down
2 changes: 1 addition & 1 deletion apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"dependencies": {
"@coinbase/cookie-banner": "1.0.4",
"@coinbase/cookie-manager": "1.1.2",
"@coinbase/cookie-manager": "1.1.3",
"next": "14.0.0",
"react": "^18",
"react-dom": "^18"
Expand Down
2 changes: 1 addition & 1 deletion packages/cookie-banner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"react-dom": "^18.1.0"
},
"dependencies": {
"@coinbase/cookie-manager": "^1.1.2",
"@coinbase/cookie-manager": "^1.1.3",
"react-intl": "^6.5.1",
"styled-components": "^5.3.6"
}
Expand Down
5 changes: 5 additions & 0 deletions packages/cookie-manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.1.3 (05/03/2024)

- Added logic to honor GCP in non-EU localities
- Fixed failing spec

## 1.1.2 (02/26/2024)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/cookie-manager/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cookie-manager",
"version": "1.1.2",
"version": "1.1.3",
"description": "Coinbase Cookie Manager",
"main": "dist/index.js",
"license": "Apache-2.0",
Expand Down
38 changes: 29 additions & 9 deletions packages/cookie-manager/src/CookieContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
TrackerType,
TrackingPreference,
} from './types';
import { applyGpcToAdPref } from './utils/applyGpcToAdPref';
import { applyGpcToCookiePref } from './utils/applyGpcToCookiePref';
import getAllCookies, { areRecordsEqual } from './utils/getAllCookies';
import getDefaultTrackingPreference from './utils/getDefaultTrackingPreference';
import { getDomainWithoutSubdomain, getHostname } from './utils/getDomain';
Expand All @@ -39,7 +41,8 @@ export const CookieProvider = ({ children }: Props) => {
const { config, region, shadowMode, log, onPreferenceChange } = useTrackingManager();

const POLL_INTERVAL = 500;
const [cookieValues, setCookieValues] = useState(() => getAllCookies());
const [cookieValues, setCookieValues] = useState(() => getAllCookies(region));
let priorCookieValue: Record<string, any>;
let trackingPreference: TrackingPreference;
let adTrackingPreference: AdTrackingPreference;

Expand All @@ -61,11 +64,16 @@ export const CookieProvider = ({ children }: Props) => {
useEffect(() => {
if (typeof window !== 'undefined') {
const checkCookies = () => {
const currentCookie = getAllCookies();
if (!areRecordsEqual(cookieValues, currentCookie)) {
const currentCookie = getAllCookies(region);

if (priorCookieValue == undefined || !areRecordsEqual(priorCookieValue, currentCookie)) {
priorCookieValue = currentCookie;
setCookieValues(currentCookie);

// Grab out prefences (they wil have GPC applied if present)
trackingPreference = getTrackingPreference(currentCookie, region, config);
adTrackingPreference = getAdTrackingPreference(currentCookie);
adTrackingPreference = getAdTrackingPreference(currentCookie, region);

setGTMVariables(trackingPreference, adTrackingPreference);
const cookiesToRemove: Array<string> = [];
Object.keys(currentCookie).forEach((c) => {
Expand Down Expand Up @@ -195,14 +203,26 @@ const getTrackingPreference = (
region === Region.EU
? cookieCache[EU_CONSENT_PREFERENCES_COOKIE]
: cookieCache[DEFAULT_CONSENT_PREFERENCES_COOKIE];
return trackingPreference || getDefaultTrackingPreference(region, config);
};

const adTrackingDefault = { value: 'true' };
// Example preference
// { region: Region.EU, consent: ['necessary'] }
const preference = trackingPreference || getDefaultTrackingPreference(region, config);
// Apply GPC when present
return applyGpcToCookiePref(preference);
};

const getAdTrackingPreference = (cookieCache: Record<string, any>): AdTrackingPreference => {
// Do we want to change the ADVERTISING_SHARING_ALLOWED value to clear prior values?
const getAdTrackingPreference = (
cookieCache: Record<string, any>,
region: Region
): AdTrackingPreference => {
const adTrackingPreference = cookieCache[ADVERTISING_SHARING_ALLOWED];
return adTrackingPreference || adTrackingDefault;

const adTrackingDefault = region === Region.EU ? { value: 'false' } : { value: 'true' };

// Example: adPreference { value: 'false' }
const adPreference = adTrackingPreference || adTrackingDefault;
return applyGpcToAdPref(region, adPreference);
};

export const useCookie = (cookieName: string): [any | undefined, SetCookieFunction] => {
Expand Down
14 changes: 14 additions & 0 deletions packages/cookie-manager/src/utils/applyGpcToAdPref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Region } from '../types';
import { applyGpcToAdPref } from './applyGpcToAdPref';

describe('applyGpcToAdPref', () => {
it('removes targeting when GPC is ON in non-EU', () => {
(navigator as any).globalPrivacyControl = true;
expect(applyGpcToAdPref(Region.DEFAULT, { value: true })).toEqual({ value: false });
});

it('ignores GPC when in EU', () => {
(navigator as any).globalPrivacyControl = true;
expect(applyGpcToAdPref(Region.EU, { value: true })).toEqual({ value: true });
});
});
31 changes: 31 additions & 0 deletions packages/cookie-manager/src/utils/applyGpcToAdPref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { AdTrackingPreference, Region } from '../types';

const applyGpcToAdPref = (
region: Region,
preference: AdTrackingPreference
): AdTrackingPreference => {
// We are only applying GPC in non-EU countries at this point
if (region == Region.EU) {
return preference;
}
// If we lack GPC or it's set ot false we are done
if (!(navigator as any).globalPrivacyControl) {
return preference;
}

// If the user already has sharing turned off nothing to do here
if (preference.value == false) {
return preference; // already allowing sharing
}

// We could set the updated at time to now if we'd like
// preference.updated_at = new Date().getTime();

const pref: AdTrackingPreference = preference.updated_at
? { value: false, updated_at: preference.updated_at }
: { value: false };

return pref;
};

export { applyGpcToAdPref };
44 changes: 44 additions & 0 deletions packages/cookie-manager/src/utils/applyGpcToCookiePref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Region, TrackingCategory } from '../types';
import { applyGpcToCookiePref } from './applyGpcToCookiePref';

describe('applyGpcToCookiePref', () => {
it('removes targeting when GPC is ON in non-EU', () => {
(navigator as any).globalPrivacyControl = true;
expect(
applyGpcToCookiePref({ region: Region.DEFAULT, consent: [TrackingCategory.TARGETING] })
).toEqual({
region: Region.DEFAULT,
consent: [],
});
});

it('does not remove targeting when GPC is ON in EU', () => {
(navigator as any).globalPrivacyControl = true;
expect(
applyGpcToCookiePref({ region: Region.EU, consent: [TrackingCategory.TARGETING] })
).toEqual({
region: Region.EU,
consent: [TrackingCategory.TARGETING],
});
});

it('retains targeting when GPC is OFF', () => {
(navigator as any).globalPrivacyControl = false;
expect(
applyGpcToCookiePref({ region: Region.DEFAULT, consent: [TrackingCategory.TARGETING] })
).toEqual({
region: Region.DEFAULT,
consent: [TrackingCategory.TARGETING],
});
});

it('retains targeting when GPC is undefined', () => {
delete (navigator as any).globalPrivacyControl;
expect(
applyGpcToCookiePref({ region: Region.DEFAULT, consent: [TrackingCategory.TARGETING] })
).toEqual({
region: Region.DEFAULT,
consent: [TrackingCategory.TARGETING],
});
});
});
24 changes: 24 additions & 0 deletions packages/cookie-manager/src/utils/applyGpcToCookiePref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Region, TrackingCategory, TrackingPreference } from '../types';

// { region: Region.DEFAULT, consent: ['necessary', 'performance', 'functional', 'targeting'] }
const applyGpcToCookiePref = (preference: TrackingPreference): TrackingPreference => {
// We are only applying GPC in non-EU countries at this point
if (preference.region == Region.EU) {
return preference;
}

if (!(navigator as any).globalPrivacyControl) {
return preference;
}
// If the user had opted in to GPC we want to honor it
const categories = preference.consent.filter((cat) => cat !== TrackingCategory.TARGETING);

if (categories == preference.consent) {
return preference;
}
const pref = { region: preference.region, consent: categories };

return pref;
};

export { applyGpcToCookiePref };
38 changes: 35 additions & 3 deletions packages/cookie-manager/src/utils/getAllCookies.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Cookies from 'js-cookie';

import getAllCookies from './getAllCookies';
import getAllCookies, { Region } from './getAllCookies';
export { Region } from '../types';

jest.mock('js-cookie', () => ({
__esModule: true,
Expand All @@ -16,17 +17,48 @@ describe('getAllCookies', () => {
const mockGet = Cookies.get as jest.MockedFunction<typeof Cookies.get>;
const value = {
region: 'DEFAULT',
consent: ['necessary', 'performance'],
consent: ['necessary', 'performance', 'targeting'],
};
const cookies = {
cm_default_preferences: JSON.stringify(value),
advertising_sharing_allowed: JSON.stringify({ value: true }),
some_cookie: 'iamastring',
another_cookie: '5',
array_cookie: JSON.stringify(['item1', 'item2']),
};
(navigator as any).globalPrivacyControl = false;

mockGet.mockImplementation(jest.fn(() => cookies));
expect(getAllCookies({})).toEqual({

expect(getAllCookies(Region.DEFAULT, {})).toEqual({
cm_default_preferences: value,
advertising_sharing_allowed: { value: true },
some_cookie: 'iamastring',
another_cookie: 5,
array_cookie: ['item1', 'item2'],
});
});

it('applies GCP to cookie values', () => {
const mockGet = Cookies.get as jest.MockedFunction<typeof Cookies.get>;
const value = {
region: 'DEFAULT',
consent: ['necessary', 'performance', 'targeting'],
};
const cookies = {
cm_default_preferences: JSON.stringify(value),
advertising_sharing_allowed: JSON.stringify({ value: true }),
some_cookie: 'iamastring',
another_cookie: '5',
array_cookie: JSON.stringify(['item1', 'item2']),
};
(navigator as any).globalPrivacyControl = true;

mockGet.mockImplementation(jest.fn(() => cookies));

expect(getAllCookies(Region.DEFAULT, {})).toEqual({
cm_default_preferences: { consent: ['necessary', 'performance'], region: 'DEFAULT' },
advertising_sharing_allowed: { value: false },
some_cookie: 'iamastring',
another_cookie: 5,
array_cookie: ['item1', 'item2'],
Expand Down
34 changes: 30 additions & 4 deletions packages/cookie-manager/src/utils/getAllCookies.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import Cookies from 'js-cookie';

export const deserializeCookies = (cookies: Record<string, string>) => {
export { Region } from '../types';

import {
ADVERTISING_SHARING_ALLOWED,
DEFAULT_CONSENT_PREFERENCES_COOKIE,
EU_CONSENT_PREFERENCES_COOKIE,
} from '../constants';
import { Region } from '../types';
import { applyGpcToAdPref } from './applyGpcToAdPref';
import { applyGpcToCookiePref } from './applyGpcToCookiePref';

export const deserializeCookies = (region: Region, cookies: Record<string, string>) => {
const parsedCookies: Record<string, any> = {};

Object.keys(cookies).forEach((c) => {
Expand All @@ -9,15 +20,30 @@ export const deserializeCookies = (cookies: Record<string, string>) => {
} catch (e) {
parsedCookies[c] = cookies[c];
}
parsedCookies[c] = filterCookieValue(region, c, parsedCookies[c]);
});
return parsedCookies;
};

export default function getAllCookies(initialCookies?: Record<string, string>) {
export default function getAllCookies(region: Region, initialCookies?: Record<string, string>) {
if (typeof window === 'undefined' && initialCookies) {
return deserializeCookies(initialCookies);
return deserializeCookies(region, initialCookies);
}
return deserializeCookies(region, Cookies.get() || {});
}

// Apply in in memory filters to the cookie values. Currently we are just apply
// Global Privacy Control (GPC) logic to ensure we are honoring GPC
function filterCookieValue(region: Region, cookieName: string, cookieValue: any) {
if (cookieName == ADVERTISING_SHARING_ALLOWED) {
cookieValue = applyGpcToAdPref(region, cookieValue);
} else if (
(region == Region.DEFAULT && cookieName == DEFAULT_CONSENT_PREFERENCES_COOKIE) ||
(region == Region.EU && cookieName == EU_CONSENT_PREFERENCES_COOKIE)
) {
cookieValue = applyGpcToCookiePref(cookieValue);
}
return deserializeCookies(Cookies.get() || {});
return cookieValue;
}

export function areRecordsEqual(
Expand Down
36 changes: 0 additions & 36 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1077,42 +1077,6 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==

"@coinbase/cookie-banner@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@coinbase/cookie-banner/-/cookie-banner-1.0.1.tgz#90a10f13b62356baca41117cbcf98a20bff7e1cd"
integrity sha512-tVgNjNaSCC7nts/uju+vWkucWyXPSL4CaZflc5rkLuEKb7ZnY0otZnqDPRx0O4/0xLQ72tY9nw41/+HhTsKOqg==
dependencies:
"@coinbase/cookie-manager" "^1.0.0"
react-intl "^6.5.1"
styled-components "^5.3.6"

"@coinbase/cookie-banner@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@coinbase/cookie-banner/-/cookie-banner-1.0.3.tgz#a755e4ac9ffa0f3bfe22fc84ec4d88863ad82ad3"
integrity sha512-RMCyb42Ja4vxdZlN8tsFQaQgZUJwx7yvSFZeMnArQyHlKOjpzvJ+NCXY3G4aVYEGC0j86otsZ5Xe43F+qs2MYw==
dependencies:
"@coinbase/cookie-manager" "1.1.1"
react-intl "^6.5.1"
styled-components "^5.3.6"

"@coinbase/cookie-manager@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@coinbase/cookie-manager/-/cookie-manager-1.1.0.tgz#3a47a89989953e0cb32b6b63445879252e42477b"
integrity sha512-r8UR7jSYxAPKIV7jSlqkmWfWi7kdcfMo7hJ0dV0FF2wMx1IIMU6V72BmuMpmn7Ov7HizAKtEcl/I/9fSWRVIQw==
dependencies:
"@coinbase/cookie-banner" "1.0.1"
"@coinbase/cookie-manager" "1.1.0"
js-cookie "^3.0.5"

"@coinbase/cookie-manager@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@coinbase/cookie-manager/-/cookie-manager-1.1.1.tgz#f204ade281a2e2dccdf6e77baa7433cd054656a4"
integrity sha512-1fjLrWOyM2392eaDdgqIHlZHGuziRRzQZib3RuYSTdrX9z81muDc/oSvakb6VeDtfZkje0+3MHhnkSscaa5tUg==
dependencies:
"@coinbase/cookie-banner" "1.0.1"
"@coinbase/cookie-manager" "1.1.0"
js-cookie "^3.0.5"

"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
Expand Down
Loading