Skip to content

Commit

Permalink
Merge pull request #13 from coinbase/gpc
Browse files Browse the repository at this point in the history
Support for GPC
  • Loading branch information
joshuaostrom-cb authored May 7, 2024
2 parents 34d4036 + 1123645 commit 51e7bb6
Show file tree
Hide file tree
Showing 14 changed files with 221 additions and 55 deletions.
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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

0 comments on commit 51e7bb6

Please sign in to comment.