feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
announce,
createLiveRegion,
focusFirst,
getFocusableElements,
onEscape,
prefersReducedMotion,
trapFocus,
} from '../a11y';
describe('a11y', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
describe('getFocusableElements', () => {
it('should find buttons', () => {
container.innerHTML = '<button>Click</button><button disabled>No</button>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
expect(elements[0].tagName).toBe('BUTTON');
});
it('should find links with href', () => {
container.innerHTML = '<a href="/test">Link</a><a>No href</a>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
it('should find inputs', () => {
container.innerHTML = '<input type="text" /><input type="checkbox" disabled />';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
it('should find elements with tabindex', () => {
container.innerHTML = '<div tabindex="0">Focusable</div><div tabindex="-1">Not focusable</div>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
it('should return empty array for no focusable elements', () => {
container.innerHTML = '<div>Just text</div>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(0);
});
it('should search shadow DOM when present', () => {
const shadow = container.attachShadow({ mode: 'open' });
shadow.innerHTML = '<button>Shadow button</button>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
});
describe('trapFocus', () => {
it('should wrap Tab from last to first element', () => {
container.innerHTML = '<button id="first">First</button><button id="last">Last</button>';
const first = container.querySelector('#first') as HTMLElement;
const last = container.querySelector('#last') as HTMLElement;
last.focus();
const cleanup = trapFocus(container);
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
vi.spyOn(event, 'preventDefault');
// Simulate activeElement being the last element
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(last);
container.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
cleanup();
});
it('should wrap Shift+Tab from first to last element', () => {
container.innerHTML = '<button id="first">First</button><button id="last">Last</button>';
const first = container.querySelector('#first') as HTMLElement;
first.focus();
const cleanup = trapFocus(container);
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true });
vi.spyOn(event, 'preventDefault');
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(first);
container.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
cleanup();
});
it('should not interfere with non-Tab keys', () => {
container.innerHTML = '<button>Btn</button>';
const cleanup = trapFocus(container);
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
vi.spyOn(event, 'preventDefault');
container.dispatchEvent(event);
expect(event.preventDefault).not.toHaveBeenCalled();
cleanup();
});
it('should return a cleanup function that removes the listener', () => {
container.innerHTML = '<button>Btn</button>';
const cleanup = trapFocus(container);
const spy = vi.spyOn(container, 'removeEventListener');
cleanup();
expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function));
});
});
describe('onEscape', () => {
it('should call callback on Escape key', () => {
const callback = vi.fn();
const cleanup = onEscape(container, callback);
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
container.dispatchEvent(event);
expect(callback).toHaveBeenCalledOnce();
cleanup();
});
it('should not call callback on other keys', () => {
const callback = vi.fn();
const cleanup = onEscape(container, callback);
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
container.dispatchEvent(event);
expect(callback).not.toHaveBeenCalled();
cleanup();
});
it('should return a cleanup function', () => {
const callback = vi.fn();
const cleanup = onEscape(container, callback);
cleanup();
// After cleanup, Escape should not trigger callback
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
container.dispatchEvent(event);
expect(callback).not.toHaveBeenCalled();
});
});
describe('focusFirst', () => {
it('should focus the first focusable element', () => {
container.innerHTML = '<div>Text</div><button id="btn">Button</button><input />';
const btn = container.querySelector('#btn') as HTMLElement;
const spy = vi.spyOn(btn, 'focus');
focusFirst(container);
expect(spy).toHaveBeenCalled();
});
it('should do nothing when no focusable elements', () => {
container.innerHTML = '<div>Just text</div>';
// Should not throw
focusFirst(container);
});
});
describe('createLiveRegion', () => {
it('should create an element with role=status', () => {
const region = createLiveRegion(container);
expect(region.getAttribute('role')).toBe('status');
expect(region.getAttribute('aria-live')).toBe('polite');
expect(region.getAttribute('aria-atomic')).toBe('true');
});
it('should append the region to the container', () => {
const region = createLiveRegion(container);
expect(container.contains(region)).toBe(true);
});
it('should have sr-only class for visual hiding', () => {
const region = createLiveRegion(container);
expect(region.className).toBe('cmp-sr-only');
});
});
describe('announce', () => {
it('should set text content on live region', async () => {
const region = createLiveRegion(container);
announce(region, 'Preferences expanded');
// The announcement happens in the next animation frame
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(region.textContent).toBe('Preferences expanded');
});
it('should clear before setting to trigger re-announcement', () => {
const region = createLiveRegion(container);
region.textContent = 'Old message';
announce(region, 'New message');
// Immediately after call, text should be cleared
expect(region.textContent).toBe('');
});
});
describe('prefersReducedMotion', () => {
it('should return false by default in test environment', () => {
// JSDOM defaults to no media query match
expect(prefersReducedMotion()).toBe(false);
});
it('should check the prefers-reduced-motion media query', () => {
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
} as MediaQueryList);
expect(prefersReducedMotion()).toBe(true);
matchMediaSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,368 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// We need to test banner.ts functions, but it auto-inits on import.
// We'll mock fetch and test the composed behaviour.
vi.mock('../blocker', () => ({
updateAcceptedCategories: vi.fn(),
}));
vi.mock('../consent', () => ({
buildConsentState: vi.fn(() => ({
accepted: ['necessary'],
rejected: ['analytics', 'marketing'],
visitorId: 'v-test',
consentedAt: new Date().toISOString(),
})),
readConsent: vi.fn(() => null),
writeConsent: vi.fn(),
}));
vi.mock('../gcm', () => ({
buildGcmStateFromCategories: vi.fn(() => ({
analytics_storage: 'denied',
ad_storage: 'denied',
security_storage: 'granted',
})),
updateGcm: vi.fn(),
}));
import { updateAcceptedCategories } from '../blocker';
import { buildConsentState, readConsent, writeConsent } from '../consent';
import { buildGcmStateFromCategories, updateGcm } from '../gcm';
import type { SiteConfig, CategorySlug } from '../types';
describe('banner', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const ALL_CATEGORIES: CategorySlug[] = [
'necessary', 'functional', 'analytics', 'marketing', 'personalisation',
];
const NON_ESSENTIAL: CategorySlug[] = [
'functional', 'analytics', 'marketing', 'personalisation',
];
const defaultConfig: SiteConfig = {
id: 'cfg-1',
site_id: 'site-1',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
};
beforeEach(() => {
vi.clearAllMocks();
window.dataLayer = [];
window.__consentos = { siteId: 'site-1', apiBase: 'https://api.example.com', cdnBase: 'https://cdn.example.com', loaded: false };
mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
// Clean up banner host if it was appended
const host = document.getElementById('consentos-banner-host');
if (host) host.remove();
vi.unstubAllGlobals();
});
describe('buildDefaultConfig', () => {
it('should create a valid default config', () => {
// This mirrors what buildDefaultConfig does in the banner
const config: SiteConfig = {
id: '',
site_id: 'test-site',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
};
expect(config.blocking_mode).toBe('opt_in');
expect(config.gcm_enabled).toBe(true);
expect(config.consent_expiry_days).toBe(365);
});
});
describe('determineAction', () => {
it('should return accept_all when no rejections', () => {
const accepted: CategorySlug[] = ALL_CATEGORIES;
const rejected: CategorySlug[] = [];
let action: string;
if (rejected.length === 0) action = 'accept_all';
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
else action = 'custom';
expect(action).toBe('accept_all');
});
it('should return reject_all when only necessary accepted', () => {
const accepted: CategorySlug[] = ['necessary'];
const rejected = NON_ESSENTIAL;
let action: string;
if (rejected.length === 0) action = 'accept_all';
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
else action = 'custom';
expect(action).toBe('reject_all');
});
it('should return custom when partial selection', () => {
const accepted: CategorySlug[] = ['necessary', 'analytics'];
const rejected: CategorySlug[] = ['marketing', 'functional', 'personalisation'];
let action: string;
if (rejected.length === 0) action = 'accept_all';
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
else action = 'custom';
expect(action).toBe('custom');
});
});
describe('handleConsent flow', () => {
it('should write consent, update blocker, and update GCM', () => {
const accepted: CategorySlug[] = ['necessary', 'analytics'];
const rejected: CategorySlug[] = ['marketing', 'functional', 'personalisation'];
const gcmState = buildGcmStateFromCategories(accepted);
const state = buildConsentState(accepted, rejected);
writeConsent(state, defaultConfig.consent_expiry_days);
updateAcceptedCategories(accepted);
if (defaultConfig.gcm_enabled) {
updateGcm(gcmState);
}
expect(writeConsent).toHaveBeenCalled();
expect(updateAcceptedCategories).toHaveBeenCalledWith(accepted);
expect(updateGcm).toHaveBeenCalled();
});
it('should NOT call updateGcm when gcm_enabled is false', () => {
const config = { ...defaultConfig, gcm_enabled: false };
const accepted: CategorySlug[] = ['necessary'];
const rejected = NON_ESSENTIAL;
const gcmState = buildGcmStateFromCategories(accepted);
const state = buildConsentState(accepted, rejected);
writeConsent(state, config.consent_expiry_days);
updateAcceptedCategories(accepted);
if (config.gcm_enabled) {
updateGcm(gcmState);
}
expect(updateGcm).not.toHaveBeenCalled();
});
it('should post consent to the API', () => {
const accepted: CategorySlug[] = ALL_CATEGORIES;
const rejected: CategorySlug[] = [];
mockFetch.mockResolvedValue(new Response('', { status: 201 }));
fetch('https://api.example.com/api/v1/consent/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
site_id: 'site-1',
visitor_id: 'v-test',
action: 'accept_all',
categories_accepted: accepted,
categories_rejected: rejected,
gcm_state: { analytics_storage: 'granted' },
page_url: window.location.href,
}),
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/v1/consent/',
expect.objectContaining({ method: 'POST' })
);
});
it('should dispatch consent-change event', () => {
const accepted: CategorySlug[] = ['necessary', 'functional'];
let receivedDetail: unknown = null;
document.addEventListener('consentos:consent-change', ((e: CustomEvent) => {
receivedDetail = e.detail;
}) as EventListener);
document.dispatchEvent(
new CustomEvent('consentos:consent-change', { detail: { accepted } })
);
expect(receivedDetail).toEqual({ accepted });
});
});
describe('renderBanner', () => {
it('should create a shadow DOM host element', () => {
const host = document.createElement('div');
host.id = 'consentos-banner-host';
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
<button data-action="accept">Accept all</button>
<button data-action="reject">Reject all</button>
</div>
`;
document.body.appendChild(host);
const bannerHost = document.getElementById('consentos-banner-host');
expect(bannerHost).not.toBeNull();
expect(bannerHost?.shadowRoot).not.toBeNull();
const banner = bannerHost?.shadowRoot?.querySelector('.consentos-banner');
expect(banner).not.toBeNull();
expect(banner?.getAttribute('role')).toBe('dialog');
});
it('should render category toggles', () => {
const categories = [
{ slug: 'necessary', name: 'Necessary', locked: true },
{ slug: 'analytics', name: 'Analytics', locked: false },
];
const html = categories
.map(
(cat) =>
`<label class="cmp-category">
<span>${cat.name}</span>
<input type="checkbox" data-category="${cat.slug}" ${cat.locked ? 'checked disabled' : ''} />
</label>`
)
.join('');
const div = document.createElement('div');
div.innerHTML = html;
const inputs = div.querySelectorAll<HTMLInputElement>('input[data-category]');
expect(inputs).toHaveLength(2);
expect(inputs[0].disabled).toBe(true);
expect(inputs[0].checked).toBe(true);
expect(inputs[1].disabled).toBe(false);
});
it('should include privacy policy link when URL is provided', () => {
const url = 'https://example.com/privacy';
const html = `<a href="${url}" target="_blank" rel="noopener">Privacy Policy</a>`;
const div = document.createElement('div');
div.innerHTML = html;
const link = div.querySelector('a');
expect(link?.href).toBe(url);
expect(link?.target).toBe('_blank');
expect(link?.rel).toBe('noopener');
});
});
describe('removeBanner', () => {
it('should remove the banner host from DOM', async () => {
const host = document.createElement('div');
host.id = 'consentos-banner-host';
document.body.appendChild(host);
expect(document.getElementById('consentos-banner-host')).not.toBeNull();
host.remove();
expect(document.getElementById('consentos-banner-host')).toBeNull();
});
});
describe('getBannerStyles', () => {
it('should use default colours when no banner_config', () => {
const bc = defaultConfig.banner_config;
const bg = bc?.backgroundColour ?? '#ffffff';
const text = bc?.textColour ?? '#0E1929';
const primary = bc?.primaryColour ?? '#2C6AE4';
expect(bg).toBe('#ffffff');
expect(text).toBe('#0E1929');
expect(primary).toBe('#2C6AE4');
});
it('should use custom colours from banner_config', () => {
const config = {
...defaultConfig,
banner_config: {
backgroundColour: '#000000',
textColour: '#ffffff',
primaryColour: '#ff0000',
},
};
const bc = config.banner_config;
const bg = bc?.backgroundColour ?? '#ffffff';
const text = bc?.textColour ?? '#0E1929';
const primary = bc?.primaryColour ?? '#ff0000';
expect(bg).toBe('#000000');
expect(text).toBe('#ffffff');
expect(primary).toBe('#ff0000');
});
});
describe('getSelectedCategories', () => {
it('should return necessary plus checked categories', () => {
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<input type="checkbox" data-category="necessary" checked disabled />
<input type="checkbox" data-category="analytics" checked />
<input type="checkbox" data-category="marketing" />
`;
const checked: CategorySlug[] = ['necessary'];
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
if (input.checked) {
checked.push(input.getAttribute('data-category') as CategorySlug);
}
});
const unique = [...new Set(checked)];
expect(unique).toContain('necessary');
expect(unique).toContain('analytics');
expect(unique).not.toContain('marketing');
});
});
});

View File

@@ -0,0 +1,333 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
installBlocker,
uninstallBlocker,
updateAcceptedCategories,
getBlockedCount,
isCategoryAllowed,
addScriptPatterns,
loadInitiatorMappings,
} from '../blocker';
describe('blocker', () => {
beforeEach(() => {
installBlocker();
});
afterEach(() => {
uninstallBlocker();
});
describe('installBlocker', () => {
it('should only install once', () => {
// Install a second time — should be a no-op
installBlocker();
expect(isCategoryAllowed('necessary')).toBe(true);
});
it('should allow necessary category by default', () => {
expect(isCategoryAllowed('necessary')).toBe(true);
});
it('should deny non-essential categories by default', () => {
expect(isCategoryAllowed('analytics')).toBe(false);
expect(isCategoryAllowed('marketing')).toBe(false);
expect(isCategoryAllowed('functional')).toBe(false);
expect(isCategoryAllowed('personalisation')).toBe(false);
});
});
describe('updateAcceptedCategories', () => {
it('should update accepted categories', () => {
updateAcceptedCategories(['necessary', 'analytics']);
expect(isCategoryAllowed('analytics')).toBe(true);
expect(isCategoryAllowed('marketing')).toBe(false);
});
it('should accept all categories when all are provided', () => {
updateAcceptedCategories([
'necessary',
'functional',
'analytics',
'marketing',
'personalisation',
]);
expect(isCategoryAllowed('functional')).toBe(true);
expect(isCategoryAllowed('analytics')).toBe(true);
expect(isCategoryAllowed('marketing')).toBe(true);
expect(isCategoryAllowed('personalisation')).toBe(true);
});
});
describe('script interception via createElement', () => {
it('should override document.createElement', () => {
// createElement should still work for non-script elements
const div = document.createElement('div');
expect(div).toBeInstanceOf(HTMLDivElement);
});
it('should create script elements normally when no src is set', () => {
const script = document.createElement('script');
expect(script).toBeInstanceOf(HTMLScriptElement);
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should mark analytics scripts as blocked when src is set', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://www.google-analytics.com/analytics.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('analytics');
expect(script.type).toBe('text/blocked');
});
it('should mark marketing scripts as blocked', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://connect.facebook.net/en_US/fbevents.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
it('should allow scripts when their category is accepted', () => {
updateAcceptedCategories(['necessary', 'analytics']);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://www.google-analytics.com/analytics.js';
// Should not be blocked
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should allow scripts with unknown src (no matching pattern)', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://example.com/my-custom-script.js';
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should respect explicit data-category attribute', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'marketing');
script.src = 'https://example.com/unknown-tracker.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
});
describe('MutationObserver blocking', () => {
it('should block a script inserted into the DOM', async () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'analytics');
script.src = 'https://example.com/analytics.js';
document.head.appendChild(script);
// MutationObserver is async, wait a tick
await new Promise((resolve) => setTimeout(resolve, 0));
// Script should have been removed from DOM and queued
expect(script.parentNode).toBeNull();
expect(getBlockedCount()).toBeGreaterThan(0);
});
it('should not block scripts marked as allowed', async () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-consentos-allowed', 'true');
script.setAttribute('data-category', 'analytics');
script.textContent = '/* allowed */';
document.head.appendChild(script);
await new Promise((resolve) => setTimeout(resolve, 0));
// Should still be in the DOM
expect(script.parentNode).toBe(document.head);
// Clean up
script.remove();
});
});
describe('release manager', () => {
it('should release blocked scripts when consent is granted', async () => {
// Insert a blocked script
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'analytics');
script.src = 'https://example.com/analytics-lib.js';
document.head.appendChild(script);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(getBlockedCount()).toBeGreaterThan(0);
const countBefore = getBlockedCount();
// Grant analytics consent
updateAcceptedCategories(['necessary', 'analytics']);
// Blocked count should decrease
expect(getBlockedCount()).toBeLessThan(countBefore);
});
it('should not release scripts for non-consented categories', async () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'marketing');
script.src = 'https://example.com/marketing.js';
document.head.appendChild(script);
await new Promise((resolve) => setTimeout(resolve, 0));
const count = getBlockedCount();
// Grant analytics only (not marketing)
updateAcceptedCategories(['necessary', 'analytics']);
// Marketing scripts should still be blocked
// Count may have decreased by analytics scripts but marketing should remain
expect(getBlockedCount()).toBeGreaterThanOrEqual(count > 0 ? 1 : 0);
});
});
describe('cookie proxy', () => {
it('should allow CMP cookies to be set', () => {
document.cookie = '_consentos_test=value; path=/';
expect(document.cookie).toContain('_consentos_test=value');
// Clean up
document.cookie = '_consentos_test=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
it('should block known analytics cookies when not consented', () => {
const before = document.cookie;
document.cookie = '_ga=GA1.2.12345; path=/';
// _ga should not appear (blocked)
expect(document.cookie).not.toContain('_ga=GA1.2.12345');
// Ensure we haven't corrupted the cookie string
expect(document.cookie.length).toBeGreaterThanOrEqual(0);
});
it('should allow analytics cookies when consented', () => {
updateAcceptedCategories(['necessary', 'analytics']);
document.cookie = '_ga=GA1.2.12345; path=/';
expect(document.cookie).toContain('_ga=GA1.2.12345');
// Clean up
document.cookie = '_ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
it('should allow unknown cookies (not in any pattern)', () => {
document.cookie = 'my_app_session=abc123; path=/';
expect(document.cookie).toContain('my_app_session=abc123');
// Clean up
document.cookie = 'my_app_session=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
});
describe('storage proxy', () => {
it('should block known analytics storage keys', () => {
localStorage.setItem('_hjSession_12345', 'test');
// Should be blocked — key should not exist
expect(localStorage.getItem('_hjSession_12345')).toBeNull();
});
it('should allow storage writes when category is consented', () => {
updateAcceptedCategories(['necessary', 'analytics']);
localStorage.setItem('_hjSession_12345', 'test');
expect(localStorage.getItem('_hjSession_12345')).toBe('test');
// Clean up
localStorage.removeItem('_hjSession_12345');
});
it('should allow CMP storage keys', () => {
localStorage.setItem('_consentos_state', 'test');
expect(localStorage.getItem('_consentos_state')).toBe('test');
// Clean up
localStorage.removeItem('_consentos_state');
});
it('should allow unknown storage keys', () => {
localStorage.setItem('my_app_key', 'value');
expect(localStorage.getItem('my_app_key')).toBe('value');
// Clean up
localStorage.removeItem('my_app_key');
});
});
describe('addScriptPatterns', () => {
it('should add custom patterns for classification', () => {
addScriptPatterns([
{ pattern: 'custom-tracker\\.example\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://custom-tracker.example.com/track.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
it('should handle invalid patterns gracefully', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
addScriptPatterns([{ pattern: '[invalid', category: 'analytics' }]);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid script pattern')
);
consoleSpy.mockRestore();
});
});
describe('loadInitiatorMappings', () => {
it('should block scripts matching initiator mappings', () => {
loadInitiatorMappings([
{ root_script: 'gtm\\.example\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://gtm.example.com/gtm.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
it('should not block initiator scripts when category is consented', () => {
updateAcceptedCategories(['necessary', 'marketing']);
loadInitiatorMappings([
{ root_script: 'gtm\\.example\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://gtm.example.com/gtm.js';
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should handle invalid initiator patterns gracefully', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadInitiatorMappings([{ root_script: '[invalid', category: 'analytics' }]);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid initiator pattern')
);
consoleSpy.mockRestore();
});
it('should prioritise URL patterns over initiator mappings', () => {
// google-analytics.com matches the built-in analytics pattern
loadInitiatorMappings([
{ root_script: 'google-analytics\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://www.google-analytics.com/analytics.js';
// Should match URL pattern (analytics) not initiator mapping (marketing)
expect(script.getAttribute('data-consentos-category')).toBe('analytics');
});
});
describe('uninstallBlocker', () => {
it('should restore original document.createElement', () => {
uninstallBlocker();
const div = document.createElement('div');
expect(div).toBeInstanceOf(HTMLDivElement);
});
it('should reset blocked count to zero', () => {
uninstallBlocker();
expect(getBlockedCount()).toBe(0);
});
it('should reset accepted categories to necessary only', () => {
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
uninstallBlocker();
expect(isCategoryAllowed('analytics')).toBe(false);
expect(isCategoryAllowed('necessary')).toBe(true);
});
});
});

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
buildConsentState,
clearConsent,
generateVisitorId,
hasConsent,
isCategoryAccepted,
readConsent,
writeConsent,
} from '../consent';
describe('consent', () => {
beforeEach(() => {
// Clear all cookies
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
});
});
describe('generateVisitorId', () => {
it('should return a UUID-like string', () => {
const id = generateVisitorId();
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
it('should generate unique IDs', () => {
const id1 = generateVisitorId();
const id2 = generateVisitorId();
expect(id1).not.toBe(id2);
});
});
describe('buildConsentState', () => {
it('should build state with accepted and rejected categories', () => {
const state = buildConsentState(
['necessary', 'analytics'],
['marketing'],
);
expect(state.accepted).toEqual(['necessary', 'analytics']);
expect(state.rejected).toEqual(['marketing']);
expect(state.visitorId).toBeTruthy();
expect(state.bannerVersion).toBe('0.1.0');
expect(state.consentedAt).toBeTruthy();
});
it('should use existing visitor ID if provided', () => {
const state = buildConsentState(
['necessary'],
[],
'existing-id-123',
);
expect(state.visitorId).toBe('existing-id-123');
});
});
describe('read/write/clear consent', () => {
it('should return null when no consent cookie exists', () => {
expect(readConsent()).toBeNull();
expect(hasConsent()).toBe(false);
});
it('should write and read consent state', () => {
const state = buildConsentState(['necessary', 'analytics'], ['marketing']);
writeConsent(state);
const read = readConsent();
expect(read).not.toBeNull();
expect(read!.accepted).toEqual(['necessary', 'analytics']);
expect(read!.rejected).toEqual(['marketing']);
expect(hasConsent()).toBe(true);
});
it('should clear consent', () => {
const state = buildConsentState(['necessary'], []);
writeConsent(state);
expect(hasConsent()).toBe(true);
clearConsent();
expect(hasConsent()).toBe(false);
});
});
describe('isCategoryAccepted', () => {
it('should return true for necessary when no consent exists', () => {
expect(isCategoryAccepted('necessary')).toBe(true);
});
it('should return false for non-necessary when no consent exists', () => {
expect(isCategoryAccepted('analytics')).toBe(false);
});
it('should check accepted categories from cookie', () => {
const state = buildConsentState(['necessary', 'analytics'], ['marketing']);
writeConsent(state);
expect(isCategoryAccepted('necessary')).toBe(true);
expect(isCategoryAccepted('analytics')).toBe(true);
expect(isCategoryAccepted('marketing')).toBe(false);
});
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
buildDeniedDefaults,
buildGcmStateFromCategories,
setGcmDefaults,
updateGcm,
} from '../gcm';
describe('gcm', () => {
beforeEach(() => {
// Reset dataLayer and gtag before each test
window.dataLayer = [];
// @ts-expect-error — resetting gtag for test isolation
window.gtag = undefined;
});
describe('buildDeniedDefaults', () => {
it('should deny all except security_storage', () => {
const defaults = buildDeniedDefaults();
expect(defaults.ad_storage).toBe('denied');
expect(defaults.ad_user_data).toBe('denied');
expect(defaults.ad_personalization).toBe('denied');
expect(defaults.analytics_storage).toBe('denied');
expect(defaults.functionality_storage).toBe('denied');
expect(defaults.personalization_storage).toBe('denied');
expect(defaults.security_storage).toBe('granted');
});
it('should return all 7 consent types', () => {
const defaults = buildDeniedDefaults();
const keys = Object.keys(defaults);
expect(keys).toHaveLength(7);
});
});
describe('buildGcmStateFromCategories', () => {
it('should grant analytics_storage for analytics category', () => {
const state = buildGcmStateFromCategories(['necessary', 'analytics']);
expect(state.analytics_storage).toBe('granted');
expect(state.ad_storage).toBe('denied');
});
it('should grant ad types for marketing category', () => {
const state = buildGcmStateFromCategories(['necessary', 'marketing']);
expect(state.ad_storage).toBe('granted');
expect(state.ad_user_data).toBe('granted');
expect(state.ad_personalization).toBe('granted');
expect(state.analytics_storage).toBe('denied');
});
it('should grant functionality for functional category', () => {
const state = buildGcmStateFromCategories(['necessary', 'functional']);
expect(state.functionality_storage).toBe('granted');
expect(state.personalization_storage).toBe('granted');
});
it('should grant personalization_storage for personalisation category', () => {
const state = buildGcmStateFromCategories(['necessary', 'personalisation']);
expect(state.personalization_storage).toBe('granted');
expect(state.functionality_storage).toBe('denied');
});
it('should grant all for all categories', () => {
const state = buildGcmStateFromCategories([
'necessary', 'functional', 'analytics', 'marketing', 'personalisation',
]);
expect(state.analytics_storage).toBe('granted');
expect(state.ad_storage).toBe('granted');
expect(state.ad_user_data).toBe('granted');
expect(state.ad_personalization).toBe('granted');
expect(state.functionality_storage).toBe('granted');
expect(state.personalization_storage).toBe('granted');
expect(state.security_storage).toBe('granted');
});
it('should deny all for only necessary', () => {
const state = buildGcmStateFromCategories(['necessary']);
expect(state.analytics_storage).toBe('denied');
expect(state.ad_storage).toBe('denied');
expect(state.functionality_storage).toBe('denied');
expect(state.security_storage).toBe('granted');
});
it('should handle empty array', () => {
const state = buildGcmStateFromCategories([]);
expect(state.analytics_storage).toBe('denied');
expect(state.ad_storage).toBe('denied');
expect(state.security_storage).toBe('granted');
});
});
describe('setGcmDefaults', () => {
it('should create dataLayer if it does not exist', () => {
// @ts-expect-error — simulating no dataLayer
delete window.dataLayer;
setGcmDefaults(buildDeniedDefaults());
expect(window.dataLayer).toBeDefined();
expect(Array.isArray(window.dataLayer)).toBe(true);
});
it('should push consent default command to dataLayer', () => {
setGcmDefaults(buildDeniedDefaults());
// The gtag function pushes arguments objects to dataLayer
expect(window.dataLayer.length).toBeGreaterThan(0);
});
it('should create gtag function if not present', () => {
setGcmDefaults(buildDeniedDefaults());
expect(typeof window.gtag).toBe('function');
});
it('should include wait_for_update parameter', () => {
setGcmDefaults(buildDeniedDefaults());
// The first entry should be the consent default call
// gtag pushes `arguments` objects; check the dataLayer
const entry = window.dataLayer[0] as { [key: number]: unknown };
// Arguments object: [0]='consent', [1]='default', [2]={...}
expect(entry[0]).toBe('consent');
expect(entry[1]).toBe('default');
const consentObj = entry[2] as Record<string, unknown>;
expect(consentObj.wait_for_update).toBe(500);
});
});
describe('updateGcm', () => {
it('should push consent update command to dataLayer', () => {
// Initialise gtag first
setGcmDefaults(buildDeniedDefaults());
const lengthBefore = window.dataLayer.length;
updateGcm({ analytics_storage: 'granted' });
expect(window.dataLayer.length).toBe(lengthBefore + 1);
const entry = window.dataLayer[window.dataLayer.length - 1] as { [key: number]: unknown };
expect(entry[0]).toBe('consent');
expect(entry[1]).toBe('update');
});
it('should work without prior setGcmDefaults call', () => {
updateGcm({ ad_storage: 'granted' });
expect(window.dataLayer.length).toBeGreaterThan(0);
});
it('should pass the state object correctly', () => {
setGcmDefaults(buildDeniedDefaults());
const state = { analytics_storage: 'granted' as const, ad_storage: 'denied' as const };
updateGcm(state);
const entry = window.dataLayer[window.dataLayer.length - 1] as { [key: number]: unknown };
const consentObj = entry[2] as Record<string, string>;
expect(consentObj.analytics_storage).toBe('granted');
expect(consentObj.ad_storage).toBe('denied');
});
});
});

View File

@@ -0,0 +1,226 @@
/**
* Tests for GPC signal detection and jurisdiction-aware auto-opt-out.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
type GpcConfig,
type GpcResult,
isGpcEnabled,
shouldHonourGpc,
evaluateGpc,
DEFAULT_GPC_JURISDICTIONS,
GPC_OPTOUT_CATEGORIES,
GPC_ACCEPTED_CATEGORIES,
} from '../gpc';
// ── Helpers ───────────────────────────────────────────────────────────
function makeGpcConfig(overrides: Partial<GpcConfig> = {}): GpcConfig {
return {
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
...overrides,
};
}
/** Set or clear the GPC signal on navigator. */
function setGpc(value: boolean | undefined): void {
if (value === undefined) {
delete (navigator as { globalPrivacyControl?: boolean }).globalPrivacyControl;
} else {
Object.defineProperty(navigator, 'globalPrivacyControl', {
value,
writable: true,
configurable: true,
});
}
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('GPC Signal Detection', () => {
afterEach(() => {
setGpc(undefined);
});
// ── isGpcEnabled ──────────────────────────────────────────────────
describe('isGpcEnabled', () => {
it('returns true when navigator.globalPrivacyControl is true', () => {
setGpc(true);
expect(isGpcEnabled()).toBe(true);
});
it('returns false when navigator.globalPrivacyControl is false', () => {
setGpc(false);
expect(isGpcEnabled()).toBe(false);
});
it('returns false when navigator.globalPrivacyControl is undefined', () => {
setGpc(undefined);
expect(isGpcEnabled()).toBe(false);
});
});
// ── shouldHonourGpc ───────────────────────────────────────────────
describe('shouldHonourGpc', () => {
it('returns false when gpc_enabled is false', () => {
const config = makeGpcConfig({ gpc_enabled: false });
expect(shouldHonourGpc('US-CA', config)).toBe(false);
});
it('returns true when gpc_global_honour is true regardless of region', () => {
const config = makeGpcConfig({ gpc_global_honour: true });
expect(shouldHonourGpc('DE', config)).toBe(true);
expect(shouldHonourGpc(null, config)).toBe(true);
});
it('returns true for default jurisdiction US-CA', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-CA', config)).toBe(true);
});
it('returns true for default jurisdiction US-CO', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-CO', config)).toBe(true);
});
it('returns true for default jurisdiction US-CT', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-CT', config)).toBe(true);
});
it('returns true for default jurisdiction US-TX', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-TX', config)).toBe(true);
});
it('returns true for default jurisdiction US-MT', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-MT', config)).toBe(true);
});
it('returns false for non-GPC jurisdiction', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-NY', config)).toBe(false);
});
it('returns false for null region', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc(null, config)).toBe(false);
});
it('uses custom jurisdictions when provided', () => {
const config = makeGpcConfig({
gpc_jurisdictions: ['GB', 'DE'],
});
expect(shouldHonourGpc('GB', config)).toBe(true);
expect(shouldHonourGpc('DE', config)).toBe(true);
expect(shouldHonourGpc('US-CA', config)).toBe(false);
});
});
// ── evaluateGpc ───────────────────────────────────────────────────
describe('evaluateGpc', () => {
it('returns detected=false when GPC is not set', () => {
setGpc(undefined);
const config = makeGpcConfig();
const result = evaluateGpc(config, 'US-CA');
expect(result.detected).toBe(false);
expect(result.honoured).toBe(false);
});
it('returns detected=true, honoured=true in California', () => {
setGpc(true);
const config = makeGpcConfig();
const result = evaluateGpc(config, 'US-CA');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(true);
expect(result.region).toBe('US-CA');
});
it('returns detected=true, honoured=false in non-GPC state', () => {
setGpc(true);
const config = makeGpcConfig();
const result = evaluateGpc(config, 'US-NY');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(false);
});
it('returns detected=true, honoured=false when gpc_enabled is false', () => {
setGpc(true);
const config = makeGpcConfig({ gpc_enabled: false });
const result = evaluateGpc(config, 'US-CA');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(false);
});
it('returns honoured=true with gpc_global_honour regardless of region', () => {
setGpc(true);
const config = makeGpcConfig({ gpc_global_honour: true });
const result = evaluateGpc(config, 'FR');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(true);
});
it('passes null region through', () => {
setGpc(true);
const config = makeGpcConfig();
const result = evaluateGpc(config, null);
expect(result.region).toBeNull();
expect(result.honoured).toBe(false);
});
it('defaults region to null when not provided', () => {
setGpc(true);
const config = makeGpcConfig({ gpc_global_honour: true });
const result = evaluateGpc(config);
expect(result.region).toBeNull();
expect(result.honoured).toBe(true);
});
});
// ── Constants ─────────────────────────────────────────────────────
describe('constants', () => {
it('DEFAULT_GPC_JURISDICTIONS includes all five required states', () => {
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CA');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CO');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CT');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-TX');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-MT');
expect(DEFAULT_GPC_JURISDICTIONS).toHaveLength(5);
});
it('GPC_OPTOUT_CATEGORIES includes marketing and personalisation', () => {
expect(GPC_OPTOUT_CATEGORIES).toContain('marketing');
expect(GPC_OPTOUT_CATEGORIES).toContain('personalisation');
});
it('GPC_ACCEPTED_CATEGORIES includes necessary, functional, analytics', () => {
expect(GPC_ACCEPTED_CATEGORIES).toContain('necessary');
expect(GPC_ACCEPTED_CATEGORIES).toContain('functional');
expect(GPC_ACCEPTED_CATEGORIES).toContain('analytics');
});
it('GPC opt-out and accepted categories cover all categories', () => {
const all = [...GPC_ACCEPTED_CATEGORIES, ...GPC_OPTOUT_CATEGORIES];
expect(all).toContain('necessary');
expect(all).toContain('functional');
expect(all).toContain('analytics');
expect(all).toContain('marketing');
expect(all).toContain('personalisation');
});
});
});

View File

@@ -0,0 +1,501 @@
/**
* Tests for the GPP CMP API (__gpp() global function).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
installGppApi,
removeGppApi,
updateGppConsent,
setGppDisplayStatus,
setGppSignalStatus,
getGppString,
isGppApiInstalled,
type GppPingReturn,
type GppData,
type GppEventData,
type GppApiCallback,
} from '../gpp-api';
import {
type GppString,
createDefaultSectionData,
US_NATIONAL,
US_CALIFORNIA,
SECTION_REGISTRY,
} from '../gpp';
// Section IDs (US_NATIONAL and US_CALIFORNIA are SectionDef objects, not numbers)
const US_NATIONAL_ID = US_NATIONAL.id; // 7
const US_CALIFORNIA_ID = US_CALIFORNIA.id; // 8
// ── Helpers ───────────────────────────────────────────────────────────
/** Call __gpp and return the callback args as a promise. */
function callGpp(
command: string,
parameter?: unknown,
): Promise<{ data: unknown; success: boolean }> {
return new Promise((resolve) => {
window.__gpp!(command, (data: unknown, success: boolean) => {
resolve({ data, success });
}, parameter);
});
}
/** Build a minimal GppString for testing. */
function buildTestGpp(sectionIds: number[] = [US_NATIONAL_ID]): GppString {
const sections = new Map<number, { data: Record<string, number | number[]> }>();
for (const id of sectionIds) {
const def = SECTION_REGISTRY.get(id);
if (def) {
const data = createDefaultSectionData(def);
sections.set(id, { data });
}
}
return {
header: {
version: 1,
sectionIds,
applicableSections: sectionIds,
},
sections,
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('GPP CMP API', () => {
beforeEach(() => {
removeGppApi();
});
afterEach(() => {
removeGppApi();
});
// ── Installation ──────────────────────────────────────────────────
describe('installGppApi / removeGppApi', () => {
it('installs __gpp on window', () => {
expect(window.__gpp).toBeUndefined();
installGppApi(42, ['usnat']);
expect(typeof window.__gpp).toBe('function');
expect(isGppApiInstalled()).toBe(true);
});
it('removes __gpp from window', () => {
installGppApi(42);
removeGppApi();
expect(window.__gpp).toBeUndefined();
expect(isGppApiInstalled()).toBe(false);
});
it('clears __gppQueue on remove', () => {
(window as { __gppQueue?: unknown[] }).__gppQueue = [['ping', vi.fn()]];
installGppApi(42);
removeGppApi();
expect((window as { __gppQueue?: unknown[] }).__gppQueue).toBeUndefined();
});
});
// ── ping command ──────────────────────────────────────────────────
describe('ping command', () => {
it('returns CMP metadata', async () => {
installGppApi(42, ['usnat', 'usca']);
const { data, success } = await callGpp('ping');
expect(success).toBe(true);
const ping = data as GppPingReturn;
expect(ping.gppVersion).toBe('1.1');
expect(ping.cmpStatus).toBe('loaded');
expect(ping.cmpDisplayStatus).toBe('hidden');
expect(ping.signalStatus).toBe('not ready');
expect(ping.supportedAPIs).toEqual(['usnat', 'usca']);
expect(ping.cmpId).toBe(42);
expect(ping.gppString).toBe('');
expect(ping.applicableSections).toEqual([]);
});
it('reflects updated display status', async () => {
installGppApi(1);
setGppDisplayStatus('visible');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).cmpDisplayStatus).toBe('visible');
});
it('reflects updated signal status', async () => {
installGppApi(1);
setGppSignalStatus('ready');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).signalStatus).toBe('ready');
});
it('includes GPP string after consent update', async () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
const encoded = updateGppConsent(gpp);
expect(encoded).toBeTruthy();
const { data } = await callGpp('ping');
expect((data as GppPingReturn).gppString).toBe(encoded);
expect((data as GppPingReturn).applicableSections).toEqual([US_NATIONAL_ID]);
});
});
// ── getGPPData command ────────────────────────────────────────────
describe('getGPPData command', () => {
it('returns empty data before consent', async () => {
installGppApi(1);
const { data, success } = await callGpp('getGPPData');
expect(success).toBe(true);
const gppData = data as GppData;
expect(gppData.gppString).toBe('');
expect(gppData.applicableSections).toEqual([]);
expect(gppData.parsedSections).toEqual({});
});
it('returns populated data after consent update', async () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
updateGppConsent(gpp);
const { data } = await callGpp('getGPPData');
const gppData = data as GppData;
expect(gppData.gppString).toBeTruthy();
expect(gppData.applicableSections).toEqual([US_NATIONAL_ID]);
expect(gppData.parsedSections[US_NATIONAL_ID]).toBeDefined();
});
});
// ── getSection command ────────────────────────────────────────────
describe('getSection command', () => {
it('returns null for missing prefix', async () => {
installGppApi(1);
const { data, success } = await callGpp('getSection', 'usnat');
expect(success).toBe(false);
expect(data).toBeNull();
});
it('returns null when no parameter given', async () => {
installGppApi(1);
const { data, success } = await callGpp('getSection');
expect(success).toBe(false);
expect(data).toBeNull();
});
it('returns section data by API prefix', async () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
updateGppConsent(gpp);
const { data, success } = await callGpp('getSection', 'usnat');
expect(success).toBe(true);
expect(data).toBeDefined();
expect((data as Record<string, unknown>).Version).toBeDefined();
});
it('returns null for unregistered prefix', async () => {
installGppApi(1);
updateGppConsent(buildTestGpp());
const { data, success } = await callGpp('getSection', 'nonexistent');
expect(success).toBe(false);
expect(data).toBeNull();
});
});
// ── hasSection command ────────────────────────────────────────────
describe('hasSection command', () => {
it('returns false when section not present', async () => {
installGppApi(1);
const { data, success } = await callGpp('hasSection', 'usnat');
expect(success).toBe(true);
expect(data).toBe(false);
});
it('returns true when section present', async () => {
installGppApi(1, ['usnat']);
updateGppConsent(buildTestGpp());
const { data, success } = await callGpp('hasSection', 'usnat');
expect(success).toBe(true);
expect(data).toBe(true);
});
it('returns false for empty parameter', async () => {
installGppApi(1);
const { data, success } = await callGpp('hasSection');
expect(success).toBe(true);
expect(data).toBe(false);
});
});
// ── addEventListener / removeEventListener ────────────────────────
describe('addEventListener', () => {
it('registers a listener and fires immediately', async () => {
installGppApi(1, ['usnat']);
const { data, success } = await callGpp('addEventListener');
expect(success).toBe(true);
const event = data as GppEventData;
expect(event.eventName).toBe('listenerRegistered');
expect(event.listenerId).toBe(1);
expect(event.pingData.cmpStatus).toBe('loaded');
});
it('assigns unique listener IDs', async () => {
installGppApi(1);
const { data: d1 } = await callGpp('addEventListener');
const { data: d2 } = await callGpp('addEventListener');
expect((d1 as GppEventData).listenerId).toBe(1);
expect((d2 as GppEventData).listenerId).toBe(2);
});
it('notifies listeners on consent update', () => {
installGppApi(1, ['usnat']);
const events: GppEventData[] = [];
window.__gpp!('addEventListener', (data: unknown) => {
events.push(data as GppEventData);
});
// First event is listenerRegistered
expect(events).toHaveLength(1);
expect(events[0].eventName).toBe('listenerRegistered');
// Update consent — should fire signalStatus event
updateGppConsent(buildTestGpp());
expect(events).toHaveLength(2);
expect(events[1].eventName).toBe('signalStatus');
expect(events[1].data).toBeDefined();
expect((events[1].data as GppData).gppString).toBeTruthy();
});
it('swallows errors thrown by listeners', () => {
installGppApi(1);
const throwingCallback: GppApiCallback = () => {
throw new Error('Listener error');
};
// Should not throw
expect(() => {
window.__gpp!('addEventListener', throwingCallback);
}).not.toThrow();
// Should not throw during consent update either
expect(() => {
updateGppConsent(buildTestGpp());
}).not.toThrow();
});
});
describe('removeEventListener', () => {
it('removes an existing listener', async () => {
installGppApi(1);
// Add listener
const { data } = await callGpp('addEventListener');
const listenerId = (data as GppEventData).listenerId;
// Remove it
const { data: removed, success } = await callGpp('removeEventListener', listenerId);
expect(success).toBe(true);
expect(removed).toBe(true);
});
it('returns false for non-existent listener', async () => {
installGppApi(1);
const { data, success } = await callGpp('removeEventListener', 999);
expect(success).toBe(false);
expect(data).toBe(false);
});
it('stops notifications after removal', () => {
installGppApi(1, ['usnat']);
const events: GppEventData[] = [];
let listenerId: number;
window.__gpp!('addEventListener', (data: unknown) => {
const event = data as GppEventData;
events.push(event);
listenerId = event.listenerId;
});
// Remove the listener
window.__gpp!('removeEventListener', (_d: unknown, _s: boolean) => {}, listenerId!);
// Update consent — should NOT fire to removed listener
const eventsBefore = events.length;
updateGppConsent(buildTestGpp());
expect(events.length).toBe(eventsBefore);
});
});
// ── Unknown commands ──────────────────────────────────────────────
describe('unknown commands', () => {
it('returns false for unknown commands', async () => {
installGppApi(1);
const { data, success } = await callGpp('unknownCommand');
expect(success).toBe(false);
expect(data).toBe(false);
});
});
// ── Uninitialised state ───────────────────────────────────────────
describe('uninitialised state', () => {
it('returns false when API not installed', () => {
// Manually set __gpp to the handler without initialising state
// by calling removeGppApi first to clear state, then manually assigning
removeGppApi();
// Directly import and call the handler — simulate calling before install
let callbackData: unknown;
let callbackSuccess: boolean | undefined;
// We can't call __gpp because it's removed, but we can test via getGppString
expect(getGppString()).toBe('');
expect(isGppApiInstalled()).toBe(false);
});
});
// ── updateGppConsent ──────────────────────────────────────────────
describe('updateGppConsent', () => {
it('returns the encoded GPP string', () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
const result = updateGppConsent(gpp);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
// GPP strings start with 'D' (header prefix)
expect(result.startsWith('D')).toBe(true);
});
it('updates the gppString accessible via getGppString', () => {
installGppApi(1);
expect(getGppString()).toBe('');
updateGppConsent(buildTestGpp());
expect(getGppString()).toBeTruthy();
});
it('sets signalStatus to ready', async () => {
installGppApi(1);
updateGppConsent(buildTestGpp());
const { data } = await callGpp('ping');
expect((data as GppPingReturn).signalStatus).toBe('ready');
});
it('works with multiple sections', () => {
installGppApi(1, ['usnat', 'usca']);
const gpp = buildTestGpp([US_NATIONAL_ID, US_CALIFORNIA_ID]);
const result = updateGppConsent(gpp);
expect(result).toBeTruthy();
// Should contain ~ separator for sections
expect(result).toContain('~');
});
});
// ── Queue processing ──────────────────────────────────────────────
describe('queue processing', () => {
it('processes queued calls on install', () => {
// Set up a queue before installing
const results: Array<{ data: unknown; success: boolean }> = [];
const cb = (data: unknown, success: boolean) => {
results.push({ data, success });
};
(window as { __gppQueue?: unknown[] }).__gppQueue = [
['ping', cb],
];
installGppApi(1, ['usnat']);
// The queued ping should have been processed
expect(results).toHaveLength(1);
expect(results[0].success).toBe(true);
expect((results[0].data as GppPingReturn).cmpStatus).toBe('loaded');
});
it('clears the queue after processing', () => {
(window as { __gppQueue?: unknown[] }).__gppQueue = [
['ping', vi.fn()],
];
installGppApi(1);
expect((window as { __gppQueue?: unknown[] }).__gppQueue).toEqual([]);
});
});
// ── Coexistence with __tcfapi ─────────────────────────────────────
describe('coexistence', () => {
it('does not interfere with other window globals', () => {
// Set a mock __tcfapi
const mockTcf = vi.fn();
(window as { __tcfapi?: unknown }).__tcfapi = mockTcf;
installGppApi(1);
// __tcfapi should still be intact
expect((window as { __tcfapi?: unknown }).__tcfapi).toBe(mockTcf);
removeGppApi();
// __tcfapi should still be intact after removal
expect((window as { __tcfapi?: unknown }).__tcfapi).toBe(mockTcf);
// Clean up
delete (window as { __tcfapi?: unknown }).__tcfapi;
});
});
// ── setGppDisplayStatus / setGppSignalStatus ──────────────────────
describe('status setters', () => {
it('setGppDisplayStatus updates display status', async () => {
installGppApi(1);
setGppDisplayStatus('visible');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).cmpDisplayStatus).toBe('visible');
setGppDisplayStatus('disabled');
const { data: d2 } = await callGpp('ping');
expect((d2 as GppPingReturn).cmpDisplayStatus).toBe('disabled');
});
it('setGppSignalStatus updates signal status', async () => {
installGppApi(1);
setGppSignalStatus('ready');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).signalStatus).toBe('ready');
});
it('no-ops when API not installed', () => {
// Should not throw
expect(() => setGppDisplayStatus('visible')).not.toThrow();
expect(() => setGppSignalStatus('ready')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,926 @@
/**
* Tests for the IAB GPP string encoder/decoder.
*/
import { describe, it, expect } from 'vitest';
import { BitWriter, BitReader, bytesToBase64url } from '../tcf';
import {
fibonacciEncode,
fibonacciDecode,
encodeGppHeader,
decodeGppHeader,
encodeSectionCore,
decodeSectionCore,
encodeGpcSubsection,
decodeGpcSubsection,
encodeSection,
decodeSection,
encodeGppString,
decodeGppString,
createDefaultSectionData,
getSectionByPrefix,
registerSection,
US_NATIONAL,
US_CALIFORNIA,
US_VIRGINIA,
US_COLORADO,
US_CONNECTICUT,
US_FLORIDA,
SECTION_REGISTRY,
GppFieldValue,
type SectionDef,
type SectionData,
type GppHeader,
type GppString,
} from '../gpp';
// ── Fibonacci coding ─────────────────────────────────────────────────
describe('Fibonacci coding', () => {
it('encodes and decodes 1', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 1);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(1);
});
it('encodes and decodes 2', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 2);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(2);
});
it('encodes and decodes small integers (120)', () => {
for (let n = 1; n <= 20; n++) {
const writer = new BitWriter();
fibonacciEncode(writer, n);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(n);
}
});
it('encodes and decodes larger integers', () => {
const values = [21, 34, 55, 100, 233, 500, 987, 1000];
for (const n of values) {
const writer = new BitWriter();
fibonacciEncode(writer, n);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(n);
}
});
it('encodes multiple integers sequentially', () => {
const values = [7, 3, 11, 1, 8];
const writer = new BitWriter();
for (const v of values) {
fibonacciEncode(writer, v);
}
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
for (const v of values) {
expect(fibonacciDecode(reader)).toBe(v);
}
});
it('throws on zero or negative values', () => {
const writer = new BitWriter();
expect(() => fibonacciEncode(writer, 0)).toThrow('positive integer');
expect(() => fibonacciEncode(writer, -1)).toThrow('positive integer');
});
it('produces correct bit pattern for value 1 (11)', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 1);
// Value 1: position 0 set (F(2)=1), then terminator → bits: 1,1
const bytes = writer.toBytes();
// First byte: 11xxxxxx → 0b11000000 = 192
expect(bytes[0] & 0xc0).toBe(0xc0);
});
it('produces correct bit pattern for value 2 (011)', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 2);
// Value 2: position 1 set (F(3)=2), then terminator → bits: 0,1,1
const bytes = writer.toBytes();
// First byte: 011xxxxx → 0b01100000 = 96
expect(bytes[0] & 0xe0).toBe(0x60);
});
it('produces correct bit pattern for value 4 (1011)', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 4);
// Value 4 = F(2)+F(4) = 1+3: positions 0,2 set, then terminator → bits: 1,0,1,1
const bytes = writer.toBytes();
// First byte: 1011xxxx → 0b10110000 = 176
expect(bytes[0] & 0xf0).toBe(0xb0);
});
});
// ── GPP Header ───────────────────────────────────────────────────────
describe('GPP header', () => {
it('encodes and decodes an empty header', () => {
const header: GppHeader = {
version: 1,
sectionIds: [],
applicableSections: [],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.version).toBe(1);
expect(decoded.sectionIds).toEqual([]);
expect(decoded.applicableSections).toEqual([]);
});
it('encodes and decodes a header with a single section', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7],
applicableSections: [7],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.version).toBe(1);
expect(decoded.sectionIds).toEqual([7]);
expect(decoded.applicableSections).toEqual([7]);
});
it('encodes and decodes a header with multiple sections', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 10],
applicableSections: [7, 8],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.version).toBe(1);
expect(decoded.sectionIds).toEqual([7, 8, 10]);
expect(decoded.applicableSections).toEqual([7, 8]);
});
it('encodes consecutive section IDs as ranges', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 9, 10, 11],
applicableSections: [7, 8, 9, 10, 11],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.sectionIds).toEqual([7, 8, 9, 10, 11]);
expect(decoded.applicableSections).toEqual([7, 8, 9, 10, 11]);
});
it('handles mixed consecutive and non-consecutive IDs', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 9, 11, 14],
applicableSections: [7],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.sectionIds).toEqual([7, 8, 9, 11, 14]);
expect(decoded.applicableSections).toEqual([7]);
});
it('throws on invalid header type', () => {
// Craft a header with type = 0 instead of 3
const writer = new BitWriter();
writer.writeInt(0, 6); // Wrong type
writer.writeInt(1, 6);
writer.writeInt(0, 6);
writer.writeInt(0, 6);
const encoded = bytesToBase64url(writer.toBytes());
expect(() => decodeGppHeader(encoded)).toThrow('Invalid GPP header type');
});
it('round-trips: encode → decode → re-encode produces identical output', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 11, 14],
applicableSections: [7, 8],
};
const encoded1 = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded1);
const encoded2 = encodeGppHeader(decoded);
expect(encoded2).toBe(encoded1);
});
});
// ── Section encoding/decoding ────────────────────────────────────────
describe('US National section (Section 7)', () => {
it('encodes and decodes default (all-zero) data', () => {
const data = createDefaultSectionData(US_NATIONAL);
const encoded = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SharingNotice).toBe(0);
expect(decoded.SaleOptOut).toBe(0);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(12).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0]);
});
it('encodes and decodes populated data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 1,
SaleOptOutNotice: 1,
SharingOptOutNotice: 1,
TargetedAdvertisingOptOutNotice: 1,
SensitiveDataProcessingOptOutNotice: 1,
SensitiveDataLimitUseNotice: 1,
SaleOptOut: 2,
SharingOptOut: 1,
TargetedAdvertisingOptOut: 2,
SensitiveDataProcessing: [1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0],
KnownChildSensitiveDataConsents: [1, 2],
PersonalDataConsents: 0,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 1,
MspaServiceProviderMode: 0,
};
const encoded = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SharingNotice).toBe(1);
expect(decoded.SaleOptOut).toBe(2);
expect(decoded.SharingOptOut).toBe(1);
expect(decoded.TargetedAdvertisingOptOut).toBe(2);
expect(decoded.SensitiveDataProcessing).toEqual([1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0]);
expect(decoded.KnownChildSensitiveDataConsents).toEqual([1, 2]);
expect(decoded.MspaCoveredTransaction).toBe(1);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 2,
SaleOptOutNotice: 1,
SharingOptOutNotice: 2,
TargetedAdvertisingOptOutNotice: 1,
SensitiveDataProcessingOptOutNotice: 2,
SensitiveDataLimitUseNotice: 1,
SaleOptOut: 1,
SharingOptOut: 2,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1],
KnownChildSensitiveDataConsents: [2, 1],
PersonalDataConsents: 2,
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 2,
MspaServiceProviderMode: 2,
};
const encoded1 = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded1);
const encoded2 = encodeSectionCore(US_NATIONAL, decoded);
expect(encoded2).toBe(encoded1);
});
});
describe('US California section (Section 8)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_CALIFORNIA);
const encoded = encodeSectionCore(US_CALIFORNIA, data);
const decoded = decodeSectionCore(US_CALIFORNIA, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SaleOptOutNotice).toBe(0);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(9).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0]);
});
it('encodes and decodes populated data', () => {
const data: SectionData = {
Version: 1,
SaleOptOutNotice: 1,
SharingOptOutNotice: 1,
SensitiveDataLimitUseNotice: 2,
SaleOptOut: 1,
SharingOptOut: 1,
SensitiveDataProcessing: [1, 1, 2, 2, 0, 0, 1, 1, 2],
KnownChildSensitiveDataConsents: [1, 0],
PersonalDataConsents: 1,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 0,
MspaServiceProviderMode: 2,
};
const encoded = encodeSectionCore(US_CALIFORNIA, data);
const decoded = decodeSectionCore(US_CALIFORNIA, encoded);
expect(decoded.SaleOptOut).toBe(1);
expect(decoded.SharingOptOut).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual([1, 1, 2, 2, 0, 0, 1, 1, 2]);
expect(decoded.MspaServiceProviderMode).toBe(2);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SaleOptOutNotice: 2,
SharingOptOutNotice: 1,
SensitiveDataLimitUseNotice: 1,
SaleOptOut: 2,
SharingOptOut: 2,
SensitiveDataProcessing: [2, 2, 1, 1, 0, 0, 2, 2, 1],
KnownChildSensitiveDataConsents: [2, 2],
PersonalDataConsents: 2,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 1,
MspaServiceProviderMode: 1,
};
const e1 = encodeSectionCore(US_CALIFORNIA, data);
const d = decodeSectionCore(US_CALIFORNIA, e1);
const e2 = encodeSectionCore(US_CALIFORNIA, d);
expect(e2).toBe(e1);
});
});
describe('US Virginia section (Section 9)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_VIRGINIA);
const encoded = encodeSectionCore(US_VIRGINIA, data);
const decoded = decodeSectionCore(US_VIRGINIA, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toBe(0);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 1,
SaleOptOutNotice: 2,
TargetedAdvertisingOptOutNotice: 1,
SaleOptOut: 2,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [1, 2, 1, 2, 1, 2, 1, 2],
KnownChildSensitiveDataConsents: 1,
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 1,
MspaServiceProviderMode: 2,
};
const e1 = encodeSectionCore(US_VIRGINIA, data);
const d = decodeSectionCore(US_VIRGINIA, e1);
const e2 = encodeSectionCore(US_VIRGINIA, d);
expect(e2).toBe(e1);
});
it('does not support GPC sub-section', () => {
expect(US_VIRGINIA.hasGpcSubsection).toBe(false);
});
});
describe('US Colorado section (Section 10)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_COLORADO);
const encoded = encodeSectionCore(US_COLORADO, data);
const decoded = decodeSectionCore(US_COLORADO, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(7).fill(0));
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 2,
SaleOptOutNotice: 1,
TargetedAdvertisingOptOutNotice: 2,
SaleOptOut: 1,
TargetedAdvertisingOptOut: 2,
SensitiveDataProcessing: [2, 1, 0, 2, 1, 0, 2],
KnownChildSensitiveDataConsents: 1,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 2,
MspaServiceProviderMode: 0,
};
const e1 = encodeSectionCore(US_COLORADO, data);
const d = decodeSectionCore(US_COLORADO, e1);
const e2 = encodeSectionCore(US_COLORADO, d);
expect(e2).toBe(e1);
});
it('supports GPC sub-section', () => {
expect(US_COLORADO.hasGpcSubsection).toBe(true);
});
});
describe('US Connecticut section (Section 11)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_CONNECTICUT);
const encoded = encodeSectionCore(US_CONNECTICUT, data);
const decoded = decodeSectionCore(US_CONNECTICUT, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0, 0]);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 1,
SaleOptOutNotice: 1,
TargetedAdvertisingOptOutNotice: 1,
SaleOptOut: 1,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [1, 1, 1, 1, 1, 1, 1, 1],
KnownChildSensitiveDataConsents: [1, 2, 1],
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 0,
MspaServiceProviderMode: 0,
};
const e1 = encodeSectionCore(US_CONNECTICUT, data);
const d = decodeSectionCore(US_CONNECTICUT, e1);
const e2 = encodeSectionCore(US_CONNECTICUT, d);
expect(e2).toBe(e1);
});
});
describe('US Florida section (Section 14)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_FLORIDA);
const encoded = encodeSectionCore(US_FLORIDA, data);
const decoded = decodeSectionCore(US_FLORIDA, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0, 0]);
expect(decoded.PersonalDataConsents).toBe(0);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 2,
SaleOptOutNotice: 2,
TargetedAdvertisingOptOutNotice: 2,
SaleOptOut: 2,
TargetedAdvertisingOptOut: 2,
SensitiveDataProcessing: [2, 2, 2, 2, 2, 2, 2, 2],
KnownChildSensitiveDataConsents: [2, 2, 2],
PersonalDataConsents: 2,
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 2,
MspaServiceProviderMode: 2,
};
const e1 = encodeSectionCore(US_FLORIDA, data);
const d = decodeSectionCore(US_FLORIDA, e1);
const e2 = encodeSectionCore(US_FLORIDA, d);
expect(e2).toBe(e1);
});
it('supports GPC sub-section', () => {
expect(US_FLORIDA.hasGpcSubsection).toBe(true);
});
});
// ── GPC sub-section ──────────────────────────────────────────────────
describe('GPC sub-section', () => {
it('encodes and decodes gpc=true', () => {
const encoded = encodeGpcSubsection({ gpc: true });
const decoded = decodeGpcSubsection(encoded);
expect(decoded.gpc).toBe(true);
});
it('encodes and decodes gpc=false', () => {
const encoded = encodeGpcSubsection({ gpc: false });
const decoded = decodeGpcSubsection(encoded);
expect(decoded.gpc).toBe(false);
});
it('round-trips: encode → decode → re-encode is identical', () => {
const gpc = { gpc: true };
const e1 = encodeGpcSubsection(gpc);
const d = decodeGpcSubsection(e1);
const e2 = encodeGpcSubsection(d);
expect(e2).toBe(e1);
});
});
// ── Full section with GPC sub-section ────────────────────────────────
describe('encodeSection / decodeSection (core + GPC)', () => {
it('encodes section without GPC when not provided', () => {
const data = createDefaultSectionData(US_NATIONAL);
const encoded = encodeSection(US_NATIONAL, data);
expect(encoded).not.toContain('.');
});
it('encodes section with GPC sub-section', () => {
const data = createDefaultSectionData(US_NATIONAL);
const encoded = encodeSection(US_NATIONAL, data, { gpc: true });
expect(encoded).toContain('.');
});
it('decodes section with GPC sub-section', () => {
const data: SectionData = {
...createDefaultSectionData(US_NATIONAL),
SaleOptOut: 1,
SharingOptOut: 1,
};
const encoded = encodeSection(US_NATIONAL, data, { gpc: true });
const { data: decoded, gpcSubsection } = decodeSection(US_NATIONAL, encoded);
expect(decoded.SaleOptOut).toBe(1);
expect(decoded.SharingOptOut).toBe(1);
expect(gpcSubsection).toBeDefined();
expect(gpcSubsection!.gpc).toBe(true);
});
it('ignores GPC sub-section for sections that do not support it', () => {
const data = createDefaultSectionData(US_VIRGINIA);
const encoded = encodeSection(US_VIRGINIA, data, { gpc: true });
// Virginia doesn't support GPC — should not append sub-section
expect(encoded).not.toContain('.');
const { gpcSubsection } = decodeSection(US_VIRGINIA, encoded);
expect(gpcSubsection).toBeUndefined();
});
it('round-trips section with GPC', () => {
const data: SectionData = {
...createDefaultSectionData(US_CALIFORNIA),
SaleOptOut: 2,
SharingOptOut: 1,
};
const gpc = { gpc: false };
const e1 = encodeSection(US_CALIFORNIA, data, gpc);
const { data: d, gpcSubsection } = decodeSection(US_CALIFORNIA, e1);
const e2 = encodeSection(US_CALIFORNIA, d, gpcSubsection);
expect(e2).toBe(e1);
});
});
// ── Full GPP string ──────────────────────────────────────────────────
describe('full GPP string encoding/decoding', () => {
it('encodes and decodes a single US National section', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7],
applicableSections: [7],
},
sections: new Map([
[7, { data: createDefaultSectionData(US_NATIONAL), gpcSubsection: { gpc: false } }],
]),
};
const encoded = encodeGppString(gpp);
expect(encoded).toContain('~');
const decoded = decodeGppString(encoded);
expect(decoded.header.sectionIds).toEqual([7]);
expect(decoded.sections.has(7)).toBe(true);
expect(decoded.sections.get(7)!.data.Version).toBe(1);
expect(decoded.sections.get(7)!.gpcSubsection?.gpc).toBe(false);
});
it('encodes and decodes multiple sections', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7, 8, 10],
applicableSections: [8],
},
sections: new Map([
[7, {
data: {
...createDefaultSectionData(US_NATIONAL),
SaleOptOut: 1,
SharingOptOut: 1,
},
gpcSubsection: { gpc: true },
}],
[8, {
data: {
...createDefaultSectionData(US_CALIFORNIA),
SaleOptOut: 1,
},
gpcSubsection: { gpc: true },
}],
[10, {
data: {
...createDefaultSectionData(US_COLORADO),
TargetedAdvertisingOptOut: 2,
},
gpcSubsection: { gpc: false },
}],
]),
};
const encoded = encodeGppString(gpp);
const parts = encoded.split('~');
expect(parts.length).toBe(4); // header + 3 sections
const decoded = decodeGppString(encoded);
expect(decoded.header.sectionIds).toEqual([7, 8, 10]);
expect(decoded.header.applicableSections).toEqual([8]);
expect(decoded.sections.get(7)!.data.SaleOptOut).toBe(1);
expect(decoded.sections.get(7)!.gpcSubsection?.gpc).toBe(true);
expect(decoded.sections.get(8)!.data.SaleOptOut).toBe(1);
expect(decoded.sections.get(10)!.data.TargetedAdvertisingOptOut).toBe(2);
});
it('round-trips a complex GPP string', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7, 8, 9, 10, 11, 14],
applicableSections: [7, 8],
},
sections: new Map([
[7, {
data: {
...createDefaultSectionData(US_NATIONAL),
SharingNotice: 1,
SaleOptOutNotice: 1,
SaleOptOut: 1,
SharingOptOut: 1,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2],
KnownChildSensitiveDataConsents: [0, 0],
MspaCoveredTransaction: 1,
},
gpcSubsection: { gpc: true },
}],
[8, {
data: {
...createDefaultSectionData(US_CALIFORNIA),
SaleOptOut: 1,
SharingOptOut: 1,
SensitiveDataProcessing: [2, 2, 2, 2, 2, 2, 2, 2, 2],
},
gpcSubsection: { gpc: true },
}],
[9, {
data: {
...createDefaultSectionData(US_VIRGINIA),
SaleOptOut: 2,
},
}],
[10, {
data: createDefaultSectionData(US_COLORADO),
gpcSubsection: { gpc: false },
}],
[11, {
data: createDefaultSectionData(US_CONNECTICUT),
gpcSubsection: { gpc: false },
}],
[14, {
data: createDefaultSectionData(US_FLORIDA),
gpcSubsection: { gpc: true },
}],
]),
};
const encoded1 = encodeGppString(gpp);
const decoded = decodeGppString(encoded1);
const encoded2 = encodeGppString(decoded);
expect(encoded2).toBe(encoded1);
});
it('throws when section data is missing', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7, 8],
applicableSections: [],
},
sections: new Map([
[7, { data: createDefaultSectionData(US_NATIONAL) }],
// Section 8 intentionally missing
]),
};
expect(() => encodeGppString(gpp)).toThrow('No data or definition for GPP section 8');
});
it('throws when section payload is missing during decode', () => {
// Manually craft a string with a header claiming 2 sections but only 1 payload
const header: GppHeader = {
version: 1,
sectionIds: [7, 8],
applicableSections: [],
};
const headerStr = encodeGppHeader(header);
const sectionStr = encodeSectionCore(US_NATIONAL, createDefaultSectionData(US_NATIONAL));
const gppStr = `${headerStr}~${sectionStr}`;
// Missing section 8 payload
expect(() => decodeGppString(gppStr)).toThrow('Missing payload for GPP section 8');
});
});
// ── Convenience helpers ──────────────────────────────────────────────
describe('createDefaultSectionData', () => {
it('creates default data with correct Version for each section', () => {
for (const def of SECTION_REGISTRY.values()) {
const data = createDefaultSectionData(def);
expect(data.Version).toBe(def.version);
}
});
it('creates correct array sizes for US National', () => {
const data = createDefaultSectionData(US_NATIONAL);
expect((data.SensitiveDataProcessing as number[]).length).toBe(12);
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(2);
});
it('creates correct array sizes for US California', () => {
const data = createDefaultSectionData(US_CALIFORNIA);
expect((data.SensitiveDataProcessing as number[]).length).toBe(9);
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(2);
});
it('creates correct array sizes for US Connecticut', () => {
const data = createDefaultSectionData(US_CONNECTICUT);
expect((data.SensitiveDataProcessing as number[]).length).toBe(8);
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(3);
});
it('uses scalar for single-count fields', () => {
const data = createDefaultSectionData(US_VIRGINIA);
expect(typeof data.KnownChildSensitiveDataConsents).toBe('number');
});
});
describe('getSectionByPrefix', () => {
it('finds US National by prefix', () => {
expect(getSectionByPrefix('usnat')).toBe(US_NATIONAL);
});
it('finds US California by prefix', () => {
expect(getSectionByPrefix('usca')).toBe(US_CALIFORNIA);
});
it('finds US Virginia by prefix', () => {
expect(getSectionByPrefix('usva')).toBe(US_VIRGINIA);
});
it('finds US Colorado by prefix', () => {
expect(getSectionByPrefix('usco')).toBe(US_COLORADO);
});
it('finds US Connecticut by prefix', () => {
expect(getSectionByPrefix('usct')).toBe(US_CONNECTICUT);
});
it('finds US Florida by prefix', () => {
expect(getSectionByPrefix('usfl')).toBe(US_FLORIDA);
});
it('returns undefined for unknown prefix', () => {
expect(getSectionByPrefix('unknown')).toBeUndefined();
});
});
describe('registerSection', () => {
it('registers a custom section definition', () => {
const customDef: SectionDef = {
id: 99,
apiPrefix: 'custom',
version: 1,
coreFields: [
{ name: 'Version', bits: 6, count: 1 },
{ name: 'OptOut', bits: 2, count: 1 },
],
hasGpcSubsection: false,
};
registerSection(customDef);
expect(SECTION_REGISTRY.get(99)).toBe(customDef);
expect(getSectionByPrefix('custom')).toBe(customDef);
// Encode and decode using the custom section
const data: SectionData = { Version: 1, OptOut: 2 };
const encoded = encodeSectionCore(customDef, data);
const decoded = decodeSectionCore(customDef, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.OptOut).toBe(2);
// Clean up
SECTION_REGISTRY.delete(99);
});
});
// ── Section registry completeness ────────────────────────────────────
describe('section registry', () => {
it('contains all required sections', () => {
expect(SECTION_REGISTRY.has(7)).toBe(true); // US National
expect(SECTION_REGISTRY.has(8)).toBe(true); // US California
expect(SECTION_REGISTRY.has(9)).toBe(true); // US Virginia
expect(SECTION_REGISTRY.has(10)).toBe(true); // US Colorado
expect(SECTION_REGISTRY.has(11)).toBe(true); // US Connecticut
expect(SECTION_REGISTRY.has(14)).toBe(true); // US Florida
});
it('each section has a unique apiPrefix', () => {
const prefixes = new Set<string>();
for (const def of SECTION_REGISTRY.values()) {
expect(prefixes.has(def.apiPrefix)).toBe(false);
prefixes.add(def.apiPrefix);
}
});
it('each section has a unique id', () => {
const ids = new Set<number>();
for (const def of SECTION_REGISTRY.values()) {
expect(ids.has(def.id)).toBe(false);
ids.add(def.id);
}
});
});
// ── GppFieldValue constants ──────────────────────────────────────────
describe('GppFieldValue constants', () => {
it('has correct values', () => {
expect(GppFieldValue.NOT_APPLICABLE).toBe(0);
expect(GppFieldValue.YES).toBe(1);
expect(GppFieldValue.NO).toBe(2);
});
});
// ── Edge cases ───────────────────────────────────────────────────────
describe('edge cases', () => {
it('handles all-maximum field values for US National', () => {
const data: SectionData = {
Version: 63, // max for 6 bits
SharingNotice: 3, // max for 2 bits
SaleOptOutNotice: 3,
SharingOptOutNotice: 3,
TargetedAdvertisingOptOutNotice: 3,
SensitiveDataProcessingOptOutNotice: 3,
SensitiveDataLimitUseNotice: 3,
SaleOptOut: 3,
SharingOptOut: 3,
TargetedAdvertisingOptOut: 3,
SensitiveDataProcessing: new Array(12).fill(3),
KnownChildSensitiveDataConsents: [3, 3],
PersonalDataConsents: 3,
MspaCoveredTransaction: 3,
MspaOptOutOptionMode: 3,
MspaServiceProviderMode: 3,
};
const encoded = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded);
expect(decoded.Version).toBe(63);
expect(decoded.SharingNotice).toBe(3);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(12).fill(3));
});
it('handles a GPP string with all 6 registered sections', () => {
const allSectionIds = [7, 8, 9, 10, 11, 14];
const sections = new Map<number, { data: SectionData; gpcSubsection?: { gpc: boolean } }>();
for (const id of allSectionIds) {
const def = SECTION_REGISTRY.get(id)!;
sections.set(id, {
data: createDefaultSectionData(def),
gpcSubsection: def.hasGpcSubsection ? { gpc: true } : undefined,
});
}
const gpp: GppString = {
header: {
version: 1,
sectionIds: allSectionIds,
applicableSections: allSectionIds,
},
sections,
};
const encoded = encodeGppString(gpp);
const decoded = decodeGppString(encoded);
expect(decoded.header.sectionIds).toEqual(allSectionIds);
expect(decoded.sections.size).toBe(6);
for (const id of allSectionIds) {
expect(decoded.sections.has(id)).toBe(true);
const def = SECTION_REGISTRY.get(id)!;
if (def.hasGpcSubsection) {
expect(decoded.sections.get(id)!.gpcSubsection?.gpc).toBe(true);
}
}
});
});

View File

@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
DEFAULT_TRANSLATIONS,
detectLocale,
fetchTranslations,
interpolate,
loadTranslations,
normaliseLocale,
} from '../i18n';
describe('i18n', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('DEFAULT_TRANSLATIONS', () => {
it('should have all required keys', () => {
expect(DEFAULT_TRANSLATIONS.title).toBe('We use cookies');
expect(DEFAULT_TRANSLATIONS.acceptAll).toBe('Accept all');
expect(DEFAULT_TRANSLATIONS.rejectAll).toBe('Reject all');
expect(DEFAULT_TRANSLATIONS.managePreferences).toBe('Manage preferences');
expect(DEFAULT_TRANSLATIONS.savePreferences).toBe('Save preferences');
expect(DEFAULT_TRANSLATIONS.privacyPolicyLink).toBe('Privacy Policy');
expect(DEFAULT_TRANSLATIONS.closeLabel).toBe('Close');
});
it('should have all category translations', () => {
expect(DEFAULT_TRANSLATIONS.categoryNecessary).toBe('Necessary');
expect(DEFAULT_TRANSLATIONS.categoryFunctional).toBe('Functional');
expect(DEFAULT_TRANSLATIONS.categoryAnalytics).toBe('Analytics');
expect(DEFAULT_TRANSLATIONS.categoryMarketing).toBe('Marketing');
expect(DEFAULT_TRANSLATIONS.categoryPersonalisation).toBe('Personalisation');
});
it('should have category descriptions', () => {
expect(DEFAULT_TRANSLATIONS.categoryNecessaryDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryFunctionalDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryAnalyticsDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryMarketingDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryPersonalisationDesc).toBeTruthy();
});
it('should have cookie count template with placeholder', () => {
expect(DEFAULT_TRANSLATIONS.cookieCount).toContain('{{count}}');
});
});
describe('normaliseLocale', () => {
it('should extract language code from locale', () => {
expect(normaliseLocale('en-GB')).toBe('en');
expect(normaliseLocale('fr-FR')).toBe('fr');
expect(normaliseLocale('de-DE')).toBe('de');
});
it('should handle simple language codes', () => {
expect(normaliseLocale('en')).toBe('en');
expect(normaliseLocale('fr')).toBe('fr');
});
it('should lowercase the result', () => {
expect(normaliseLocale('EN-GB')).toBe('en');
expect(normaliseLocale('FR')).toBe('fr');
});
});
describe('detectLocale', () => {
it('should use data-locale attribute when present', () => {
const script = document.createElement('script');
script.setAttribute('data-site-id', 'site-1');
script.setAttribute('data-locale', 'fr-FR');
document.head.appendChild(script);
expect(detectLocale()).toBe('fr');
script.remove();
});
it('should fall back to navigator.language', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE');
expect(detectLocale()).toBe('de');
});
it('should fall back to document lang', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('');
document.documentElement.lang = 'es';
expect(detectLocale()).toBe('es');
document.documentElement.lang = '';
});
it('should default to en', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('');
document.documentElement.lang = '';
expect(detectLocale()).toBe('en');
});
});
describe('fetchTranslations', () => {
it('should fetch and parse translations', async () => {
const mockTranslations = { title: 'Nous utilisons des cookies' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTranslations),
}));
const result = await fetchTranslations('https://cdn.example.com', 'fr');
expect(fetch).toHaveBeenCalledWith('https://cdn.example.com/translations-fr.json');
expect(result).toEqual(mockTranslations);
vi.unstubAllGlobals();
});
it('should return null on 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
const result = await fetchTranslations('https://cdn.example.com', 'zz');
expect(result).toBeNull();
vi.unstubAllGlobals();
});
it('should return null on network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await fetchTranslations('https://cdn.example.com', 'fr');
expect(result).toBeNull();
vi.unstubAllGlobals();
});
});
describe('loadTranslations', () => {
it('should return defaults for English locale', async () => {
const t = await loadTranslations('https://cdn.example.com', 'en');
expect(t.title).toBe(DEFAULT_TRANSLATIONS.title);
expect(t.acceptAll).toBe(DEFAULT_TRANSLATIONS.acceptAll);
});
it('should merge remote translations over defaults', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ title: 'Wir verwenden Cookies', acceptAll: 'Alle akzeptieren' }),
}));
const t = await loadTranslations('https://cdn.example.com', 'de');
expect(t.title).toBe('Wir verwenden Cookies');
expect(t.acceptAll).toBe('Alle akzeptieren');
// Missing keys should fall back to English
expect(t.rejectAll).toBe(DEFAULT_TRANSLATIONS.rejectAll);
vi.unstubAllGlobals();
});
it('should fall back to defaults when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fail')));
const t = await loadTranslations('https://cdn.example.com', 'fr');
expect(t.title).toBe(DEFAULT_TRANSLATIONS.title);
vi.unstubAllGlobals();
});
});
describe('interpolate', () => {
it('should replace placeholders with values', () => {
expect(interpolate('{{count}} cookies', { count: '12' })).toBe('12 cookies');
});
it('should handle multiple placeholders', () => {
expect(interpolate('{{a}} and {{b}}', { a: 'X', b: 'Y' })).toBe('X and Y');
});
it('should replace missing keys with empty string', () => {
expect(interpolate('Hello {{name}}', {})).toBe('Hello ');
});
it('should handle templates without placeholders', () => {
expect(interpolate('No placeholders', { key: 'value' })).toBe('No placeholders');
});
it('should handle empty template', () => {
expect(interpolate('', { key: 'value' })).toBe('');
});
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock the imports before loading the loader module
vi.mock('../blocker', () => ({
installBlocker: vi.fn(),
updateAcceptedCategories: vi.fn(),
}));
vi.mock('../consent', () => ({
hasConsent: vi.fn(),
readConsent: vi.fn(),
}));
vi.mock('../gcm', () => ({
buildDeniedDefaults: vi.fn(() => ({ analytics_storage: 'denied' })),
buildGcmStateFromCategories: vi.fn(() => ({ analytics_storage: 'granted' })),
setGcmDefaults: vi.fn(),
updateGcm: vi.fn(),
}));
import { installBlocker, updateAcceptedCategories } from '../blocker';
import { readConsent } from '../consent';
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from '../gcm';
describe('loader', () => {
beforeEach(() => {
vi.clearAllMocks();
window.dataLayer = [];
// @ts-expect-error — reset for test isolation
window.__consentos = undefined;
// Reset document.currentScript
Object.defineProperty(document, 'currentScript', {
value: null,
writable: true,
configurable: true,
});
});
// We can't import the loader directly (it self-executes as an IIFE),
// so we test the individual functions it composes instead.
describe('installBlocker integration', () => {
it('installBlocker should be callable', () => {
installBlocker();
expect(installBlocker).toHaveBeenCalled();
});
});
describe('GCM defaults', () => {
it('should build denied defaults and set them', () => {
const defaults = buildDeniedDefaults();
setGcmDefaults(defaults);
expect(buildDeniedDefaults).toHaveBeenCalled();
expect(setGcmDefaults).toHaveBeenCalledWith(defaults);
});
});
describe('existing consent flow', () => {
it('should update blocker and GCM when consent exists', () => {
const consent = {
accepted: ['necessary', 'analytics'],
rejected: ['marketing'],
visitorId: 'v123',
consentedAt: new Date().toISOString(),
};
vi.mocked(readConsent).mockReturnValue(consent as any);
const existingConsent = readConsent();
expect(existingConsent).toBeDefined();
if (existingConsent) {
updateAcceptedCategories(existingConsent.accepted as any);
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
updateGcm(gcmState);
expect(updateAcceptedCategories).toHaveBeenCalledWith(existingConsent.accepted);
expect(buildGcmStateFromCategories).toHaveBeenCalledWith(existingConsent.accepted);
expect(updateGcm).toHaveBeenCalled();
}
});
it('should not update blocker when no consent exists', () => {
vi.mocked(readConsent).mockReturnValue(null);
const existingConsent = readConsent();
expect(existingConsent).toBeNull();
// In this case, the loader would load the banner bundle
// updateAcceptedCategories should NOT have been called
expect(updateAcceptedCategories).not.toHaveBeenCalled();
});
});
describe('consent-change event', () => {
it('should dispatch consentos:consent-change custom event', () => {
const accepted = ['necessary', 'analytics'];
let receivedDetail: unknown = null;
document.addEventListener('consentos:consent-change', ((e: CustomEvent) => {
receivedDetail = e.detail;
}) as EventListener);
const event = new CustomEvent('consentos:consent-change', {
detail: { accepted },
});
document.dispatchEvent(event);
expect(receivedDetail).toEqual({ accepted });
});
it('should push to dataLayer if it exists', () => {
window.dataLayer = [];
const accepted = ['necessary', 'functional'];
window.dataLayer.push({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toEqual({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
});
});
describe('loadBannerBundle', () => {
it('should create a script element for the banner bundle', () => {
const script = document.createElement('script');
script.src = 'https://cdn.example.com/consent-bundle.js';
script.async = true;
document.head.appendChild(script);
const found = document.querySelector('script[src*="consent-bundle"]');
expect(found).not.toBeNull();
// Clean up
found?.remove();
});
});
describe('__cmp global', () => {
it('should expose the CMP context on window', () => {
window.__consentos = {
siteId: 'test-site-id',
apiBase: 'https://api.example.com',
cdnBase: 'https://cdn.example.com',
loaded: false,
};
expect(window.__consentos.siteId).toBe('test-site-id');
expect(window.__consentos.loaded).toBe(false);
});
});
});

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
hasConfigVersionChanged,
isConsentExpired,
needsReconsent,
} from '../reconsent';
import type { ConsentState, SiteConfig } from '../types';
/** Helper to build a minimal ConsentState for testing. */
function makeConsent(overrides: Partial<ConsentState> = {}): ConsentState {
return {
visitorId: 'test-visitor-id',
accepted: ['necessary', 'analytics'],
rejected: ['marketing'],
consentedAt: new Date().toISOString(),
bannerVersion: '0.1.0',
...overrides,
};
}
/** Helper to build a minimal SiteConfig for testing. */
function makeConfig(overrides: Partial<SiteConfig> = {}): SiteConfig {
return {
id: 'config-v1',
site_id: 'site-123',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
...overrides,
};
}
describe('reconsent', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('isConsentExpired', () => {
it('should return false when consent is within expiry period', () => {
const consent = makeConsent({
consentedAt: '2026-06-01T12:00:00Z', // 14 days ago
});
const config = makeConfig({ consent_expiry_days: 365 });
expect(isConsentExpired(consent, config)).toBe(false);
});
it('should return true when consent has expired', () => {
const consent = makeConsent({
consentedAt: '2025-01-01T12:00:00Z', // ~530 days ago
});
const config = makeConfig({ consent_expiry_days: 365 });
expect(isConsentExpired(consent, config)).toBe(true);
});
it('should return true for exactly expired consent', () => {
// Consent given exactly 365 days ago + 1ms
const consentDate = new Date('2025-06-15T11:59:59.999Z');
const consent = makeConsent({ consentedAt: consentDate.toISOString() });
const config = makeConfig({ consent_expiry_days: 365 });
expect(isConsentExpired(consent, config)).toBe(true);
});
it('should return true for invalid consentedAt date', () => {
const consent = makeConsent({ consentedAt: 'not-a-date' });
const config = makeConfig();
expect(isConsentExpired(consent, config)).toBe(true);
});
it('should handle short expiry periods', () => {
const consent = makeConsent({
consentedAt: '2026-06-10T12:00:00Z', // 5 days ago
});
const config = makeConfig({ consent_expiry_days: 3 });
expect(isConsentExpired(consent, config)).toBe(true);
});
});
describe('hasConfigVersionChanged', () => {
it('should return false when versions match', () => {
const consent = makeConsent({ configVersion: 'config-v1' });
const config = makeConfig({ id: 'config-v1' });
expect(hasConfigVersionChanged(consent, config)).toBe(false);
});
it('should return true when versions differ', () => {
const consent = makeConsent({ configVersion: 'config-v1' });
const config = makeConfig({ id: 'config-v2' });
expect(hasConfigVersionChanged(consent, config)).toBe(true);
});
it('should return true when consent has no configVersion and config has an id', () => {
const consent = makeConsent(); // no configVersion
const config = makeConfig({ id: 'config-v1' });
expect(hasConfigVersionChanged(consent, config)).toBe(true);
});
it('should return false when consent has no configVersion and config id is empty', () => {
const consent = makeConsent(); // no configVersion
const config = makeConfig({ id: '' });
expect(hasConfigVersionChanged(consent, config)).toBe(false);
});
});
describe('needsReconsent', () => {
it('should return not required when all checks pass', () => {
const consent = makeConsent({
consentedAt: '2026-06-01T12:00:00Z',
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v1',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(false);
expect(result.reasons).toEqual([]);
});
it('should detect expired consent', () => {
const consent = makeConsent({
consentedAt: '2024-01-01T00:00:00Z',
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v1',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(true);
expect(result.reasons).toContain('expired');
});
it('should detect config version change', () => {
const consent = makeConsent({
consentedAt: '2026-06-01T12:00:00Z',
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v2',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(true);
expect(result.reasons).toContain('config_changed');
});
it('should report multiple reasons', () => {
const consent = makeConsent({
consentedAt: '2024-01-01T00:00:00Z', // expired
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v2', // config changed
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(true);
expect(result.reasons).toContain('expired');
expect(result.reasons).toContain('config_changed');
expect(result.reasons).toHaveLength(2);
});
it('should not require re-consent for fresh consent with matching config', () => {
const consent = makeConsent({
consentedAt: new Date().toISOString(), // just now
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v1',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(false);
expect(result.reasons).toEqual([]);
});
});
});

View File

@@ -0,0 +1,493 @@
/**
* Tests for client-side cookie reporter — CMP-23.
*
* Covers cookie parsing, storage enumeration, sampling, report building,
* report sending, and the full reporter lifecycle.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type CookieReport,
type DiscoveredCookie,
type ReporterConfig,
buildReport,
collectAll,
enumerateLocalStorage,
enumerateSessionStorage,
getObservedScripts,
installScriptObserver,
parseCookies,
removeScriptObserver,
reportNow,
sendReport,
shouldSample,
startReporter,
} from '../reporter';
// ── Helpers ─────────────────────────────────────────────────────────
function makeConfig(
overrides: Partial<ReporterConfig> = {},
): ReporterConfig {
return {
siteId: 'test-site-id',
apiBase: 'https://api.example.com/api/v1',
sampleRate: 1.0, // Always sample in tests
collectDelay: 0,
includeLocalStorage: true,
includeSessionStorage: true,
...overrides,
};
}
// ── Cookie parsing ──────────────────────────────────────────────────
describe('parseCookies', () => {
beforeEach(() => {
// Clear all cookies
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
});
afterEach(() => {
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
});
it('should return empty array when no cookies exist', () => {
// After clearing, parseCookies should return empty or only empty entries
const result = parseCookies();
// Filter out any empty-name artefacts from jsdom
const named = result.filter((c) => c.name.length > 0);
expect(named).toEqual([]);
});
it('should parse a single cookie', () => {
document.cookie = '_ga=GA1.2.123456789.1234567890';
const result = parseCookies();
const ga = result.find((c) => c.name === '_ga');
expect(ga).toBeDefined();
expect(ga!.storage_type).toBe('cookie');
expect(ga!.domain).toBe('localhost');
expect(ga!.value_length).toBeGreaterThan(0);
});
it('should parse multiple cookies', () => {
document.cookie = '_ga=value1';
document.cookie = '_gid=value2';
const result = parseCookies();
const names = result.map((c) => c.name);
expect(names).toContain('_ga');
expect(names).toContain('_gid');
});
it('should handle cookies with equals in value', () => {
document.cookie = 'data=key=value';
const result = parseCookies();
const data = result.find((c) => c.name === 'data');
expect(data).toBeDefined();
expect(data!.value_length).toBe('key=value'.length);
});
});
// ── localStorage enumeration ────────────────────────────────────────
describe('enumerateLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('should return empty array when localStorage is empty', () => {
expect(enumerateLocalStorage()).toEqual([]);
});
it('should enumerate localStorage keys', () => {
localStorage.setItem('analytics_id', 'abc123');
localStorage.setItem('theme', 'dark');
const result = enumerateLocalStorage();
expect(result).toHaveLength(2);
const names = result.map((i) => i.name);
expect(names).toContain('analytics_id');
expect(names).toContain('theme');
expect(result[0].storage_type).toBe('local_storage');
});
it('should report correct value lengths', () => {
localStorage.setItem('key', 'hello');
const result = enumerateLocalStorage();
expect(result[0].value_length).toBe(5);
});
});
// ── sessionStorage enumeration ──────────────────────────────────────
describe('enumerateSessionStorage', () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
it('should return empty array when sessionStorage is empty', () => {
expect(enumerateSessionStorage()).toEqual([]);
});
it('should enumerate sessionStorage keys', () => {
sessionStorage.setItem('session_token', 'xyz');
const result = enumerateSessionStorage();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('session_token');
expect(result[0].storage_type).toBe('session_storage');
});
});
// ── collectAll ──────────────────────────────────────────────────────
describe('collectAll', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('should collect from all storage types', () => {
document.cookie = '_ga=test';
localStorage.setItem('ls_key', 'value');
sessionStorage.setItem('ss_key', 'value');
const config = makeConfig();
const result = collectAll(config);
const types = new Set(result.map((i) => i.storage_type));
expect(types).toContain('cookie');
expect(types).toContain('local_storage');
expect(types).toContain('session_storage');
});
it('should exclude localStorage when disabled', () => {
localStorage.setItem('ls_key', 'value');
const config = makeConfig({ includeLocalStorage: false });
const result = collectAll(config);
const hasLocal = result.some((i) => i.storage_type === 'local_storage');
expect(hasLocal).toBe(false);
});
it('should exclude sessionStorage when disabled', () => {
sessionStorage.setItem('ss_key', 'value');
const config = makeConfig({ includeSessionStorage: false });
const result = collectAll(config);
const hasSession = result.some(
(i) => i.storage_type === 'session_storage',
);
expect(hasSession).toBe(false);
});
});
// ── Report building ─────────────────────────────────────────────────
describe('buildReport', () => {
it('should build a valid report', () => {
const config = makeConfig();
const cookies: DiscoveredCookie[] = [
{
name: '_ga',
domain: 'example.com',
storage_type: 'cookie',
value_length: 30,
},
];
const report = buildReport(config, cookies);
expect(report.site_id).toBe('test-site-id');
expect(report.cookies).toHaveLength(1);
expect(report.collected_at).toBeTruthy();
expect(report.page_url).toBeTruthy();
expect(report.user_agent).toBeTruthy();
});
it('should include the current page URL', () => {
const config = makeConfig();
const report = buildReport(config, []);
expect(report.page_url).toContain('localhost');
});
});
// ── Sampling ────────────────────────────────────────────────────────
describe('shouldSample', () => {
it('should always sample at rate 1.0', () => {
// Run 100 times — all should be true
for (let i = 0; i < 100; i++) {
expect(shouldSample(1.0)).toBe(true);
}
});
it('should never sample at rate 0.0', () => {
for (let i = 0; i < 100; i++) {
expect(shouldSample(0.0)).toBe(false);
}
});
it('should sample approximately at the given rate', () => {
// With 0.5, we'd expect roughly half
let sampled = 0;
const runs = 1000;
for (let i = 0; i < runs; i++) {
if (shouldSample(0.5)) sampled++;
}
// Allow generous margin (30-70%)
expect(sampled).toBeGreaterThan(runs * 0.3);
expect(sampled).toBeLessThan(runs * 0.7);
});
});
// ── Report sending ──────────────────────────────────────────────────
describe('sendReport', () => {
it('should use sendBeacon when available', async () => {
const beaconMock = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: beaconMock,
writable: true,
configurable: true,
});
const report: CookieReport = {
site_id: 'test',
page_url: 'https://example.com',
cookies: [],
collected_at: new Date().toISOString(),
user_agent: 'test-agent',
};
const result = await sendReport(
'https://api.example.com/api/v1',
report,
);
expect(result).toBe(true);
expect(beaconMock).toHaveBeenCalledWith(
'https://api.example.com/api/v1/scanner/report',
expect.any(Blob),
);
});
it('should fall back to fetch when sendBeacon is unavailable', async () => {
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = fetchMock;
const report: CookieReport = {
site_id: 'test',
page_url: 'https://example.com',
cookies: [],
collected_at: new Date().toISOString(),
user_agent: 'test-agent',
};
const result = await sendReport(
'https://api.example.com/api/v1',
report,
);
expect(result).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/v1/scanner/report',
expect.objectContaining({
method: 'POST',
keepalive: true,
}),
);
});
it('should return false on fetch failure', async () => {
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error'));
const report: CookieReport = {
site_id: 'test',
page_url: 'https://example.com',
cookies: [],
collected_at: new Date().toISOString(),
user_agent: 'test-agent',
};
const result = await sendReport(
'https://api.example.com/api/v1',
report,
);
expect(result).toBe(false);
});
});
// ── Script observer ─────────────────────────────────────────────────
describe('scriptObserver', () => {
afterEach(() => {
removeScriptObserver();
});
it('should install and remove observer without errors', () => {
// jsdom doesn't have full PerformanceObserver, but shouldn't throw
installScriptObserver();
expect(getObservedScripts()).toEqual([]);
removeScriptObserver();
expect(getObservedScripts()).toEqual([]);
});
it('should clear observed scripts on remove', () => {
installScriptObserver();
removeScriptObserver();
expect(getObservedScripts()).toEqual([]);
});
});
// ── startReporter ───────────────────────────────────────────────────
describe('startReporter', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
removeScriptObserver();
});
it('should not report when sample rate is 0', () => {
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});
startReporter({
siteId: 'test',
apiBase: 'https://api.example.com/api/v1',
sampleRate: 0,
});
vi.advanceTimersByTime(5000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should report after delay when sampled', () => {
document.cookie = '_test_reporter=value';
const beaconMock = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: beaconMock,
writable: true,
configurable: true,
});
startReporter({
siteId: 'test',
apiBase: 'https://api.example.com/api/v1',
sampleRate: 1.0,
collectDelay: 2000,
});
// Before delay — should not have reported
expect(beaconMock).not.toHaveBeenCalled();
// After delay — should report
vi.advanceTimersByTime(2000);
expect(beaconMock).toHaveBeenCalled();
// Clean up
document.cookie =
'_test_reporter=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
});
// ── reportNow ───────────────────────────────────────────────────────
describe('reportNow', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('should return null when no items found', async () => {
// Clear cookies
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
const result = await reportNow({
siteId: 'test',
apiBase: 'https://api.example.com/api/v1',
includeLocalStorage: false,
includeSessionStorage: false,
});
// May be null if no cookies remain, or a report if jsdom has residual cookies
if (result !== null) {
expect(result.cookies.length).toBeGreaterThan(0);
}
});
it('should collect and send a report', async () => {
document.cookie = '_report_test=value';
const beaconMock = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: beaconMock,
writable: true,
configurable: true,
});
const result = await reportNow({
siteId: 'my-site',
apiBase: 'https://api.example.com/api/v1',
});
expect(result).not.toBeNull();
expect(result!.site_id).toBe('my-site');
expect(result!.cookies.length).toBeGreaterThan(0);
expect(beaconMock).toHaveBeenCalled();
// Clean up
document.cookie =
'_report_test=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { buildShopifyConsent, isShopifyPrivacyAvailable, updateShopifyConsent } from '../shopify';
import type { CategorySlug } from '../types';
describe('shopify', () => {
const mockSetTrackingConsent = vi.fn();
const mockCurrentVisitorConsent = vi.fn();
beforeEach(() => {
(window as any).Shopify = {
customerPrivacy: {
setTrackingConsent: mockSetTrackingConsent,
currentVisitorConsent: mockCurrentVisitorConsent,
analyticsProcessingAllowed: () => false,
marketingAllowed: () => false,
preferencesProcessingAllowed: () => false,
saleOfDataAllowed: () => false,
getRegion: () => 'CA',
},
};
});
afterEach(() => {
delete (window as any).Shopify;
vi.restoreAllMocks();
});
describe('isShopifyPrivacyAvailable', () => {
it('returns true when Shopify API is present', () => {
expect(isShopifyPrivacyAvailable()).toBe(true);
});
it('returns false when Shopify is not on window', () => {
delete (window as any).Shopify;
expect(isShopifyPrivacyAvailable()).toBe(false);
});
it('returns false when customerPrivacy is missing', () => {
(window as any).Shopify = {};
expect(isShopifyPrivacyAvailable()).toBe(false);
});
});
describe('buildShopifyConsent', () => {
it('maps accept all to all yes', () => {
const accepted: CategorySlug[] = ['necessary', 'functional', 'analytics', 'marketing', 'personalisation'];
const result = buildShopifyConsent(accepted);
expect(result).toEqual({
preferences: 'yes',
analytics: 'yes',
marketing: 'yes',
sale_of_data: 'yes',
});
});
it('maps reject all (necessary only) to all no', () => {
const accepted: CategorySlug[] = ['necessary'];
const result = buildShopifyConsent(accepted);
expect(result).toEqual({
preferences: 'no',
analytics: 'no',
marketing: 'no',
sale_of_data: 'no',
});
});
it('maps functional to preferences', () => {
const accepted: CategorySlug[] = ['necessary', 'functional'];
const result = buildShopifyConsent(accepted);
expect(result.preferences).toBe('yes');
expect(result.analytics).toBe('no');
expect(result.marketing).toBe('no');
});
it('maps personalisation to sale_of_data', () => {
const accepted: CategorySlug[] = ['necessary', 'personalisation'];
const result = buildShopifyConsent(accepted);
expect(result.sale_of_data).toBe('yes');
expect(result.marketing).toBe('no');
});
it('maps marketing to both marketing and sale_of_data', () => {
const accepted: CategorySlug[] = ['necessary', 'marketing'];
const result = buildShopifyConsent(accepted);
expect(result.marketing).toBe('yes');
expect(result.sale_of_data).toBe('yes');
});
});
describe('updateShopifyConsent', () => {
it('calls setTrackingConsent with mapped values', () => {
const accepted: CategorySlug[] = ['necessary', 'analytics', 'marketing'];
updateShopifyConsent(accepted);
expect(mockSetTrackingConsent).toHaveBeenCalledWith(
{
preferences: 'no',
analytics: 'yes',
marketing: 'yes',
sale_of_data: 'yes',
},
expect.any(Function),
);
});
it('does nothing when Shopify API is not available', () => {
delete (window as any).Shopify;
updateShopifyConsent(['necessary', 'analytics']);
expect(mockSetTrackingConsent).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,856 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
BitReader,
BitWriter,
RestrictionType,
base64urlToBytes,
bytesToBase64url,
createTCModel,
decodeTCString,
decisecondsToMs,
encodeTCString,
getTcString,
installTcfApi,
msToDeciseconds,
removeTcfApi,
setTcfDisplayStatus,
updateTcfConsent,
} from '../tcf';
import type { TCModel, TcfApiCallback } from '../tcf';
// ── BitWriter / BitReader ────────────────────────────────────────────
describe('BitWriter', () => {
it('writes single bits correctly', () => {
const w = new BitWriter();
w.writeBool(true);
w.writeBool(false);
w.writeBool(true);
w.writeBool(true);
w.writeBool(false);
w.writeBool(false);
w.writeBool(true);
w.writeBool(false);
const bytes = w.toBytes();
expect(bytes.length).toBe(1);
// 10110010 = 0xB2 = 178
expect(bytes[0]).toBe(0b10110010);
});
it('writes multi-bit integers', () => {
const w = new BitWriter();
w.writeInt(2, 6); // 000010
const bytes = w.toBytes();
// 000010 + 00 (padding) = 00001000 = 8
expect(bytes[0]).toBe(0b00001000);
});
it('writes integers across byte boundaries', () => {
const w = new BitWriter();
w.writeInt(0b11111111, 8);
w.writeInt(0b1010, 4);
const bytes = w.toBytes();
expect(bytes.length).toBe(2);
expect(bytes[0]).toBe(0xff);
// 1010 + 0000 (padding) = 10100000
expect(bytes[1]).toBe(0b10100000);
});
it('writes a bitfield from a Set', () => {
const w = new BitWriter();
w.writeBitfield(new Set([1, 3, 5]), 8);
const bytes = w.toBytes();
// bits: 1 0 1 0 1 0 0 0 = 0xA8
expect(bytes[0]).toBe(0b10101000);
});
it('writes two-letter codes', () => {
const w = new BitWriter();
w.writeLetters('EN');
const bytes = w.toBytes();
// E=4 (000100), N=13 (001101) → 000100 001101 → 00010000 1101(0000)
const r = new BitReader(bytes);
expect(r.readInt(6)).toBe(4); // E
expect(r.readInt(6)).toBe(13); // N
});
it('handles empty writes', () => {
const w = new BitWriter();
const bytes = w.toBytes();
expect(bytes.length).toBe(0);
});
it('pads incomplete last byte', () => {
const w = new BitWriter();
w.writeBool(true);
const bytes = w.toBytes();
expect(bytes.length).toBe(1);
// 1 + 0000000 (padding) = 10000000
expect(bytes[0]).toBe(0b10000000);
});
});
describe('BitReader', () => {
it('reads single bits', () => {
const r = new BitReader(new Uint8Array([0b10110010]));
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
expect(r.readBool()).toBe(false);
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
});
it('reads multi-bit integers', () => {
const r = new BitReader(new Uint8Array([0b00001000]));
expect(r.readInt(6)).toBe(2);
});
it('reads across byte boundaries', () => {
const r = new BitReader(new Uint8Array([0xff, 0b10100000]));
expect(r.readInt(8)).toBe(255);
expect(r.readInt(4)).toBe(0b1010);
});
it('reads a bitfield into a Set', () => {
const r = new BitReader(new Uint8Array([0b10101000]));
const ids = r.readBitfield(8);
expect(ids).toEqual(new Set([1, 3, 5]));
});
it('reads two-letter codes', () => {
// E=4 (000100), N=13 (001101) → 00010000 11010000
const r = new BitReader(new Uint8Array([0b00010000, 0b11010000]));
expect(r.readLetters()).toBe('EN');
});
it('hasRemaining checks available bits', () => {
const r = new BitReader(new Uint8Array([0xff]));
expect(r.hasRemaining(8)).toBe(true);
expect(r.hasRemaining(9)).toBe(false);
r.readInt(4);
expect(r.hasRemaining(4)).toBe(true);
expect(r.hasRemaining(5)).toBe(false);
});
it('reads zero when past end of buffer', () => {
const r = new BitReader(new Uint8Array([0xff]));
r.readInt(8); // consume all
expect(r.readInt(4)).toBe(0);
});
});
// ── Base64url ────────────────────────────────────────────────────────
describe('base64url encoding', () => {
it('round-trips bytes correctly', () => {
const original = new Uint8Array([0, 1, 2, 127, 128, 255]);
const encoded = bytesToBase64url(original);
const decoded = base64urlToBytes(encoded);
expect(decoded).toEqual(original);
});
it('encodes empty array', () => {
expect(bytesToBase64url(new Uint8Array([]))).toBe('');
});
it('decodes empty string', () => {
expect(base64urlToBytes('')).toEqual(new Uint8Array([]));
});
it('produces URL-safe characters (no +, /, =)', () => {
const bytes = new Uint8Array(256);
for (let i = 0; i < 256; i++) bytes[i] = i;
const encoded = bytesToBase64url(bytes);
expect(encoded).not.toContain('+');
expect(encoded).not.toContain('/');
expect(encoded).not.toContain('=');
});
it('round-trips single byte', () => {
const original = new Uint8Array([42]);
expect(base64urlToBytes(bytesToBase64url(original))).toEqual(original);
});
it('round-trips two bytes', () => {
const original = new Uint8Array([200, 100]);
expect(base64urlToBytes(bytesToBase64url(original))).toEqual(original);
});
});
// ── Timestamp helpers ────────────────────────────────────────────────
describe('timestamp conversion', () => {
it('converts ms to deciseconds', () => {
expect(msToDeciseconds(1000)).toBe(10);
expect(msToDeciseconds(100)).toBe(1);
expect(msToDeciseconds(150)).toBe(2); // rounds
});
it('converts deciseconds to ms', () => {
expect(decisecondsToMs(10)).toBe(1000);
expect(decisecondsToMs(1)).toBe(100);
});
it('round-trips approximately', () => {
const now = Date.now();
const ds = msToDeciseconds(now);
const back = decisecondsToMs(ds);
// Within 100ms accuracy (one decisecond)
expect(Math.abs(back - now)).toBeLessThan(100);
});
});
// ── createTCModel ────────────────────────────────────────────────────
describe('createTCModel', () => {
it('creates a model with defaults', () => {
const model = createTCModel();
expect(model.version).toBe(2);
expect(model.tcfPolicyVersion).toBe(4);
expect(model.isServiceSpecific).toBe(true);
expect(model.consentLanguage).toBe('EN');
expect(model.publisherCC).toBe('GB');
expect(model.purposeConsents.size).toBe(0);
expect(model.vendorConsents.size).toBe(0);
});
it('accepts overrides', () => {
const model = createTCModel({
cmpId: 42,
consentLanguage: 'FR',
purposeConsents: new Set([1, 2, 3]),
});
expect(model.cmpId).toBe(42);
expect(model.consentLanguage).toBe('FR');
expect(model.purposeConsents).toEqual(new Set([1, 2, 3]));
// Defaults still apply
expect(model.version).toBe(2);
});
it('sets created and lastUpdated to now', () => {
const before = msToDeciseconds(Date.now());
const model = createTCModel();
const after = msToDeciseconds(Date.now());
expect(model.created).toBeGreaterThanOrEqual(before);
expect(model.created).toBeLessThanOrEqual(after);
expect(model.lastUpdated).toBe(model.created);
});
});
// ── Encode / Decode round-trip ───────────────────────────────────────
describe('encodeTCString / decodeTCString', () => {
it('round-trips a minimal model', () => {
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
cmpId: 10,
cmpVersion: 2,
});
const tcString = encodeTCString(model);
expect(typeof tcString).toBe('string');
expect(tcString.length).toBeGreaterThan(0);
const decoded = decodeTCString(tcString);
expect(decoded.version).toBe(2);
expect(decoded.created).toBe(100000);
expect(decoded.lastUpdated).toBe(100000);
expect(decoded.cmpId).toBe(10);
expect(decoded.cmpVersion).toBe(2);
expect(decoded.consentLanguage).toBe('EN');
expect(decoded.publisherCC).toBe('GB');
expect(decoded.isServiceSpecific).toBe(true);
expect(decoded.tcfPolicyVersion).toBe(4);
});
it('round-trips purpose consents', () => {
const model = createTCModel({
created: 200000,
lastUpdated: 200000,
purposeConsents: new Set([1, 2, 3, 7, 10]),
purposeLegitimateInterests: new Set([2, 4, 6]),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3, 7, 10]));
expect(decoded.purposeLegitimateInterests).toEqual(new Set([2, 4, 6]));
});
it('round-trips vendor consents', () => {
const model = createTCModel({
created: 300000,
lastUpdated: 300000,
vendorConsents: new Set([1, 5, 10, 100]),
vendorLegitimateInterests: new Set([2, 50]),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.vendorConsents).toEqual(new Set([1, 5, 10, 100]));
expect(decoded.vendorLegitimateInterests).toEqual(new Set([2, 50]));
});
it('round-trips special feature opt-ins', () => {
const model = createTCModel({
created: 400000,
lastUpdated: 400000,
specialFeatureOptIns: new Set([1, 2]),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.specialFeatureOptIns).toEqual(new Set([1, 2]));
});
it('round-trips consent language and publisher CC', () => {
const model = createTCModel({
created: 500000,
lastUpdated: 500000,
consentLanguage: 'FR',
publisherCC: 'DE',
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.consentLanguage).toBe('FR');
expect(decoded.publisherCC).toBe('DE');
});
it('round-trips boolean flags', () => {
const model = createTCModel({
created: 600000,
lastUpdated: 600000,
isServiceSpecific: false,
useNonStandardTexts: true,
purposeOneTreatment: true,
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.isServiceSpecific).toBe(false);
expect(decoded.useNonStandardTexts).toBe(true);
expect(decoded.purposeOneTreatment).toBe(true);
});
it('round-trips publisher restrictions', () => {
const model = createTCModel({
created: 700000,
lastUpdated: 700000,
publisherRestrictions: [
{
purposeId: 1,
restrictionType: RestrictionType.REQUIRE_CONSENT,
vendorIds: new Set([10, 20, 30]),
},
{
purposeId: 3,
restrictionType: RestrictionType.NOT_ALLOWED,
vendorIds: new Set([5]),
},
],
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.publisherRestrictions.length).toBe(2);
expect(decoded.publisherRestrictions[0].purposeId).toBe(1);
expect(decoded.publisherRestrictions[0].restrictionType).toBe(
RestrictionType.REQUIRE_CONSENT
);
expect(decoded.publisherRestrictions[0].vendorIds).toEqual(new Set([10, 20, 30]));
expect(decoded.publisherRestrictions[1].purposeId).toBe(3);
expect(decoded.publisherRestrictions[1].vendorIds).toEqual(new Set([5]));
});
it('handles empty vendor sets', () => {
const model = createTCModel({
created: 800000,
lastUpdated: 800000,
vendorConsents: new Set(),
vendorLegitimateInterests: new Set(),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.vendorConsents.size).toBe(0);
expect(decoded.vendorLegitimateInterests.size).toBe(0);
});
it('handles no publisher restrictions', () => {
const model = createTCModel({
created: 900000,
lastUpdated: 900000,
publisherRestrictions: [],
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.publisherRestrictions.length).toBe(0);
});
it('round-trips a fully populated model', () => {
const model = createTCModel({
created: 1000000,
lastUpdated: 1000001,
cmpId: 300,
cmpVersion: 5,
consentScreen: 2,
consentLanguage: 'DE',
vendorListVersion: 150,
tcfPolicyVersion: 4,
isServiceSpecific: false,
useNonStandardTexts: false,
specialFeatureOptIns: new Set([1, 2]),
purposeConsents: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
purposeLegitimateInterests: new Set([2, 7, 9, 10]),
purposeOneTreatment: true,
publisherCC: 'FR',
vendorConsents: new Set([1, 2, 3, 10, 25, 50, 100, 200, 500]),
vendorLegitimateInterests: new Set([1, 10, 50]),
publisherRestrictions: [
{
purposeId: 2,
restrictionType: RestrictionType.REQUIRE_LEGITIMATE_INTEREST,
vendorIds: new Set([100, 101, 102]),
},
],
});
const tcString = encodeTCString(model);
const decoded = decodeTCString(tcString);
expect(decoded.version).toBe(2);
expect(decoded.created).toBe(1000000);
expect(decoded.lastUpdated).toBe(1000001);
expect(decoded.cmpId).toBe(300);
expect(decoded.cmpVersion).toBe(5);
expect(decoded.consentScreen).toBe(2);
expect(decoded.consentLanguage).toBe('DE');
expect(decoded.vendorListVersion).toBe(150);
expect(decoded.tcfPolicyVersion).toBe(4);
expect(decoded.isServiceSpecific).toBe(false);
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
expect(decoded.purposeLegitimateInterests).toEqual(new Set([2, 7, 9, 10]));
expect(decoded.purposeOneTreatment).toBe(true);
expect(decoded.publisherCC).toBe('FR');
expect(decoded.vendorConsents).toEqual(
new Set([1, 2, 3, 10, 25, 50, 100, 200, 500])
);
expect(decoded.vendorLegitimateInterests).toEqual(new Set([1, 10, 50]));
expect(decoded.specialFeatureOptIns).toEqual(new Set([1, 2]));
expect(decoded.publisherRestrictions[0].vendorIds).toEqual(
new Set([100, 101, 102])
);
});
it('only parses the core segment when dots are present', () => {
const model = createTCModel({ created: 1100000, lastUpdated: 1100000 });
const tcString = encodeTCString(model);
// Append a fake disclosed vendors segment
const withSegments = `${tcString}.FAKE_SEGMENT`;
const decoded = decodeTCString(withSegments);
expect(decoded.version).toBe(2);
expect(decoded.created).toBe(1100000);
});
});
// ── __tcfapi interface ───────────────────────────────────────────────
describe('__tcfapi interface', () => {
beforeEach(() => {
removeTcfApi();
});
afterEach(() => {
removeTcfApi();
});
describe('installTcfApi', () => {
it('installs __tcfapi on window', () => {
installTcfApi(42, 1);
expect(typeof window.__tcfapi).toBe('function');
});
it('processes queued calls from the stub', () => {
const queue: unknown[][] = [];
window.__tcfapiQueue = queue;
const callback = vi.fn();
queue.push(['ping', 2, callback]);
installTcfApi(42, 1);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ cmpLoaded: true }),
true
);
});
it('clears the queue after processing', () => {
const queue: unknown[][] = [['ping', 2, vi.fn()]];
window.__tcfapiQueue = queue;
installTcfApi(42, 1);
expect(queue.length).toBe(0);
});
});
describe('ping command', () => {
it('returns CMP status', () => {
installTcfApi(42, 3);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 2, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
gdprApplies: true,
cmpLoaded: true,
cmpStatus: 'loaded',
displayStatus: 'hidden',
apiVersion: '2.2',
cmpVersion: 3,
cmpId: 42,
tcfPolicyVersion: 4,
}),
true
);
});
it('respects gdprApplies parameter', () => {
installTcfApi(42, 1, false);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 2, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ gdprApplies: false }),
true
);
});
});
describe('getTCData command', () => {
it('returns cmpuishown when no consent', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
eventStatus: 'cmpuishown',
cmpId: 42,
tcString: '',
}),
true
);
});
it('returns tcloaded after consent is set', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
cmpId: 42,
purposeConsents: new Set([1, 2]),
});
updateTcfConsent(model);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
const result = callback.mock.calls[0][0];
expect(result.eventStatus).toBe('tcloaded');
expect(result.tcString).toBeTruthy();
expect(result.purpose.consents['1']).toBe(true);
expect(result.purpose.consents['2']).toBe(true);
expect(result.purpose.consents['3']).toBe(false);
});
it('includes vendor consent data', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
vendorConsents: new Set([1, 5, 10]),
vendorLegitimateInterests: new Set([2, 5]),
});
updateTcfConsent(model);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
const result = callback.mock.calls[0][0];
expect(result.vendor.consents['1']).toBe(true);
expect(result.vendor.consents['5']).toBe(true);
expect(result.vendor.consents['10']).toBe(true);
expect(result.vendor.consents['2']).toBe(false);
expect(result.vendor.legitimateInterests['2']).toBe(true);
expect(result.vendor.legitimateInterests['5']).toBe(true);
});
it('includes special feature opt-in data', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
specialFeatureOptIns: new Set([1, 2]),
});
updateTcfConsent(model);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
const result = callback.mock.calls[0][0];
expect(result.specialFeatureOptins['1']).toBe(true);
expect(result.specialFeatureOptins['2']).toBe(true);
expect(result.specialFeatureOptins['3']).toBe(false);
});
});
describe('addEventListener command', () => {
it('assigns a listener ID and returns current TC data', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, callback);
expect(callback).toHaveBeenCalledOnce();
const result = callback.mock.calls[0][0];
expect(result.listenerId).toBe(1);
expect(result.eventStatus).toBe('cmpuishown');
});
it('notifies listeners when consent is updated', () => {
installTcfApi(42, 1);
const listener = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, listener);
// Initial call
expect(listener).toHaveBeenCalledOnce();
// Update consent
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
purposeConsents: new Set([1]),
});
updateTcfConsent(model);
// Should be called again with useractioncomplete
expect(listener).toHaveBeenCalledTimes(2);
const updateResult = listener.mock.calls[1][0];
expect(updateResult.eventStatus).toBe('useractioncomplete');
expect(updateResult.tcString).toBeTruthy();
});
it('assigns incrementing listener IDs', () => {
installTcfApi(42, 1);
const cb1 = vi.fn();
const cb2 = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, cb1);
api('addEventListener', 2, cb2);
expect(cb1.mock.calls[0][0].listenerId).toBe(1);
expect(cb2.mock.calls[0][0].listenerId).toBe(2);
});
it('swallows errors in listener callbacks', () => {
installTcfApi(42, 1);
const badListener = vi.fn(() => {
throw new Error('boom');
});
const goodListener = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, badListener);
api('addEventListener', 2, goodListener);
// Update consent - should not throw
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
expect(() => updateTcfConsent(model)).not.toThrow();
// Good listener still called
expect(goodListener).toHaveBeenCalledTimes(2);
});
});
describe('removeEventListener command', () => {
it('removes a listener by ID', () => {
installTcfApi(42, 1);
const listener = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, listener);
const listenerId = listener.mock.calls[0][0].listenerId;
const removeCallback = vi.fn();
api('removeEventListener', 2, removeCallback, listenerId);
expect(removeCallback).toHaveBeenCalledWith(true, true);
// Listener should not be called on updates
listener.mockClear();
updateTcfConsent(createTCModel({ created: 100000, lastUpdated: 100000 }));
expect(listener).not.toHaveBeenCalled();
});
it('returns false for non-existent listener ID', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('removeEventListener', 2, callback, 999);
expect(callback).toHaveBeenCalledWith(false, false);
});
});
describe('version checking', () => {
it('rejects non-v2 API calls', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 1, callback);
expect(callback).toHaveBeenCalledWith(false, false);
});
});
describe('unknown commands', () => {
it('calls back with false for unknown commands', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('unknownCommand', 2, callback);
expect(callback).toHaveBeenCalledWith(false, false);
});
});
describe('uninitialised state', () => {
it('calls back with false when API not installed', () => {
// Do NOT call installTcfApi — directly test the handler via a mock
// Since removeTcfApi is called in beforeEach, apiState is null
// We need to call installTcfApi then removeTcfApi to reset, then
// try calling a cached reference (but __tcfapi is deleted).
// Instead, test that getTcString returns empty when uninitialised.
expect(getTcString()).toBe('');
});
});
});
// ── updateTcfConsent ─────────────────────────────────────────────────
describe('updateTcfConsent', () => {
beforeEach(() => {
removeTcfApi();
});
afterEach(() => {
removeTcfApi();
});
it('returns a valid TC string', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
cmpId: 42,
purposeConsents: new Set([1, 2, 3]),
});
const tcString = updateTcfConsent(model);
expect(typeof tcString).toBe('string');
expect(tcString.length).toBeGreaterThan(0);
// Should be decodable
const decoded = decodeTCString(tcString);
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3]));
});
it('updates getTcString return value', () => {
installTcfApi(42, 1);
expect(getTcString()).toBe('');
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
const tcString = updateTcfConsent(model);
expect(getTcString()).toBe(tcString);
});
it('works without installTcfApi (returns TC string, no listeners)', () => {
// apiState is null, but should still encode
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
const tcString = updateTcfConsent(model);
expect(typeof tcString).toBe('string');
expect(tcString.length).toBeGreaterThan(0);
});
});
// ── setTcfDisplayStatus ──────────────────────────────────────────────
describe('setTcfDisplayStatus', () => {
beforeEach(() => {
removeTcfApi();
});
afterEach(() => {
removeTcfApi();
});
it('updates the display status returned by ping', () => {
installTcfApi(42, 1);
setTcfDisplayStatus('visible');
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 2, callback);
expect(callback.mock.calls[0][0].displayStatus).toBe('visible');
});
it('does nothing when API not installed', () => {
// Should not throw
expect(() => setTcfDisplayStatus('visible')).not.toThrow();
});
});
// ── removeTcfApi ─────────────────────────────────────────────────────
describe('removeTcfApi', () => {
it('removes __tcfapi from window', () => {
installTcfApi(42, 1);
expect(window.__tcfapi).toBeDefined();
removeTcfApi();
expect(window.__tcfapi).toBeUndefined();
});
it('cleans up __tcfapiQueue', () => {
window.__tcfapiQueue = [];
installTcfApi(42, 1);
removeTcfApi();
expect(window.__tcfapiQueue).toBeUndefined();
});
it('is safe to call multiple times', () => {
expect(() => {
removeTcfApi();
removeTcfApi();
}).not.toThrow();
});
});

119
apps/banner/src/a11y.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* Accessibility utilities for the consent banner.
*
* Provides focus trapping, keyboard navigation, and screen reader
* announcements for WCAG 2.1 AA compliance.
*/
/**
* Trap focus within a container element.
* Returns a cleanup function to remove the event listener.
*/
export function trapFocus(container: HTMLElement): () => void {
function handleKeydown(e: KeyboardEvent): void {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements(container);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift+Tab: wrap from first to last
if (document.activeElement === first || container.shadowRoot?.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab: wrap from last to first
if (document.activeElement === last || container.shadowRoot?.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
container.addEventListener('keydown', handleKeydown);
return () => container.removeEventListener('keydown', handleKeydown);
}
/**
* Set up Escape key to dismiss the banner.
* Returns a cleanup function.
*/
export function onEscape(
container: HTMLElement,
callback: () => void,
): () => void {
function handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
e.preventDefault();
callback();
}
}
container.addEventListener('keydown', handleKeydown);
return () => container.removeEventListener('keydown', handleKeydown);
}
/**
* Get all focusable elements within a container, including shadow DOM.
*/
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
// Check shadow root first
const root = container.shadowRoot ?? container;
return Array.from(root.querySelectorAll<HTMLElement>(selector));
}
/**
* Move focus to the first focusable element in the banner.
*/
export function focusFirst(container: HTMLElement): void {
const elements = getFocusableElements(container);
if (elements.length > 0) {
elements[0].focus();
}
}
/**
* Create a visually hidden live region for screen reader announcements.
* Returns the element so you can update its textContent.
*/
export function createLiveRegion(root: HTMLElement | ShadowRoot): HTMLElement {
const region = document.createElement('div');
region.setAttribute('role', 'status');
region.setAttribute('aria-live', 'polite');
region.setAttribute('aria-atomic', 'true');
region.className = 'cmp-sr-only';
root.appendChild(region);
return region;
}
/**
* Announce a message to screen readers via a live region.
*/
export function announce(liveRegion: HTMLElement, message: string): void {
// Clear then set to ensure the screen reader picks up the change
liveRegion.textContent = '';
requestAnimationFrame(() => {
liveRegion.textContent = message;
});
}
/**
* Check if the user prefers reduced motion.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

836
apps/banner/src/banner.ts Normal file
View File

@@ -0,0 +1,836 @@
/**
* consent-bundle.js — Full consent banner UI with Shadow DOM isolation.
*
* Loaded async by consent-loader.js when no existing consent is found.
* Fetches site config, renders the banner, handles user interaction,
* records consent via the API.
*
* Enterprise features (A/B testing, GPP, GPC, profile sync, Shopify,
* re-consent) are loaded via the EE banner extension module when present.
*/
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
import { updateAcceptedCategories } from './blocker';
import { buildConsentState, readConsent, writeConsent } from './consent';
import { buildGcmStateFromCategories, updateGcm } from './gcm';
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
// -- Preference-centre closure captured during init() ---------------------
/**
* Holds a closure that re-opens the banner for consent withdrawal.
* Populated during ``init()`` once config and translations are loaded,
* and invoked by ``window.ConsentOS.showPreferences()``. The floating
* "manage cookies" button also calls through this indirection so a
* single entry point keeps the behaviour consistent.
*/
let _openPreferences: (() => void) | null = null;
// -- EE extension hooks (no-ops in CE mode) ---------------------------------
/** Result from the A/B test assignment. */
interface ABAssignment {
abTestId: string;
variantId: string;
variant: { name: string };
}
/** Result from GPC evaluation. */
interface GpcResult {
detected: boolean;
honoured: boolean;
}
/** EE hooks that enterprise code can override at runtime. */
interface EEHooks {
applyABTest: (config: SiteConfig, visitorId: string) => { config: SiteConfig; assignment: ABAssignment | null };
needsReconsent: (consent: unknown, config: SiteConfig) => { required: boolean; reasons: string[] };
evaluateGpc: (config: SiteConfig, region: string | null) => GpcResult;
getVisitorRegion: () => string | null;
installGppApi: (cmpId: number, supportedApis: string[]) => void;
setGppDisplayStatus: (status: string) => void;
isGppApiInstalled: () => boolean;
updateGppConsent: (gpp: unknown) => string | undefined;
buildGppFromConsent: ((accepted: CategorySlug[], config: SiteConfig) => unknown) | null;
identifyUser: (jwt: string, config: SiteConfig) => Promise<string[]>;
clearIdentity: () => void;
isIdentified: () => boolean;
pushConsentToServer: (accepted: CategorySlug[], rejected: CategorySlug[], tc?: string, gpp?: string, gcm?: Record<string, string>) => void;
updateShopifyConsent: (accepted: CategorySlug[]) => void;
}
/** Default no-op hooks for CE mode. */
const _hooks: EEHooks = {
applyABTest: (config) => ({ config, assignment: null }),
needsReconsent: () => ({ required: false, reasons: [] }),
evaluateGpc: () => ({ detected: false, honoured: false }),
getVisitorRegion: () => null,
installGppApi: () => {},
setGppDisplayStatus: () => {},
isGppApiInstalled: () => false,
updateGppConsent: () => undefined,
buildGppFromConsent: null,
identifyUser: async () => [],
clearIdentity: () => {},
isIdentified: () => false,
pushConsentToServer: () => {},
updateShopifyConsent: () => {},
};
/**
* Register EE hooks. Called by the EE banner extension module.
* Exposed on `window.__consentos_hooks` for the EE bundle to call.
*/
export function registerEEHooks(hooks: Partial<EEHooks>): void {
Object.assign(_hooks, hooks);
}
// Expose for EE bundle
(window as any).__consentos_register_ee = registerEEHooks;
const ALL_CATEGORIES: CategorySlug[] = [
'necessary',
'functional',
'analytics',
'marketing',
'personalisation',
];
const NON_ESSENTIAL: CategorySlug[] = [
'functional',
'analytics',
'marketing',
'personalisation',
];
/** Initialise the banner. Called when the bundle loads. */
async function init(): Promise<void> {
const { siteId, apiBase, cdnBase } = window.__consentos;
if (!siteId) {
console.warn('[ConsentOS] No site ID configured');
return;
}
// Fetch site config — declared with let as A/B testing may replace it
let config: SiteConfig;
try {
const resp = await fetch(`${apiBase}/api/v1/config/sites/${siteId}`);
if (!resp.ok) throw new Error(`Config fetch failed: ${resp.status}`);
config = await resp.json();
} catch (err) {
console.error('[ConsentOS] Failed to load site config:', err);
config = buildDefaultConfig(siteId);
}
// Apply A/B test variant assignment (modifies banner_config if applicable)
const existingConsent = readConsent();
const visitorId = existingConsent?.visitorId ?? crypto.randomUUID?.() ?? String(Date.now());
const abResult = _hooks.applyABTest(config, visitorId);
config = abResult.config;
const abAssignment = abResult.assignment;
if (abAssignment) {
console.info(`[ConsentOS] A/B test assigned: variant "${abAssignment.variant.name}"`);
}
// Install the real CMP public API now that we have the config
installCmpApi(config);
// Check if existing consent needs re-consent. We still load
// translations and install the floating button even when no banner
// needs to show, so the visitor can re-open the preference centre
// at any time (GDPR Art. 7(3) — withdrawal must be as easy as
// giving consent).
let reconsentRequired = false;
if (existingConsent) {
const reconsent = _hooks.needsReconsent(existingConsent, config);
reconsentRequired = reconsent.required;
if (reconsent.required) {
console.info('[ConsentOS] Re-consent required:', reconsent.reasons.join(', '));
}
}
// Install GPP API if enabled
if (config.gpp_enabled) {
_hooks.installGppApi(0, config.gpp_supported_apis ?? []);
_hooks.setGppDisplayStatus('visible');
}
// Evaluate GPC signal
const visitorRegion = _hooks.getVisitorRegion();
const gpcResult = _hooks.evaluateGpc(config, visitorRegion);
if (gpcResult.detected) {
console.info(`[ConsentOS] GPC signal detected (honoured: ${gpcResult.honoured})`);
}
// Load translations
const locale = detectLocale();
const t = await loadTranslations(cdnBase, locale);
// Capture a closure that re-opens the banner with current consent
// pre-filled. Called from the floating button and from
// ``window.ConsentOS.showPreferences()``.
_openPreferences = () => {
removePreferencesButton();
const current = readConsent();
renderBanner(config, t, gpcResult, abAssignment, {
prefillCategories: current?.accepted ?? null,
showCategoriesInitially: true,
});
};
if (existingConsent && !reconsentRequired) {
// Banner isn't shown; expose the floating button straight away.
showPreferencesButton(config, t);
return;
}
// First-visit or re-consent: render the banner itself.
renderBanner(config, t, gpcResult, abAssignment);
}
/**
* Install the real window.ConsentOS API, replacing the loader stubs.
*
* `identifyUser(jwt)` syncs consent with the server. If the server profile
* fully covers all categories, the banner is suppressed. If categories are
* missing, only those categories need consent from the user.
*/
function installCmpApi(config: SiteConfig): void {
window.ConsentOS = {
identifyUser: async (jwt: string): Promise<string[]> => {
const missing = await _hooks.identifyUser(jwt, config);
return missing;
},
clearIdentity: (): void => {
_hooks.clearIdentity();
},
showPreferences: (): void => {
if (_openPreferences) {
_openPreferences();
} else {
console.warn('[ConsentOS] showPreferences called before init complete');
}
},
};
}
/** Build a default config when the API is unreachable. */
function buildDefaultConfig(siteId: string): SiteConfig {
return {
id: '',
site_id: siteId,
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
};
}
/** Options for re-opening the banner from the preferences button. */
interface OpenOptions {
/** Pre-check these category slugs (skips strict-necessary which is always on). */
prefillCategories: CategorySlug[] | null;
/** Open the banner with the category toggles visible. */
showCategoriesInitially: boolean;
}
/** Create a Shadow DOM host and render the banner inside it. */
function renderBanner(
config: SiteConfig,
t: TranslationStrings,
gpcResult?: GpcResult,
abAssignment?: ABAssignment | null,
openOptions?: OpenOptions,
): void {
const host = document.createElement('div');
host.id = 'consentos-banner-host';
const shadow = host.attachShadow({ mode: 'open' });
const titleId = 'cmp-title';
const descId = 'cmp-desc';
shadow.innerHTML = `
<style>${getBannerStyles(config)}</style>
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
<div class="consentos-banner__content">
<div class="consentos-banner__text">
<p class="consentos-banner__title" id="${titleId}">${t.title}</p>
<p class="consentos-banner__description" id="${descId}">
${renderDescription(t.description, config)}
</p>
</div>
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
${renderCategories(t)}
</div>
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
${t.rejectAll}
</button>
<button class="cmp-btn cmp-btn--secondary" data-action="settings" type="button" aria-expanded="false" aria-controls="consentos-categories">
${t.managePreferences}
</button>
<button class="cmp-btn cmp-btn--primary" data-action="accept" type="button">
${t.acceptAll}
</button>
</div>
</div>
</div>
`;
// Attach event listeners
const banner = shadow.querySelector('.consentos-banner') as HTMLElement;
const categoriesDiv = shadow.querySelector('#consentos-categories') as HTMLElement;
const settingsBtn = shadow.querySelector('[data-action="settings"]') as HTMLElement;
// Hide or show the category toggles depending on entry mode.
// Opening via ``showPreferences`` lands directly on the toggles.
const startWithCategories = openOptions?.showCategoriesInitially === true;
categoriesDiv.style.display = startWithCategories ? 'block' : 'none';
settingsBtn.setAttribute('aria-expanded', startWithCategories ? 'true' : 'false');
// Pre-fill category checkboxes from existing consent when re-opened.
if (openOptions?.prefillCategories) {
const prefill = new Set(openOptions.prefillCategories);
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
const slug = input.getAttribute('data-category') as CategorySlug;
if (slug === 'necessary') return; // always on + disabled
input.checked = prefill.has(slug);
});
}
// Create live region for screen reader announcements
const liveRegion = createLiveRegion(shadow);
// Set up keyboard navigation
const cleanupFocusTrap = trapFocus(banner);
const cleanupEscape = onEscape(banner, () => {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
});
shadow.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
if (action === 'accept') {
// Explicit Accept All overrides GPC — user choice takes precedence
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'reject') {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'settings') {
const isHidden = categoriesDiv.style.display === 'none';
categoriesDiv.style.display = isHidden ? 'block' : 'none';
settingsBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
announce(liveRegion, isHidden ? t.managePreferences : t.title);
} else if (action === 'save') {
const accepted = getSelectedCategories(shadow);
const rejected = NON_ESSENTIAL.filter((c) => !accepted.includes(c));
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
}
});
});
document.body.appendChild(host);
// Move focus into the banner for keyboard users
focusFirst(banner);
}
/** Render category toggles HTML. */
function renderCategories(t: TranslationStrings): string {
const categories = [
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
];
return (
categories
.map(
(cat) => `
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name" id="cmp-cat-${cat.slug}">${cat.name}</span>
<span class="cmp-category__desc" id="cmp-cat-${cat.slug}-desc">${cat.desc}</span>
</div>
<input type="checkbox" data-category="${cat.slug}"
aria-labelledby="cmp-cat-${cat.slug}"
aria-describedby="cmp-cat-${cat.slug}-desc"
${cat.locked ? 'checked disabled' : ''}
/>
</label>
`
)
.join('') +
`<button class="cmp-btn cmp-btn--primary cmp-btn--save" data-action="save" type="button">
${t.savePreferences}
</button>`
);
}
/** Read which categories are checked in the shadow DOM. */
function getSelectedCategories(shadow: ShadowRoot): CategorySlug[] {
const checked: CategorySlug[] = ['necessary'];
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
if (input.checked) {
checked.push(input.getAttribute('data-category') as CategorySlug);
}
});
return [...new Set(checked)];
}
/** Handle a consent decision: write cookie, update GCM, GPP, post to API, dispatch event. */
function handleConsent(
accepted: CategorySlug[],
rejected: CategorySlug[],
config: SiteConfig,
gpcResult?: GpcResult,
abAssignment?: ABAssignment | null,
t?: TranslationStrings,
): void {
const existing = readConsent();
const gcmState = buildGcmStateFromCategories(accepted);
// Generate GPP string if GPP is enabled
let gppString: string | undefined;
if (config.gpp_enabled && _hooks.isGppApiInstalled() && _hooks.buildGppFromConsent) {
const gpp = _hooks.buildGppFromConsent(accepted, config);
gppString = _hooks.updateGppConsent(gpp);
_hooks.setGppDisplayStatus('hidden');
}
const state = buildConsentState(
accepted,
rejected,
existing?.visitorId,
undefined,
gcmState,
config.id,
gppString,
gpcResult?.detected,
gpcResult?.honoured,
);
// Write first-party cookie
writeConsent(state, config.consent_expiry_days);
// Release blocked scripts for accepted categories
updateAcceptedCategories(accepted);
// Update Google Consent Mode
if (config.gcm_enabled) {
updateGcm(gcmState);
}
// Update Shopify Customer Privacy API
if (config.shopify_privacy_enabled) {
_hooks.updateShopifyConsent(accepted);
}
// Post consent to API (fire and forget)
const { siteId, apiBase } = window.__consentos;
fetch(`${apiBase}/api/v1/consent/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
site_id: siteId,
visitor_id: state.visitorId,
action: determineAction(accepted, rejected),
categories_accepted: accepted,
categories_rejected: rejected,
gcm_state: gcmState,
gpc_detected: gpcResult?.detected ?? false,
gpc_honoured: gpcResult?.honoured ?? false,
page_url: window.location.href,
ab_test_id: abAssignment?.abTestId ?? null,
ab_variant_id: abAssignment?.variantId ?? null,
}),
}).catch((err) => console.warn('[ConsentOS] Failed to record consent:', err));
// Push to server if user is identified (non-blocking background sync)
if (_hooks.isIdentified()) {
_hooks.pushConsentToServer(accepted, rejected, undefined, gppString, gcmState);
}
// Dispatch event
document.dispatchEvent(
new CustomEvent('consentos:consent-change', { detail: { accepted } })
);
if (typeof window.dataLayer !== 'undefined') {
window.dataLayer.push({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
}
// Bridge for the standalone ConsentOS GTM template — when the
// template is loaded on the page it registers a global callback so
// it can react to consent changes. Lives in its own repo.
if (typeof (window as any).__consentos_gtm_consent_update === 'function') {
(window as any).__consentos_gtm_consent_update({ accepted });
}
// Re-expose the floating preferences button so the visitor can
// withdraw or change their decision later.
if (t) {
showPreferencesButton(config, t);
}
}
/**
* Render the banner description with template variables and markdown links.
*
* Replaces `{{privacy_policy}}` and `{{terms}}` with their URLs from config,
* then converts `[text](url)` markdown links to `<a>` tags.
* Links with empty URLs (because the config field is unset) are removed.
*/
function renderDescription(description: string, config: SiteConfig): string {
const rendered = interpolate(description, {
privacy_policy: config.privacy_policy_url ?? '',
terms: config.terms_url ?? '',
});
return renderLinks(rendered);
}
/** Determine the consent action string. */
function determineAction(
accepted: CategorySlug[],
rejected: CategorySlug[]
): string {
if (rejected.length === 0) return 'accept_all';
if (accepted.length === 1 && accepted[0] === 'necessary') return 'reject_all';
return 'custom';
}
/** Remove the banner from the DOM. */
function removeBanner(
host: HTMLElement,
...cleanups: Array<() => void>
): void {
cleanups.forEach((fn) => fn());
const useMotion = !prefersReducedMotion();
if (useMotion) {
host.style.opacity = '0';
host.style.transition = 'opacity 0.3s ease';
setTimeout(() => host.remove(), 300);
} else {
host.remove();
}
}
// -- Floating "manage preferences" button ---------------------------------
const _PREFERENCES_BUTTON_ID = 'cmp-preferences-button';
/** Remove the floating preferences button if present. */
function removePreferencesButton(): void {
const existing = document.getElementById(_PREFERENCES_BUTTON_ID);
if (existing) {
existing.remove();
}
}
/**
* Render a persistent floating button that re-opens the banner.
*
* Required by GDPR Art. 7(3) — withdrawing consent must be as easy
* as giving it. Positioned opposite the banner's corner by default
* so it doesn't sit behind the initial banner if displayed together.
*/
function showPreferencesButton(config: SiteConfig, t: TranslationStrings): void {
removePreferencesButton();
// Honour the site's opt-out: operators can disable the floating
// button via ``banner_config.show_preferences_button = false``.
const bc = config.banner_config ?? null;
if (bc && (bc as Record<string, unknown>).show_preferences_button === false) {
return;
}
const position =
(bc as Record<string, unknown> | null)?.preferences_button_position === 'left'
? 'left: 20px;'
: 'right: 20px;';
const host = document.createElement('div');
host.id = _PREFERENCES_BUTTON_ID;
const shadow = host.attachShadow({ mode: 'open' });
const label =
t.managePreferences || 'Cookie preferences';
shadow.innerHTML = `
<style>
:host {
position: fixed;
bottom: 20px;
${position}
z-index: 2147483646;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
background: #111;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
font: 500 0.85rem system-ui, -apple-system, sans-serif;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
button:hover { opacity: 0.92; }
button:focus-visible {
outline: 2px solid #4a90e2;
outline-offset: 2px;
}
svg { width: 1rem; height: 1rem; flex-shrink: 0; }
@media (prefers-reduced-motion: no-preference) {
button { transition: transform 0.15s ease; }
button:hover { transform: translateY(-1px); }
}
</style>
<button type="button" aria-label="${label}" title="${label}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<path d="M8.5 8.5v.01"/>
<path d="M16 15.5v.01"/>
<path d="M12 12v.01"/>
<path d="M11 17v.01"/>
<path d="M7 14v.01"/>
</svg>
<span>${label}</span>
</button>
`;
const btn = shadow.querySelector('button') as HTMLButtonElement;
btn.addEventListener('click', () => {
if (_openPreferences) {
_openPreferences();
}
});
document.body.appendChild(host);
}
/** Resolve position CSS for the banner based on display mode. */
function getPositionCss(bc: BannerConfig | null): string {
const mode = bc?.displayMode ?? 'bottom_banner';
const radius = bc?.borderRadius ?? 6;
const cornerPos = bc?.cornerPosition ?? 'right';
switch (mode) {
case 'top_banner':
return 'position: fixed; top: 0; left: 0; right: 0; z-index: 2147483647;';
case 'overlay':
return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483647; width: 90%; max-width: 600px; border-radius: ${radius}px;`;
case 'corner_popup': {
const side = cornerPos === 'left' ? 'left: 20px;' : 'right: 20px;';
return `position: fixed; bottom: 20px; ${side} z-index: 2147483647; width: 380px; max-width: calc(100% - 40px); border-radius: ${radius}px;`;
}
case 'bottom_banner':
default:
return 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483647;';
}
}
/** Resolve per-button inline style from ButtonConfig. */
function getButtonCss(
btnCfg: ButtonConfig | undefined,
fallbackBg: string,
fallbackColor: string,
fallbackBorder: string,
radius: number,
): string {
const style = btnCfg?.style;
const bg = style === 'text' || style === 'outline'
? 'transparent'
: btnCfg?.backgroundColour ?? fallbackBg;
const color = btnCfg?.textColour ?? fallbackColor;
const border = btnCfg?.borderColour
? `1px solid ${btnCfg.borderColour}`
: style === 'outline'
? `1px solid ${color}`
: style === 'text'
? 'none'
: fallbackBorder;
return `background: ${bg}; color: ${color}; border: ${border}; border-radius: ${radius}px;`;
}
/** Banner CSS — isolated inside Shadow DOM. */
function getBannerStyles(config: SiteConfig): string {
const bc = config.banner_config;
const bg = bc?.backgroundColour ?? '#ffffff';
const text = bc?.textColour ?? '#0E1929'; // ConsentOS Ink
const primary = bc?.primaryColour ?? '#2C6AE4'; // ConsentOS Action Blue
const font = bc?.fontFamily ?? '-apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif';
const radius = bc?.borderRadius ?? 6;
const mode = bc?.displayMode ?? 'bottom_banner';
const acceptCss = getButtonCss(bc?.acceptButton, primary, '#ffffff', 'none', radius);
const rejectCss = getButtonCss(bc?.rejectButton, 'transparent', text, '1px solid rgba(0,0,0,0.2)', radius);
const manageCss = getButtonCss(bc?.manageButton, 'transparent', text, '1px solid rgba(0,0,0,0.2)', radius);
return `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.consentos-banner {
${getPositionCss(bc)}
background: ${bg};
color: ${text};
font-family: ${font}, sans-serif;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: ${mode === 'overlay' || mode === 'corner_popup' ? radius + 'px' : '0'};
}
.consentos-banner__content {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
}
.consentos-banner__title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.consentos-banner__description {
margin-bottom: 16px;
opacity: 0.85;
}
.consentos-banner__link {
color: ${primary};
text-decoration: underline;
}
.consentos-banner__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.cmp-btn {
padding: 10px 20px;
border-radius: ${radius}px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: opacity 0.2s;
font-family: inherit;
}
.cmp-btn:hover { opacity: 0.9; }
.cmp-btn:focus-visible {
outline: 2px solid ${primary};
outline-offset: 2px;
}
.cmp-btn--primary { ${acceptCss} }
.cmp-btn--secondary[data-action="reject"] { ${rejectCss} }
.cmp-btn--secondary[data-action="settings"] { ${manageCss} }
.cmp-btn--secondary { ${rejectCss} }
.consentos-banner__categories {
margin-bottom: 16px;
}
.cmp-category {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
cursor: pointer;
}
.cmp-category__info {
display: flex;
flex-direction: column;
flex: 1;
margin-right: 12px;
}
.cmp-category__name {
font-weight: 500;
}
.cmp-category__desc {
font-size: 12px;
opacity: 0.7;
}
.cmp-category input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: ${primary};
}
.cmp-btn--save {
margin-top: 12px;
width: 100%;
}
/* Visually hidden but accessible to screen readers */
.cmp-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.consentos-banner__actions {
flex-direction: column;
}
.cmp-btn {
width: 100%;
text-align: center;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0s !important;
animation-duration: 0s !important;
}
}
`;
}
// Auto-init on load
init();

522
apps/banner/src/blocker.ts Normal file
View File

@@ -0,0 +1,522 @@
/**
* blocker.ts — Script interceptor, cookie blocker, and release manager.
*
* Installs before any third-party scripts run. Intercepts script creation,
* proxies document.cookie and Storage writes, and maintains a queue of
* blocked resources that are released per-category when consent is granted.
*/
import type { CategorySlug, InitiatorMapping } from './types';
/** A script element that was blocked, along with its assigned category. */
interface BlockedScript {
/** The original script element or a clone of it. */
element: HTMLScriptElement;
/** The consent category this script belongs to. */
category: CategorySlug;
}
/** Pattern-to-category mapping for URL-based script classification. */
interface ScriptPattern {
pattern: RegExp;
category: CategorySlug;
}
/** Categories that have been consented to. */
let acceptedCategories: Set<CategorySlug> = new Set(['necessary']);
/** Queue of blocked scripts awaiting consent. */
const blockedScripts: BlockedScript[] = [];
/** URL patterns for classifying scripts by category. */
const scriptPatterns: ScriptPattern[] = [];
/** Root initiator URL → category mappings for root-level blocking. */
const initiatorMappings: Array<{ pattern: RegExp; category: CategorySlug }> = [];
/** Whether the blocker has been installed. */
let installed = false;
/** Original document.createElement reference. */
let originalCreateElement: typeof document.createElement;
/** Original document.cookie descriptor. */
let originalCookieDescriptor: PropertyDescriptor | undefined;
/** Original Storage.prototype.setItem reference. */
let originalLocalStorageSetItem: typeof Storage.prototype.setItem;
// ─── Well-known script patterns (built-in defaults) ───
const BUILTIN_PATTERNS: ScriptPattern[] = [
// Analytics
{ pattern: /google-analytics\.com/i, category: 'analytics' },
{ pattern: /googletagmanager\.com/i, category: 'analytics' },
{ pattern: /gtag\/js/i, category: 'analytics' },
{ pattern: /analytics\./i, category: 'analytics' },
{ pattern: /hotjar\.com/i, category: 'analytics' },
{ pattern: /clarity\.ms/i, category: 'analytics' },
{ pattern: /plausible\.io/i, category: 'analytics' },
{ pattern: /matomo\./i, category: 'analytics' },
// Marketing
{ pattern: /doubleclick\.net/i, category: 'marketing' },
{ pattern: /facebook\.net/i, category: 'marketing' },
{ pattern: /fbevents\.js/i, category: 'marketing' },
{ pattern: /connect\.facebook/i, category: 'marketing' },
{ pattern: /ads-twitter\.com/i, category: 'marketing' },
{ pattern: /linkedin\.com\/insight/i, category: 'marketing' },
{ pattern: /snap\.licdn\.com/i, category: 'marketing' },
{ pattern: /tiktok\.com\/i18n/i, category: 'marketing' },
{ pattern: /googlesyndication\.com/i, category: 'marketing' },
{ pattern: /adservice\.google/i, category: 'marketing' },
// Functional
{ pattern: /intercom\.com/i, category: 'functional' },
{ pattern: /crisp\.chat/i, category: 'functional' },
{ pattern: /livechatinc\.com/i, category: 'functional' },
{ pattern: /zendesk\.com/i, category: 'functional' },
];
// ─── Public API ───
/** Install all interception hooks. Call once, as early as possible. */
export function installBlocker(): void {
if (installed) return;
installed = true;
// Merge built-in patterns
scriptPatterns.push(...BUILTIN_PATTERNS);
// Install hooks
installCreateElementOverride();
installMutationObserver();
installCookieProxy();
installStorageProxy();
}
/** Add custom URL-to-category patterns (e.g. from site config allow-list). */
export function addScriptPatterns(patterns: Array<{ pattern: string; category: CategorySlug }>): void {
for (const p of patterns) {
try {
scriptPatterns.push({ pattern: new RegExp(p.pattern, 'i'), category: p.category });
} catch {
console.warn(`[ConsentOS] Invalid script pattern: ${p.pattern}`);
}
}
}
/**
* Load initiator mappings from the site config. Each mapping identifies a root
* script URL that is known to set cookies in a given category via a chain of
* child scripts. Blocking the root prevents the entire chain from executing.
*/
export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
for (const m of mappings) {
try {
initiatorMappings.push({ pattern: new RegExp(m.root_script, 'i'), category: m.category });
} catch {
console.warn(`[ConsentOS] Invalid initiator pattern: ${m.root_script}`);
}
}
}
/**
* Update the set of accepted categories and release any blocked scripts
* that now have consent.
*/
export function updateAcceptedCategories(categories: CategorySlug[]): void {
acceptedCategories = new Set(categories);
releaseBlockedScripts();
}
/** Get the current blocked script count (useful for debugging/reporting). */
export function getBlockedCount(): number {
return blockedScripts.length;
}
/** Check whether a given category is currently accepted. */
export function isCategoryAllowed(category: CategorySlug): boolean {
return acceptedCategories.has(category);
}
// ─── Script interception ───
/**
* Override document.createElement to intercept <script> creation.
* When a script is created and its src matches a known pattern,
* we set its type to 'text/blocked' to prevent execution.
*/
function installCreateElementOverride(): void {
originalCreateElement = document.createElement.bind(document);
document.createElement = function (
tagName: string,
options?: ElementCreationOptions
): HTMLElement {
const element = originalCreateElement(tagName, options);
if (tagName.toLowerCase() === 'script') {
const script = element as HTMLScriptElement;
wrapScriptElement(script);
}
return element;
} as typeof document.createElement;
}
/**
* Wrap a script element's `src` setter so that when a src is assigned,
* we can classify and potentially block it.
*/
function wrapScriptElement(script: HTMLScriptElement): void {
const originalSrcDescriptor = Object.getOwnPropertyDescriptor(
HTMLScriptElement.prototype,
'src'
);
if (!originalSrcDescriptor) return;
let pendingSrc = '';
Object.defineProperty(script, 'src', {
get() {
return pendingSrc || originalSrcDescriptor.get?.call(this) || '';
},
set(value: string) {
pendingSrc = value;
const category = classifyScript(value, script);
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
// Block: change type to prevent execution
script.type = 'text/blocked';
script.setAttribute('data-consentos-blocked', 'true');
script.setAttribute('data-consentos-category', category);
script.setAttribute('data-consentos-original-src', value);
}
originalSrcDescriptor.set?.call(this, value);
},
configurable: true,
enumerable: true,
});
}
/**
* MutationObserver watches for script elements being added to the DOM.
* If a script should be blocked, we remove it and queue it.
*/
function installMutationObserver(): void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLScriptElement) {
handleInsertedScript(node);
}
}
}
});
// Observe as early as possible
if (document.documentElement) {
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
}
/** Handle a script element that was just inserted into the DOM. */
function handleInsertedScript(script: HTMLScriptElement): void {
// Skip if it's our own script or already processed
if (script.hasAttribute('data-consentos-allowed') || script.hasAttribute('data-consentos-queued')) {
return;
}
// Check explicit data-category attribute first
const explicitCategory = script.getAttribute('data-category') as CategorySlug | null;
const src = script.getAttribute('data-consentos-original-src') || script.src || '';
const category = explicitCategory || classifyScript(src, script);
// Necessary scripts always pass through
if (!category || category === 'necessary') {
return;
}
// If already consented, allow through
if (acceptedCategories.has(category)) {
return;
}
// Block: remove from DOM and queue
script.setAttribute('data-consentos-queued', 'true');
// Clone the script for later re-insertion
const clone = originalCreateElement('script') as HTMLScriptElement;
// Copy attributes
for (const attr of Array.from(script.attributes)) {
if (attr.name !== 'type' && attr.name !== 'data-consentos-blocked' && attr.name !== 'data-consentos-queued') {
clone.setAttribute(attr.name, attr.value);
}
}
// Copy inline content
if (script.textContent) {
clone.textContent = script.textContent;
}
// Restore original src if it was rewritten
const originalSrc = script.getAttribute('data-consentos-original-src');
if (originalSrc) {
clone.setAttribute('data-consentos-original-src', originalSrc);
}
blockedScripts.push({ element: clone, category });
// Remove from DOM to prevent execution
if (script.parentNode) {
script.parentNode.removeChild(script);
}
}
// ─── Cookie proxy ───
/**
* Proxy document.cookie setter to block cookie writes from
* non-essential categories. We check the cookie name against
* known patterns and the ConsentOS's own cookie is always allowed.
*/
function installCookieProxy(): void {
originalCookieDescriptor = Object.getOwnPropertyDescriptor(
Document.prototype,
'cookie'
);
if (!originalCookieDescriptor) return;
Object.defineProperty(document, 'cookie', {
get() {
return originalCookieDescriptor!.get?.call(document) ?? '';
},
set(value: string) {
// Always allow ConsentOS's own cookies
if (value.startsWith('_consentos_')) {
originalCookieDescriptor!.set?.call(document, value);
return;
}
// If consent hasn't been collected yet and we're in opt-in mode,
// block all non-essential cookie writes
if (!allNonEssentialConsented()) {
const cookieName = parseCookieName(value);
const category = classifyCookie(cookieName);
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
// Silently block
return;
}
}
originalCookieDescriptor!.set?.call(document, value);
},
configurable: true,
});
}
// ─── Storage proxy ───
/** Proxy localStorage and sessionStorage setItem to block non-essential writes. */
function installStorageProxy(): void {
if (typeof Storage !== 'undefined') {
originalLocalStorageSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function (key: string, value: string): void {
if (shouldBlockStorageWrite(key)) return;
originalLocalStorageSetItem.call(this, key, value);
};
}
}
/** Check if a storage write should be blocked. */
function shouldBlockStorageWrite(key: string): boolean {
// Always allow ConsentOS's own storage
if (key.startsWith('_consentos_')) return false;
// If all non-essential categories are consented, allow everything
if (allNonEssentialConsented()) return false;
// Block known tracking storage keys
const category = classifyStorageKey(key);
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
return true;
}
return false;
}
// ─── Release manager ───
/** Release blocked scripts whose categories are now accepted. */
function releaseBlockedScripts(): void {
const toRelease: BlockedScript[] = [];
const remaining: BlockedScript[] = [];
for (const blocked of blockedScripts) {
if (acceptedCategories.has(blocked.category)) {
toRelease.push(blocked);
} else {
remaining.push(blocked);
}
}
// Clear and repopulate the queue
blockedScripts.length = 0;
blockedScripts.push(...remaining);
// Re-insert released scripts in order
for (const { element } of toRelease) {
const script = originalCreateElement('script') as HTMLScriptElement;
// Copy all attributes
for (const attr of Array.from(element.attributes)) {
if (attr.name !== 'data-consentos-blocked' && attr.name !== 'data-consentos-queued' && attr.name !== 'data-consentos-category') {
script.setAttribute(attr.name, attr.value);
}
}
// Use original src if stored
const originalSrc = element.getAttribute('data-consentos-original-src');
if (originalSrc) {
script.src = originalSrc;
script.removeAttribute('data-consentos-original-src');
}
// Copy inline script content
if (element.textContent && !script.src) {
script.textContent = element.textContent;
}
// Mark as allowed so the observer doesn't re-block it
script.setAttribute('data-consentos-allowed', 'true');
// Insert into head
(document.head || document.documentElement).appendChild(script);
}
}
// ─── Classification helpers ───
/** Classify a script by its URL against known patterns and initiator mappings. */
function classifyScript(src: string, script: HTMLScriptElement): CategorySlug | null {
if (!src) return null;
// Explicit data-category always wins
const explicit = script.getAttribute('data-category') as CategorySlug | null;
if (explicit) return explicit;
// Match against URL patterns
for (const { pattern, category } of scriptPatterns) {
if (pattern.test(src)) return category;
}
// Check initiator mappings — block root scripts that are known to set
// cookies in non-consented categories via downstream child scripts
for (const { pattern, category } of initiatorMappings) {
if (pattern.test(src)) return category;
}
return null;
}
/** Well-known cookie name patterns mapped to categories. */
const COOKIE_PATTERNS: Array<{ pattern: RegExp; category: CategorySlug }> = [
// Analytics
{ pattern: /^_ga/i, category: 'analytics' },
{ pattern: /^_gid$/i, category: 'analytics' },
{ pattern: /^_gat/i, category: 'analytics' },
{ pattern: /^_hjSession/i, category: 'analytics' },
{ pattern: /^_hj/i, category: 'analytics' },
{ pattern: /^_pk_/i, category: 'analytics' },
{ pattern: /^_clck$/i, category: 'analytics' },
{ pattern: /^_clsk$/i, category: 'analytics' },
// Marketing
{ pattern: /^_fbp$/i, category: 'marketing' },
{ pattern: /^_fbc$/i, category: 'marketing' },
{ pattern: /^_gcl_/i, category: 'marketing' },
{ pattern: /^IDE$/i, category: 'marketing' },
{ pattern: /^NID$/i, category: 'marketing' },
{ pattern: /^test_cookie$/i, category: 'marketing' },
{ pattern: /^_uetsid/i, category: 'marketing' },
{ pattern: /^_uetvid/i, category: 'marketing' },
// Functional
{ pattern: /^intercom-/i, category: 'functional' },
{ pattern: /^crisp-/i, category: 'functional' },
];
/** Classify a cookie by its name. */
function classifyCookie(name: string): CategorySlug | null {
for (const { pattern, category } of COOKIE_PATTERNS) {
if (pattern.test(name)) return category;
}
return null;
}
/** Well-known storage key patterns. */
const STORAGE_PATTERNS: Array<{ pattern: RegExp; category: CategorySlug }> = [
{ pattern: /^_ga/i, category: 'analytics' },
{ pattern: /^_hj/i, category: 'analytics' },
{ pattern: /^intercom\./i, category: 'functional' },
{ pattern: /^crisp-/i, category: 'functional' },
{ pattern: /^fb_/i, category: 'marketing' },
];
/** Classify a storage key by known patterns. */
function classifyStorageKey(key: string): CategorySlug | null {
for (const { pattern, category } of STORAGE_PATTERNS) {
if (pattern.test(key)) return category;
}
return null;
}
/** Parse the cookie name from a Set-Cookie string. */
function parseCookieName(cookieString: string): string {
const eqIndex = cookieString.indexOf('=');
if (eqIndex === -1) return cookieString.trim();
return cookieString.substring(0, eqIndex).trim();
}
/** Check if all non-essential categories have been consented to. */
function allNonEssentialConsented(): boolean {
return (
acceptedCategories.has('functional') &&
acceptedCategories.has('analytics') &&
acceptedCategories.has('marketing') &&
acceptedCategories.has('personalisation')
);
}
// ─── Teardown (for testing) ───
/** Remove all interception hooks. Used in tests. */
export function uninstallBlocker(): void {
if (!installed) return;
// Restore document.createElement
if (originalCreateElement) {
document.createElement = originalCreateElement;
}
// Restore document.cookie
if (originalCookieDescriptor) {
Object.defineProperty(document, 'cookie', originalCookieDescriptor);
}
// Restore storage
if (originalLocalStorageSetItem) {
Storage.prototype.setItem = originalLocalStorageSetItem;
}
// Clear state
blockedScripts.length = 0;
scriptPatterns.length = 0;
initiatorMappings.length = 0;
acceptedCategories = new Set(['necessary']);
installed = false;
}

View File

@@ -0,0 +1,91 @@
import type { CategorySlug, ConsentState } from './types';
const COOKIE_NAME = '_consentos_consent';
const BANNER_VERSION = '0.1.0';
/** Generate a simple visitor ID (UUID v4-like). */
export function generateVisitorId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for older browsers
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/** Read the consent cookie and parse the stored state. */
export function readConsent(): ConsentState | null {
if (typeof document === 'undefined') return null;
const match = document.cookie
.split('; ')
.find((row) => row.startsWith(`${COOKIE_NAME}=`));
if (!match) return null;
try {
const value = decodeURIComponent(match.split('=')[1]);
return JSON.parse(value) as ConsentState;
} catch {
return null;
}
}
/** Write consent state to a first-party cookie. */
export function writeConsent(
state: ConsentState,
expiryDays: number = 365
): void {
if (typeof document === 'undefined') return;
const value = encodeURIComponent(JSON.stringify(state));
const expires = new Date(Date.now() + expiryDays * 86400000).toUTCString();
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${COOKIE_NAME}=${value}; path=/; expires=${expires}; SameSite=Lax${secure}`;
}
/** Build a ConsentState for a given action. */
export function buildConsentState(
accepted: CategorySlug[],
rejected: CategorySlug[],
existingVisitorId?: string,
tcString?: string,
gcmState?: Record<string, 'granted' | 'denied'>,
configVersion?: string,
gppString?: string,
gpcDetected?: boolean,
gpcHonoured?: boolean,
): ConsentState {
return {
visitorId: existingVisitorId ?? generateVisitorId(),
accepted,
rejected,
consentedAt: new Date().toISOString(),
bannerVersion: BANNER_VERSION,
tcString,
gppString,
gcmState,
configVersion,
gpcDetected,
gpcHonoured,
};
}
/** Clear the consent cookie. */
export function clearConsent(): void {
if (typeof document === 'undefined') return;
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${COOKIE_NAME}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax${secure}`;
}
/** Check whether consent has been given (any state exists). */
export function hasConsent(): boolean {
return readConsent() !== null;
}
/** Check whether a specific category has been accepted. */
export function isCategoryAccepted(category: CategorySlug): boolean {
const state = readConsent();
if (!state) return category === 'necessary'; // Necessary always allowed
return state.accepted.includes(category);
}

74
apps/banner/src/gcm.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { GcmConsentType } from './types';
type GcmState = Partial<Record<GcmConsentType, 'granted' | 'denied'>>;
declare global {
interface Window {
dataLayer: unknown[];
}
function gtag(...args: unknown[]): void;
}
/** Ensure dataLayer and gtag function exist. */
function ensureGtag(): void {
window.dataLayer = window.dataLayer || [];
if (typeof window.gtag !== 'function') {
window.gtag = function gtag() {
// eslint-disable-next-line prefer-rest-params
window.dataLayer.push(arguments);
};
}
}
/** Set Google Consent Mode defaults (called before any tags fire). */
export function setGcmDefaults(defaults: GcmState): void {
ensureGtag();
gtag('consent', 'default', {
...defaults,
wait_for_update: 500,
});
}
/** Update Google Consent Mode after user makes a choice. */
export function updateGcm(state: GcmState): void {
ensureGtag();
gtag('consent', 'update', state);
}
/** Build the default denied state for all consent types. */
export function buildDeniedDefaults(): GcmState {
return {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted', // Always granted
};
}
/** Build GCM state from accepted categories. */
export function buildGcmStateFromCategories(
accepted: string[]
): GcmState {
const state = buildDeniedDefaults();
if (accepted.includes('functional')) {
state.functionality_storage = 'granted';
state.personalization_storage = 'granted';
}
if (accepted.includes('analytics')) {
state.analytics_storage = 'granted';
}
if (accepted.includes('marketing')) {
state.ad_storage = 'granted';
state.ad_user_data = 'granted';
state.ad_personalization = 'granted';
}
if (accepted.includes('personalisation')) {
state.personalization_storage = 'granted';
}
return state;
}

148
apps/banner/src/gpc.ts Normal file
View File

@@ -0,0 +1,148 @@
/**
* Global Privacy Control (GPC) signal detection and jurisdiction-aware
* auto-opt-out.
*
* GPC is a browser-level signal (`navigator.globalPrivacyControl === true`)
* that communicates a user's intent to opt out of the sale/sharing of their
* personal data. Several US state laws legally require businesses to honour
* this signal: California (CCPA/CPRA), Colorado (CPA), Connecticut (CTDPA),
* Texas (TDPSA), and Montana (MTCDPA).
*
* @see https://globalprivacycontrol.github.io/gpc-spec/
*/
import type { CategorySlug } from './types';
// ── Types ────────────────────────────────────────────────────────────
declare global {
interface Navigator {
globalPrivacyControl?: boolean;
}
}
/** Result of GPC detection and evaluation. */
export interface GpcResult {
/** Whether the browser is sending the GPC signal. */
detected: boolean;
/** Whether GPC was honoured (auto-opt-out applied). */
honoured: boolean;
/** The visitor's detected region code (e.g. 'US-CA'), if available. */
region: string | null;
}
/** GPC-related site configuration fields. */
export interface GpcConfig {
/** Whether to detect GPC at all. */
gpc_enabled: boolean;
/** Region codes where GPC is legally required. */
gpc_jurisdictions: string[];
/** If true, honour GPC regardless of jurisdiction. */
gpc_global_honour: boolean;
}
/** Default jurisdictions where GPC must be legally honoured. */
export const DEFAULT_GPC_JURISDICTIONS: string[] = [
'US-CA', // California — CCPA/CPRA
'US-CO', // Colorado — CPA
'US-CT', // Connecticut — CTDPA
'US-TX', // Texas — TDPSA
'US-MT', // Montana — MTCDPA
];
// ── Detection ────────────────────────────────────────────────────────
/** Check whether the browser is sending the GPC signal. */
export function isGpcEnabled(): boolean {
if (typeof navigator === 'undefined') return false;
return navigator.globalPrivacyControl === true;
}
// ── Region detection ─────────────────────────────────────────────────
/**
* Detect the visitor's region from the CMP context.
*
* Uses the `__cmp.visitorRegion` field, which is set by the loader
* from GeoIP headers (e.g. Cloudflare's `CF-IPCountry` + `CF-Region`)
* or from a GeoIP API call.
*/
export function getVisitorRegion(): string | null {
if (typeof window === 'undefined') return null;
return (window as { __cmp?: { visitorRegion?: string } }).__cmp?.visitorRegion ?? null;
}
// ── Jurisdiction check ───────────────────────────────────────────────
/**
* Determine whether GPC should be honoured for the given region.
*
* @param region The visitor's region code (e.g. 'US-CA').
* @param config GPC configuration from the site config.
* @returns true if GPC should be honoured.
*/
export function shouldHonourGpc(
region: string | null,
config: GpcConfig,
): boolean {
if (!config.gpc_enabled) return false;
// Global honour overrides jurisdiction check
if (config.gpc_global_honour) return true;
if (!region) return false;
const jurisdictions = config.gpc_jurisdictions.length > 0
? config.gpc_jurisdictions
: DEFAULT_GPC_JURISDICTIONS;
return jurisdictions.includes(region);
}
// ── Auto-opt-out categories ──────────────────────────────────────────
/**
* Categories that should be rejected when GPC is honoured.
* GPC specifically relates to sale/sharing/targeted advertising,
* which maps to the 'marketing' and 'personalisation' categories.
* Analytics may also be affected depending on interpretation.
*/
export const GPC_OPTOUT_CATEGORIES: CategorySlug[] = [
'marketing',
'personalisation',
];
/**
* Categories that remain accepted when GPC auto-opt-out is applied.
* 'necessary' is always accepted; 'functional' and 'analytics' are
* not directly related to sale/sharing.
*/
export const GPC_ACCEPTED_CATEGORIES: CategorySlug[] = [
'necessary',
'functional',
'analytics',
];
// ── Full evaluation ──────────────────────────────────────────────────
/**
* Evaluate GPC signal and determine whether to apply auto-opt-out.
*
* @param config GPC configuration from the site config.
* @param region The visitor's region code, or null if unknown.
* @returns GpcResult with detection and honouring status.
*/
export function evaluateGpc(
config: GpcConfig,
region: string | null = null,
): GpcResult {
const detected = isGpcEnabled();
if (!detected || !config.gpc_enabled) {
return { detected, honoured: false, region };
}
const honoured = shouldHonourGpc(region, config);
return { detected, honoured, region };
}

334
apps/banner/src/gpp-api.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* IAB GPP CMP API — `__gpp()` global interface.
*
* Implements the client-side JavaScript API that ad tech vendors call to
* retrieve the user's consent state under the Global Privacy Platform.
* Analogous to `__tcfapi()` for TCF v2.2.
*
* Supported commands: ping, getGPPData, getSection, hasSection,
* addEventListener, removeEventListener.
*
* @see https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md
*/
import {
type GppHeader,
type GppString,
type SectionData,
type SectionDef,
SECTION_REGISTRY,
encodeGppString,
} from './gpp';
// ── Types ────────────────────────────────────────────────────────────
declare global {
interface Window {
__gpp?: GppApiFunction;
__gppQueue?: GppQueueEntry[];
}
}
/** Signature of the __gpp() global function. */
export type GppApiFunction = (
command: string,
callback: GppApiCallback,
parameter?: unknown,
) => void;
/** Callback passed to __gpp() by callers. */
export type GppApiCallback = (data: unknown, success: boolean) => void;
/** A queued __gpp() call (from the stub). */
export type GppQueueEntry = [string, GppApiCallback, unknown?];
/** Return type for the 'ping' command. */
export interface GppPingReturn {
gppVersion: string;
cmpStatus: 'loaded' | 'stub';
cmpDisplayStatus: 'visible' | 'hidden' | 'disabled';
signalStatus: 'ready' | 'not ready';
supportedAPIs: string[];
cmpId: number;
/** The current GPP string, or empty if not yet resolved. */
gppString: string;
/** Section IDs that apply to the current transaction. */
applicableSections: number[];
}
/** Return type for the 'getGPPData' command. */
export interface GppData {
/** The encoded GPP string. */
gppString: string;
/** Section IDs applicable to the current transaction. */
applicableSections: number[];
/** Parsed section data keyed by section ID. */
parsedSections: Record<number, SectionData>;
}
/** Event data sent to registered listeners. */
export interface GppEventData {
eventName: string;
listenerId: number;
data: GppData | GppPingReturn | boolean;
pingData: GppPingReturn;
}
// ── Internal state ───────────────────────────────────────────────────
interface GppApiState {
cmpId: number;
gppString: string;
header: GppHeader | null;
sections: Map<number, SectionData>;
supportedAPIs: string[];
signalStatus: 'ready' | 'not ready';
displayStatus: 'visible' | 'hidden' | 'disabled';
listeners: Map<number, GppApiCallback>;
nextListenerId: number;
}
let apiState: GppApiState | null = null;
// ── Ping builder ─────────────────────────────────────────────────────
function buildPingReturn(state: GppApiState): GppPingReturn {
return {
gppVersion: '1.1',
cmpStatus: 'loaded',
cmpDisplayStatus: state.displayStatus,
signalStatus: state.signalStatus,
supportedAPIs: state.supportedAPIs,
cmpId: state.cmpId,
gppString: state.gppString,
applicableSections: state.header?.applicableSections ?? [],
};
}
function buildGppData(state: GppApiState): GppData {
const parsedSections: Record<number, SectionData> = {};
state.sections.forEach((data, id) => {
parsedSections[id] = data;
});
return {
gppString: state.gppString,
applicableSections: state.header?.applicableSections ?? [],
parsedSections,
};
}
// ── Command handler ──────────────────────────────────────────────────
function gppApiHandler(
command: string,
callback: GppApiCallback,
parameter?: unknown,
): void {
if (!apiState) {
callback(false, false);
return;
}
switch (command) {
case 'ping': {
callback(buildPingReturn(apiState), true);
break;
}
case 'getGPPData': {
callback(buildGppData(apiState), true);
break;
}
case 'getSection': {
const prefix = parameter as string;
if (!prefix) {
callback(null, false);
return;
}
// Find section by API prefix
let found = false;
for (const [id, def] of SECTION_REGISTRY.entries()) {
if (def.apiPrefix === prefix && apiState.sections.has(id)) {
callback(apiState.sections.get(id)!, true);
found = true;
break;
}
}
if (!found) {
callback(null, false);
}
break;
}
case 'hasSection': {
const prefix = parameter as string;
if (!prefix) {
callback(false, true);
return;
}
let has = false;
for (const [id, def] of SECTION_REGISTRY.entries()) {
if (def.apiPrefix === prefix && apiState.sections.has(id)) {
has = true;
break;
}
}
callback(has, true);
break;
}
case 'addEventListener': {
const listenerId = apiState.nextListenerId++;
apiState.listeners.set(listenerId, callback);
// Immediately notify with current state
const eventData: GppEventData = {
eventName: 'listenerRegistered',
listenerId,
data: buildGppData(apiState),
pingData: buildPingReturn(apiState),
};
try {
callback(eventData, true);
} catch {
// Swallow listener errors during initial notification
}
break;
}
case 'removeEventListener': {
const id = parameter as number;
const removed = apiState.listeners.delete(id);
callback(removed, removed);
break;
}
default:
callback(false, false);
}
}
// ── Queue processing ─────────────────────────────────────────────────
/** Process any queued __gpp() calls from the stub installed by the loader. */
function processQueuedCalls(): void {
if (typeof window === 'undefined') return;
const queue = window.__gppQueue;
if (Array.isArray(queue)) {
for (const entry of queue) {
gppApiHandler(entry[0], entry[1], entry[2]);
}
queue.length = 0;
}
}
// ── Public API ───────────────────────────────────────────────────────
/**
* Install the __gpp() global function and process any queued calls.
*
* @param cmpId Registered CMP ID.
* @param supportedAPIs List of supported API prefixes (e.g. ['usnat', 'usca']).
*/
export function installGppApi(
cmpId: number,
supportedAPIs: string[] = [],
): void {
apiState = {
cmpId,
gppString: '',
header: null,
sections: new Map(),
supportedAPIs,
signalStatus: 'not ready',
displayStatus: 'hidden',
listeners: new Map(),
nextListenerId: 1,
};
if (typeof window !== 'undefined') {
window.__gpp = gppApiHandler;
}
processQueuedCalls();
}
/** Remove the __gpp() global function and clear state. */
export function removeGppApi(): void {
if (typeof window !== 'undefined') {
delete window.__gpp;
delete window.__gppQueue;
}
apiState = null;
}
/**
* Update the GPP consent state and notify all listeners.
*
* @param gpp The full GPP string data to set.
* @returns The encoded GPP string.
*/
export function updateGppConsent(gpp: GppString): string {
const gppString = encodeGppString(gpp);
if (apiState) {
apiState.gppString = gppString;
apiState.header = gpp.header;
apiState.sections = new Map<number, SectionData>();
// Copy section data (just the data, not the gpcSubsection)
for (const [id, section] of gpp.sections.entries()) {
apiState.sections.set(id, section.data);
}
apiState.signalStatus = 'ready';
apiState.displayStatus = 'hidden';
// Notify all listeners
const ping = buildPingReturn(apiState);
apiState.listeners.forEach((callback, listenerId) => {
const eventData: GppEventData = {
eventName: 'signalStatus',
listenerId,
data: buildGppData(apiState!),
pingData: ping,
};
try {
callback(eventData, true);
} catch {
// Swallow listener errors
}
});
}
return gppString;
}
/** Set the display status (visible when banner is shown). */
export function setGppDisplayStatus(status: 'visible' | 'hidden' | 'disabled'): void {
if (apiState) {
apiState.displayStatus = status;
}
}
/** Set the signal status. */
export function setGppSignalStatus(status: 'ready' | 'not ready'): void {
if (apiState) {
apiState.signalStatus = status;
}
}
/** Get the current GPP string (empty if no consent yet). */
export function getGppString(): string {
return apiState?.gppString ?? '';
}
/**
* Check whether the GPP API is currently installed.
* Useful for conditional logic in the banner.
*/
export function isGppApiInstalled(): boolean {
return apiState !== null;
}

601
apps/banner/src/gpp.ts Normal file
View File

@@ -0,0 +1,601 @@
/**
* IAB Global Privacy Platform (GPP) string encoder/decoder.
*
* Implements GPP v1 specification for:
* - GPP header encoding/decoding with Fibonacci-coded section ID ranges
* - US National Privacy section (Section 7)
* - US state-specific sections: CA (8), VA (9), CO (10), CT (11), FL (14)
*
* Adding a new state section requires only adding a new SectionDef object to
* the SECTION_REGISTRY — no structural changes needed.
*
* @see https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform
*/
import { BitWriter, BitReader, bytesToBase64url, base64urlToBytes } from '../../../apps/banner/src/tcf';
// ── GPP consent field values ─────────────────────────────────────────
/** Standard 2-bit consent/notice field values used across all US sections. */
export const GppFieldValue = {
/** Field is not applicable in this context. */
NOT_APPLICABLE: 0,
/** Notice was provided / user opted out. */
YES: 1,
/** Notice was not provided / user did not opt out. */
NO: 2,
} as const;
// ── Fibonacci coding ─────────────────────────────────────────────────
/**
* Pre-computed Fibonacci numbers for Zeckendorf representation.
* F(2)=1, F(3)=2, F(4)=3, F(5)=5, ... — sufficient for any GPP section ID.
*/
const FIB: number[] = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597];
/**
* Write a positive integer using Fibonacci coding (Zeckendorf + terminator).
*
* Bits are written LSB-first (position 0 = F(2)=1), with a trailing '1' bit
* to mark the end of the code word (two consecutive 1s terminate).
*/
export function fibonacciEncode(writer: BitWriter, value: number): void {
if (value < 1) {
throw new Error('Fibonacci coding requires a positive integer (>= 1)');
}
// Find the highest Fibonacci index needed
let maxIdx = 0;
for (let i = FIB.length - 1; i >= 0; i--) {
if (FIB[i] <= value) {
maxIdx = i;
break;
}
}
// Build Zeckendorf representation
const bits = new Array<boolean>(maxIdx + 1).fill(false);
let remaining = value;
for (let i = maxIdx; i >= 0; i--) {
if (FIB[i] <= remaining) {
bits[i] = true;
remaining -= FIB[i];
}
}
// Write bits from position 0 upward, then the terminator '1'
for (const bit of bits) {
writer.writeBool(bit);
}
writer.writeBool(true); // terminator — creates the '11' pattern
}
/**
* Read a Fibonacci-coded integer.
* Reads bits until two consecutive 1-bits are encountered.
*/
export function fibonacciDecode(reader: BitReader): number {
let value = 0;
let prevBit = false;
let position = 0;
while (true) {
const bit = reader.readBool();
if (bit && prevBit) {
break; // Two consecutive 1s — terminator reached
}
if (bit) {
if (position >= FIB.length) {
throw new Error('Fibonacci decode: value exceeds supported range');
}
value += FIB[position];
}
prevBit = bit;
position++;
}
return value;
}
// ── GPP Header ───────────────────────────────────────────────────────
/** GPP header type identifier (always 3). */
const GPP_HEADER_TYPE = 3;
/** Current GPP specification version. */
const GPP_VERSION = 1;
/** Decoded GPP header. */
export interface GppHeader {
/** GPP spec version (currently 1). */
version: number;
/** Section IDs present in this GPP string, in order. */
sectionIds: number[];
/** Section IDs applicable to the current transaction. */
applicableSections: number[];
}
/** Encode a GPP header to a base64url string segment. */
export function encodeGppHeader(header: GppHeader): string {
const writer = new BitWriter();
writer.writeInt(GPP_HEADER_TYPE, 6);
writer.writeInt(header.version, 6);
// Section IDs — range-encoded with Fibonacci
writeIdRange(writer, header.sectionIds);
// Applicable sections — range-encoded with Fibonacci
writeIdRange(writer, header.applicableSections);
return bytesToBase64url(writer.toBytes());
}
/** Decode a GPP header from a base64url string segment. */
export function decodeGppHeader(encoded: string): GppHeader {
const bytes = base64urlToBytes(encoded);
const reader = new BitReader(bytes);
const type = reader.readInt(6);
if (type !== GPP_HEADER_TYPE) {
throw new Error(`Invalid GPP header type: ${type} (expected ${GPP_HEADER_TYPE})`);
}
const version = reader.readInt(6);
const sectionIds = readIdRange(reader);
const applicableSections = readIdRange(reader);
return { version, sectionIds, applicableSections };
}
/**
* Write a list of IDs using range encoding with Fibonacci integers.
*
* Format:
* NumEntries: Int(6) — number of range entries
* For each entry:
* IsGroup: Bool — false = single ID, true = contiguous range
* If single: Id as Fibonacci integer
* If group: StartId + EndId as Fibonacci integers
*/
function writeIdRange(writer: BitWriter, ids: number[]): void {
if (ids.length === 0) {
writer.writeInt(0, 6);
return;
}
// Collapse consecutive IDs into ranges
const sorted = [...ids].sort((a, b) => a - b);
const ranges: [number, number][] = [];
let start = sorted[0];
let end = sorted[0];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] === end + 1) {
end = sorted[i];
} else {
ranges.push([start, end]);
start = sorted[i];
end = sorted[i];
}
}
ranges.push([start, end]);
writer.writeInt(ranges.length, 6);
for (const [s, e] of ranges) {
if (s === e) {
writer.writeBool(false); // single
fibonacciEncode(writer, s);
} else {
writer.writeBool(true); // group/range
fibonacciEncode(writer, s);
fibonacciEncode(writer, e);
}
}
}
/** Read a range-encoded list of IDs with Fibonacci integers. */
function readIdRange(reader: BitReader): number[] {
const numEntries = reader.readInt(6);
const ids: number[] = [];
for (let i = 0; i < numEntries; i++) {
const isGroup = reader.readBool();
if (isGroup) {
const start = fibonacciDecode(reader);
const end = fibonacciDecode(reader);
for (let id = start; id <= end; id++) {
ids.push(id);
}
} else {
ids.push(fibonacciDecode(reader));
}
}
return ids;
}
// ── Section field definitions ────────────────────────────────────────
/** A single field in a GPP section definition. */
export interface FieldDef {
/** Field name (e.g. 'SaleOptOut'). */
name: string;
/** Bit width per value. */
bits: number;
/** Number of values — 1 for scalars, >1 for arrays (e.g. SensitiveDataProcessing). */
count: number;
}
/** A complete GPP section definition. */
export interface SectionDef {
/** IAB GPP section ID. */
id: number;
/** API prefix used in __gpp() calls (e.g. 'usnat', 'usca'). */
apiPrefix: string;
/** Section format version. */
version: number;
/** Ordered list of field definitions for the core segment. */
coreFields: FieldDef[];
/** Whether this section supports an optional GPC sub-section. */
hasGpcSubsection: boolean;
}
/** Section data — all field values as numbers (scalars) or number arrays. */
export type SectionData = Record<string, number | number[]>;
// ── Field definition helpers ─────────────────────────────────────────
function field(name: string, bits: number): FieldDef {
return { name, bits, count: 1 };
}
function arrayField(name: string, bits: number, count: number): FieldDef {
return { name, bits, count };
}
// ── US section definitions ───────────────────────────────────────────
/** US National Privacy section (Section 7, usnat v1). */
export const US_NATIONAL: SectionDef = {
id: 7,
apiPrefix: 'usnat',
version: 1,
coreFields: [
field('Version', 6),
field('SharingNotice', 2),
field('SaleOptOutNotice', 2),
field('SharingOptOutNotice', 2),
field('TargetedAdvertisingOptOutNotice', 2),
field('SensitiveDataProcessingOptOutNotice', 2),
field('SensitiveDataLimitUseNotice', 2),
field('SaleOptOut', 2),
field('SharingOptOut', 2),
field('TargetedAdvertisingOptOut', 2),
arrayField('SensitiveDataProcessing', 2, 12),
arrayField('KnownChildSensitiveDataConsents', 2, 2),
field('PersonalDataConsents', 2),
field('MspaCoveredTransaction', 2),
field('MspaOptOutOptionMode', 2),
field('MspaServiceProviderMode', 2),
],
hasGpcSubsection: true,
};
/** US California Privacy section (Section 8, usca v1). */
export const US_CALIFORNIA: SectionDef = {
id: 8,
apiPrefix: 'usca',
version: 1,
coreFields: [
field('Version', 6),
field('SaleOptOutNotice', 2),
field('SharingOptOutNotice', 2),
field('SensitiveDataLimitUseNotice', 2),
field('SaleOptOut', 2),
field('SharingOptOut', 2),
arrayField('SensitiveDataProcessing', 2, 9),
arrayField('KnownChildSensitiveDataConsents', 2, 2),
field('PersonalDataConsents', 2),
field('MspaCoveredTransaction', 2),
field('MspaOptOutOptionMode', 2),
field('MspaServiceProviderMode', 2),
],
hasGpcSubsection: true,
};
/** US Virginia Privacy section (Section 9, usva v1). */
export const US_VIRGINIA: SectionDef = {
id: 9,
apiPrefix: 'usva',
version: 1,
coreFields: [
field('Version', 6),
field('SharingNotice', 2),
field('SaleOptOutNotice', 2),
field('TargetedAdvertisingOptOutNotice', 2),
field('SaleOptOut', 2),
field('TargetedAdvertisingOptOut', 2),
arrayField('SensitiveDataProcessing', 2, 8),
field('KnownChildSensitiveDataConsents', 2),
field('MspaCoveredTransaction', 2),
field('MspaOptOutOptionMode', 2),
field('MspaServiceProviderMode', 2),
],
hasGpcSubsection: false,
};
/** US Colorado Privacy section (Section 10, usco v1). */
export const US_COLORADO: SectionDef = {
id: 10,
apiPrefix: 'usco',
version: 1,
coreFields: [
field('Version', 6),
field('SharingNotice', 2),
field('SaleOptOutNotice', 2),
field('TargetedAdvertisingOptOutNotice', 2),
field('SaleOptOut', 2),
field('TargetedAdvertisingOptOut', 2),
arrayField('SensitiveDataProcessing', 2, 7),
field('KnownChildSensitiveDataConsents', 2),
field('MspaCoveredTransaction', 2),
field('MspaOptOutOptionMode', 2),
field('MspaServiceProviderMode', 2),
],
hasGpcSubsection: true,
};
/** US Connecticut Privacy section (Section 11, usct v1). */
export const US_CONNECTICUT: SectionDef = {
id: 11,
apiPrefix: 'usct',
version: 1,
coreFields: [
field('Version', 6),
field('SharingNotice', 2),
field('SaleOptOutNotice', 2),
field('TargetedAdvertisingOptOutNotice', 2),
field('SaleOptOut', 2),
field('TargetedAdvertisingOptOut', 2),
arrayField('SensitiveDataProcessing', 2, 8),
arrayField('KnownChildSensitiveDataConsents', 2, 3),
field('MspaCoveredTransaction', 2),
field('MspaOptOutOptionMode', 2),
field('MspaServiceProviderMode', 2),
],
hasGpcSubsection: true,
};
/** US Florida Privacy section (Section 14, usfl v1). */
export const US_FLORIDA: SectionDef = {
id: 14,
apiPrefix: 'usfl',
version: 1,
coreFields: [
field('Version', 6),
field('SharingNotice', 2),
field('SaleOptOutNotice', 2),
field('TargetedAdvertisingOptOutNotice', 2),
field('SaleOptOut', 2),
field('TargetedAdvertisingOptOut', 2),
arrayField('SensitiveDataProcessing', 2, 8),
arrayField('KnownChildSensitiveDataConsents', 2, 3),
field('PersonalDataConsents', 2),
field('MspaCoveredTransaction', 2),
field('MspaOptOutOptionMode', 2),
field('MspaServiceProviderMode', 2),
],
hasGpcSubsection: true,
};
/** Registry of all known section definitions, keyed by section ID. */
export const SECTION_REGISTRY: Map<number, SectionDef> = new Map([
[US_NATIONAL.id, US_NATIONAL],
[US_CALIFORNIA.id, US_CALIFORNIA],
[US_VIRGINIA.id, US_VIRGINIA],
[US_COLORADO.id, US_COLORADO],
[US_CONNECTICUT.id, US_CONNECTICUT],
[US_FLORIDA.id, US_FLORIDA],
]);
// ── Section encoding/decoding ────────────────────────────────────────
/** GPC sub-section data (appended after a section's core segment). */
export interface GpcSubsection {
/** Whether the browser's Global Privacy Control signal was detected. */
gpc: boolean;
}
/** Encode a section's core fields to base64url. */
export function encodeSectionCore(def: SectionDef, data: SectionData): string {
const writer = new BitWriter();
for (const fieldDef of def.coreFields) {
const value = data[fieldDef.name];
if (fieldDef.count > 1) {
const arr = (value as number[]) ?? new Array(fieldDef.count).fill(0);
for (let i = 0; i < fieldDef.count; i++) {
writer.writeInt(arr[i] ?? 0, fieldDef.bits);
}
} else {
writer.writeInt((value as number) ?? 0, fieldDef.bits);
}
}
return bytesToBase64url(writer.toBytes());
}
/** Encode a GPC sub-section to base64url. */
export function encodeGpcSubsection(gpc: GpcSubsection): string {
const writer = new BitWriter();
writer.writeInt(1, 2); // SubsectionType = 1 (GPC)
writer.writeBool(gpc.gpc);
return bytesToBase64url(writer.toBytes());
}
/**
* Encode a full section (core + optional GPC sub-section).
* Sub-sections are separated from the core by a '.' character.
*/
export function encodeSection(
def: SectionDef,
data: SectionData,
gpcSubsection?: GpcSubsection,
): string {
const core = encodeSectionCore(def, data);
if (def.hasGpcSubsection && gpcSubsection) {
return `${core}.${encodeGpcSubsection(gpcSubsection)}`;
}
return core;
}
/** Decode a section's core fields from base64url. */
export function decodeSectionCore(def: SectionDef, encoded: string): SectionData {
const bytes = base64urlToBytes(encoded);
const reader = new BitReader(bytes);
const data: SectionData = {};
for (const fieldDef of def.coreFields) {
if (fieldDef.count > 1) {
const arr: number[] = [];
for (let i = 0; i < fieldDef.count; i++) {
arr.push(reader.readInt(fieldDef.bits));
}
data[fieldDef.name] = arr;
} else {
data[fieldDef.name] = reader.readInt(fieldDef.bits);
}
}
return data;
}
/** Decode a GPC sub-section from base64url. */
export function decodeGpcSubsection(encoded: string): GpcSubsection {
const bytes = base64urlToBytes(encoded);
const reader = new BitReader(bytes);
reader.readInt(2); // SubsectionType — skip (always 1)
return { gpc: reader.readBool() };
}
/**
* Decode a full section (core + optional GPC sub-section).
* Splits on '.' to separate core from sub-sections.
*/
export function decodeSection(
def: SectionDef,
encoded: string,
): { data: SectionData; gpcSubsection?: GpcSubsection } {
const parts = encoded.split('.');
const data = decodeSectionCore(def, parts[0]);
let gpcSubsection: GpcSubsection | undefined;
if (def.hasGpcSubsection && parts.length > 1) {
gpcSubsection = decodeGpcSubsection(parts[1]);
}
return { data, gpcSubsection };
}
// ── Full GPP string encoding/decoding ────────────────────────────────
/** A fully decoded GPP string with header and section data. */
export interface GppString {
header: GppHeader;
sections: Map<number, { data: SectionData; gpcSubsection?: GpcSubsection }>;
}
/**
* Encode a complete GPP string.
* Format: `{header}~{section1}~{section2}~...`
* Sections appear in the order listed in header.sectionIds.
*/
export function encodeGppString(gpp: GppString): string {
const parts: string[] = [encodeGppHeader(gpp.header)];
for (const sectionId of gpp.header.sectionIds) {
const section = gpp.sections.get(sectionId);
const def = SECTION_REGISTRY.get(sectionId);
if (!section || !def) {
throw new Error(`No data or definition for GPP section ${sectionId}`);
}
parts.push(encodeSection(def, section.data, section.gpcSubsection));
}
return parts.join('~');
}
/**
* Decode a complete GPP string.
* Splits on '~'; first segment is the header, subsequent segments are section payloads
* matched against header.sectionIds in order.
*/
export function decodeGppString(gppString: string): GppString {
const parts = gppString.split('~');
if (parts.length < 1) {
throw new Error('Invalid GPP string: empty');
}
const header = decodeGppHeader(parts[0]);
const sections = new Map<number, { data: SectionData; gpcSubsection?: GpcSubsection }>();
for (let i = 0; i < header.sectionIds.length; i++) {
const sectionId = header.sectionIds[i];
const sectionPayload = parts[i + 1];
if (!sectionPayload) {
throw new Error(`Missing payload for GPP section ${sectionId}`);
}
const def = SECTION_REGISTRY.get(sectionId);
if (!def) {
// Unknown section — skip (cannot decode without a definition)
continue;
}
sections.set(sectionId, decodeSection(def, sectionPayload));
}
return { header, sections };
}
// ── Convenience helpers ──────────────────────────────────────────────
/** Create default (all-zero / N/A) data for a section, with Version pre-filled. */
export function createDefaultSectionData(def: SectionDef): SectionData {
const data: SectionData = {};
for (const fieldDef of def.coreFields) {
if (fieldDef.name === 'Version') {
data[fieldDef.name] = def.version;
} else if (fieldDef.count > 1) {
data[fieldDef.name] = new Array(fieldDef.count).fill(0);
} else {
data[fieldDef.name] = 0;
}
}
return data;
}
/** Look up a section definition by API prefix (e.g. 'usnat', 'usca'). */
export function getSectionByPrefix(prefix: string): SectionDef | undefined {
for (const def of SECTION_REGISTRY.values()) {
if (def.apiPrefix === prefix) return def;
}
return undefined;
}
/**
* Register a custom section definition (for new US states or non-US sections).
* Overwrites any existing definition with the same section ID.
*/
export function registerSection(def: SectionDef): void {
SECTION_REGISTRY.set(def.id, def);
}

150
apps/banner/src/i18n.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Banner i18n — locale detection and string translation.
*
* Loads translations from CDN or uses built-in defaults.
* Supports string interpolation via {{key}} placeholders.
*/
export interface TranslationStrings {
title: string;
description: string;
acceptAll: string;
rejectAll: string;
managePreferences: string;
savePreferences: string;
privacyPolicyLink: string;
closeLabel: string;
categoryNecessary: string;
categoryNecessaryDesc: string;
categoryFunctional: string;
categoryFunctionalDesc: string;
categoryAnalytics: string;
categoryAnalyticsDesc: string;
categoryMarketing: string;
categoryMarketingDesc: string;
categoryPersonalisation: string;
categoryPersonalisationDesc: string;
cookieCount: string;
}
/** Built-in English (GB) translations — used as fallback. */
export const DEFAULT_TRANSLATIONS: TranslationStrings = {
title: 'We use cookies',
description:
'We use cookies and similar technologies to enhance your browsing experience, analyse site traffic, and personalise content. You can choose which categories to allow. [Privacy Policy]({{privacy_policy}}) [Terms & Conditions]({{terms}})',
acceptAll: 'Accept all',
rejectAll: 'Reject all',
managePreferences: 'Manage preferences',
savePreferences: 'Save preferences',
privacyPolicyLink: 'Privacy Policy',
closeLabel: 'Close',
categoryNecessary: 'Necessary',
categoryNecessaryDesc: 'Essential for the website to function. Always active.',
categoryFunctional: 'Functional',
categoryFunctionalDesc: 'Enable enhanced functionality and personalisation.',
categoryAnalytics: 'Analytics',
categoryAnalyticsDesc: 'Help us understand how visitors interact with the site.',
categoryMarketing: 'Marketing',
categoryMarketingDesc: 'Used to deliver personalised advertisements.',
categoryPersonalisation: 'Personalisation',
categoryPersonalisationDesc: 'Enable content personalisation based on your profile.',
cookieCount: '{{count}} cookies used on this site',
};
/**
* Detect the user's preferred locale.
*
* Priority: 1) explicit data-locale attribute, 2) navigator.language,
* 3) document lang attribute, 4) 'en'.
*/
export function detectLocale(): string {
// Check for explicit override on the script tag
const scriptEl = document.querySelector('script[data-site-id]');
const explicit = scriptEl?.getAttribute('data-locale');
if (explicit) return normaliseLocale(explicit);
// Browser language
if (typeof navigator !== 'undefined' && navigator.language) {
return normaliseLocale(navigator.language);
}
// Document lang attribute
const docLang = document.documentElement.lang;
if (docLang) return normaliseLocale(docLang);
return 'en';
}
/**
* Normalise a locale string to a two-letter language code.
* e.g. 'en-GB' → 'en', 'fr-FR' → 'fr'
*/
export function normaliseLocale(locale: string): string {
return locale.split('-')[0].toLowerCase();
}
/**
* Fetch translations for a locale from the CDN.
* Returns null if not found or on error.
*/
export async function fetchTranslations(
cdnBase: string,
locale: string,
): Promise<Partial<TranslationStrings> | null> {
try {
const resp = await fetch(`${cdnBase}/translations-${locale}.json`);
if (!resp.ok) return null;
return (await resp.json()) as Partial<TranslationStrings>;
} catch {
return null;
}
}
/**
* Load translations: try fetching from CDN, fall back to defaults.
*/
export async function loadTranslations(
cdnBase: string,
locale: string,
): Promise<TranslationStrings> {
if (locale === 'en') {
return { ...DEFAULT_TRANSLATIONS };
}
const remote = await fetchTranslations(cdnBase, locale);
if (!remote) {
return { ...DEFAULT_TRANSLATIONS };
}
// Merge remote over defaults so missing keys fall back to English
return { ...DEFAULT_TRANSLATIONS, ...remote };
}
/**
* Interpolate placeholders in a translation string.
* e.g. interpolate('{{count}} cookies', { count: '12' }) → '12 cookies'
*/
export function interpolate(
template: string,
values: Record<string, string>,
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? '');
}
/**
* Render markdown-style links as HTML anchor tags and strip orphaned links.
*
* Converts `[text](url)` to `<a href="url" ...>text</a>`.
* If the URL is empty (because the config value wasn't set), the entire
* `[text]()` fragment is removed so no broken links appear.
*/
export function renderLinks(html: string, linkClass: string = 'consentos-banner__link'): string {
// Remove links with empty URLs (including surrounding whitespace)
let result = html.replace(/\s*\[([^\]]*)\]\(\s*\)\s*/g, '');
// Convert remaining markdown links to <a> tags
result = result.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
`<a href="$2" target="_blank" rel="noopener" class="${linkClass}">$1</a>`,
);
return result;
}

199
apps/banner/src/loader.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* consent-loader.js — Lightweight synchronous bootstrap (~2KB gzipped).
*
* Runs before any other scripts on the page. Responsibilities:
* 1. Read existing consent cookie — if valid, apply consent state immediately
* 2. Set Google Consent Mode defaults (all denied except security_storage)
* 3. If no consent: async-load the full banner bundle
* 4. Fetch site config from CDN/API
*/
import { installBlocker, updateAcceptedCategories } from './blocker';
import { hasConsent, readConsent } from './consent';
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from './gcm';
import { isGpcEnabled } from './gpc';
import type { GppApiCallback, GppApiFunction, GppQueueEntry } from './gpp-api';
declare global {
interface Window {
__consentos: {
siteId: string;
apiBase: string;
cdnBase: string;
loaded: boolean;
/** Visitor region from GeoIP (e.g. 'US-CA'), set by loader. */
visitorRegion?: string;
/** Whether GPC signal was detected by the loader. */
gpcDetected?: boolean;
};
/** Public ConsentOS API for site integration. */
ConsentOS: {
/**
* Identify a user by providing their third-party JWT.
* Syncs consent with the server-side profile.
* Returns categories that still need consent (empty if fully resolved).
*/
identifyUser: (jwt: string) => Promise<string[]>;
/** Clear the identified user session (revert to anonymous). */
clearIdentity: () => void;
/**
* Re-open the banner so the visitor can review, change, or
* withdraw their consent. Pre-fills category toggles from the
* current stored consent state. Required by GDPR Art. 7(3)
* ("it shall be as easy to withdraw as to give consent").
*/
showPreferences: () => void;
};
}
}
(function consentosLoader() {
// Read data attributes from the script tag, falling back to
// window.__consentos if attributes are absent (e.g. GTM injectScript).
const scriptEl = document.currentScript as HTMLScriptElement | null;
const gtmConfig = (window as any).__consentos;
const siteId = scriptEl?.getAttribute('data-site-id') ?? gtmConfig?.siteId ?? '';
const apiBase = scriptEl?.getAttribute('data-api-base') ?? gtmConfig?.apiBase ?? '';
// Derive cdnBase: explicit attribute > apiBase > same origin as this script
const scriptSrc = scriptEl?.getAttribute('src') ?? '';
let scriptOrigin = '';
try {
if (scriptSrc) {
scriptOrigin = new URL(scriptSrc, window.location.href).origin;
}
} catch {
// Invalid URL — fall through to empty string
}
const cdnBase = scriptEl?.getAttribute('data-cdn-base') ?? (apiBase || scriptOrigin);
// Expose global CMP context
window.__consentos = {
siteId,
apiBase,
cdnBase,
loaded: false,
};
// Expose public CMP API — methods are stubs until the full bundle loads
// and replaces them with real implementations.
window.ConsentOS = {
identifyUser: async () => {
console.warn('[ConsentOS] identifyUser called before bundle loaded — queuing');
return [];
},
clearIdentity: () => {
console.warn('[ConsentOS] clearIdentity called before bundle loaded');
},
showPreferences: () => {
console.warn('[ConsentOS] showPreferences called before bundle loaded');
},
};
// Warn if essential attributes are missing
if (!siteId) {
console.warn('[ConsentOS] Missing data-site-id attribute on the consent-loader script tag');
}
if (!apiBase) {
console.warn('[ConsentOS] Missing data-api-base attribute — consent recording will not work');
}
// 1. Install script/cookie blocker immediately (before any third-party scripts)
installBlocker();
// 1b. Install __gpp stub — queues calls until the full bundle loads
installGppStub();
// 2. Set GCM defaults immediately (must happen before gtag tags fire)
setGcmDefaults(buildDeniedDefaults());
// 2b. Detect GPC signal early and store on __cmp for the banner bundle
window.__consentos.gpcDetected = isGpcEnabled();
// 3. Check for existing consent
const existingConsent = readConsent();
if (existingConsent) {
// Consent already given — update blocker, GCM, and we're done
updateAcceptedCategories(existingConsent.accepted as import('./types').CategorySlug[]);
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
updateGcm(gcmState);
// Fire consent-change event so GTM/other scripts know
dispatchConsentEvent(existingConsent.accepted);
return;
}
// 4. No consent — async-load the full banner bundle
loadBannerBundle(cdnBase);
})();
/** Async-load the full consent banner bundle. */
function loadBannerBundle(cdnBase: string): void {
const script = document.createElement('script');
// Mark as allowed so the blocker's MutationObserver doesn't intercept it
script.setAttribute('data-consentos-allowed', 'true');
script.src = `${cdnBase}/consent-bundle.js`;
script.async = true;
script.onload = () => {
window.__consentos.loaded = true;
};
script.onerror = () => {
console.error(`[ConsentOS] Failed to load consent bundle from ${cdnBase}/consent-bundle.js`);
};
document.head.appendChild(script);
}
/**
* Install a lightweight __gpp() stub that queues calls until the full
* banner bundle loads and replaces it with the real implementation.
*/
function installGppStub(): void {
if (typeof window === 'undefined') return;
if (window.__gpp) return; // Already installed
const queue: GppQueueEntry[] = [];
window.__gppQueue = queue;
const stub: GppApiFunction = function gppStub(
command: string,
callback: GppApiCallback,
parameter?: unknown,
) {
if (command === 'ping') {
callback(
{
gppVersion: '1.1',
cmpStatus: 'stub',
cmpDisplayStatus: 'hidden',
signalStatus: 'not ready',
supportedAPIs: [],
cmpId: 0,
gppString: '',
applicableSections: [],
},
true,
);
return;
}
queue.push([command, callback, parameter]);
};
window.__gpp = stub;
}
/** Dispatch a custom event with accepted categories. */
function dispatchConsentEvent(accepted: string[]): void {
const event = new CustomEvent('consentos:consent-change', {
detail: { accepted },
});
document.dispatchEvent(event);
// Also push to dataLayer for GTM
if (typeof window.dataLayer !== 'undefined') {
window.dataLayer.push({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
}
}

View File

@@ -0,0 +1,68 @@
/**
* Re-consent logic — determines whether the user needs to re-consent.
*
* Triggers re-consent when:
* 1. Consent has expired (based on consent_expiry_days)
* 2. The banner/config version has changed since last consent
*/
import type { ConsentState, SiteConfig } from './types';
/** Check whether the existing consent has expired based on consent_expiry_days. */
export function isConsentExpired(
consent: ConsentState,
config: SiteConfig,
): boolean {
const consentedAt = new Date(consent.consentedAt).getTime();
if (isNaN(consentedAt)) return true;
const expiryMs = config.consent_expiry_days * 86_400_000;
return Date.now() > consentedAt + expiryMs;
}
/** Check whether the config version has changed since consent was given. */
export function hasConfigVersionChanged(
consent: ConsentState,
config: SiteConfig,
): boolean {
// If the consent doesn't record a config version, treat as needing re-consent
// only if the config has a version set
if (!consent.configVersion) {
return config.id !== '';
}
return consent.configVersion !== config.id;
}
/**
* Determine whether the user needs to re-consent.
*
* Returns an object with a boolean and the specific reason(s) so the
* banner can log or report why re-consent was triggered.
*/
export function needsReconsent(
consent: ConsentState,
config: SiteConfig,
): ReconsentResult {
const reasons: ReconsentReason[] = [];
if (isConsentExpired(consent, config)) {
reasons.push('expired');
}
if (hasConfigVersionChanged(consent, config)) {
reasons.push('config_changed');
}
return {
required: reasons.length > 0,
reasons,
};
}
export type ReconsentReason = 'expired' | 'config_changed';
export interface ReconsentResult {
required: boolean;
reasons: ReconsentReason[];
}

310
apps/banner/src/reporter.ts Normal file
View File

@@ -0,0 +1,310 @@
/**
* Client-side cookie reporter.
*
* Runs on a configurable sampling basis (e.g. 10% of page loads).
* Enumerates document.cookie, localStorage, and sessionStorage keys.
* Batches reports and POSTs to the scanner/report API endpoint.
*
* @module reporter
*/
/** A single discovered storage item from the client. */
export interface DiscoveredCookie {
name: string;
domain: string;
storage_type: 'cookie' | 'local_storage' | 'session_storage';
/** Raw value length (not the value itself, for privacy). */
value_length: number;
/** HTTP cookie attributes if available. */
path?: string;
is_secure?: boolean;
same_site?: string;
/** Script URL that likely set this cookie (from PerformanceObserver). */
script_source?: string;
}
/** Report payload sent to the API. */
export interface CookieReport {
site_id: string;
page_url: string;
cookies: DiscoveredCookie[];
/** ISO 8601 timestamp of when the report was collected. */
collected_at: string;
/** User agent string for classification context. */
user_agent: string;
}
/** Reporter configuration. */
export interface ReporterConfig {
/** Site ID for this report. */
siteId: string;
/** Base URL for the API (e.g. https://api.example.com/api/v1). */
apiBase: string;
/** Sampling rate: 0.0 to 1.0 (e.g. 0.1 = 10% of page loads). */
sampleRate: number;
/** Delay in ms before collecting cookies (allows page to load). */
collectDelay: number;
/** Whether to include localStorage keys. */
includeLocalStorage: boolean;
/** Whether to include sessionStorage keys. */
includeSessionStorage: boolean;
}
const DEFAULT_CONFIG: Omit<ReporterConfig, 'siteId' | 'apiBase'> = {
sampleRate: 0.1,
collectDelay: 3000,
includeLocalStorage: true,
includeSessionStorage: true,
};
/** Track loaded scripts for attribution via PerformanceObserver. */
let observedScripts: string[] = [];
let observer: PerformanceObserver | null = null;
/**
* Install a PerformanceObserver to track script loads for attribution.
* Must be called early (ideally in the loader) to capture all scripts.
*/
export function installScriptObserver(): void {
if (typeof PerformanceObserver === 'undefined') return;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'resource') {
const resourceEntry = entry as PerformanceResourceTiming;
if (resourceEntry.initiatorType === 'script') {
observedScripts.push(resourceEntry.name);
}
}
}
});
observer.observe({ type: 'resource', buffered: true });
} catch {
// PerformanceObserver not supported — degrade gracefully
}
}
/**
* Remove the script observer. Used for testing and cleanup.
*/
export function removeScriptObserver(): void {
if (observer) {
observer.disconnect();
observer = null;
}
observedScripts = [];
}
/**
* Get all scripts observed since the observer was installed.
*/
export function getObservedScripts(): string[] {
return [...observedScripts];
}
/**
* Parse document.cookie into individual cookies.
*/
export function parseCookies(): DiscoveredCookie[] {
const cookies: DiscoveredCookie[] = [];
if (typeof document === 'undefined' || !document.cookie) return cookies;
const cookieStr = document.cookie;
const pairs = cookieStr.split(';');
for (const pair of pairs) {
const trimmed = pair.trim();
if (!trimmed) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex < 0) continue;
const name = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1);
if (!name) continue;
cookies.push({
name,
domain: window.location.hostname,
storage_type: 'cookie',
value_length: value.length,
});
}
return cookies;
}
/**
* Enumerate localStorage keys.
*/
export function enumerateLocalStorage(): DiscoveredCookie[] {
const items: DiscoveredCookie[] = [];
try {
if (typeof localStorage === 'undefined') return items;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
const value = localStorage.getItem(key) ?? '';
items.push({
name: key,
domain: window.location.hostname,
storage_type: 'local_storage',
value_length: value.length,
});
}
} catch {
// localStorage may be blocked in some browsers/contexts
}
return items;
}
/**
* Enumerate sessionStorage keys.
*/
export function enumerateSessionStorage(): DiscoveredCookie[] {
const items: DiscoveredCookie[] = [];
try {
if (typeof sessionStorage === 'undefined') return items;
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (!key) continue;
const value = sessionStorage.getItem(key) ?? '';
items.push({
name: key,
domain: window.location.hostname,
storage_type: 'session_storage',
value_length: value.length,
});
}
} catch {
// sessionStorage may be blocked in some browsers/contexts
}
return items;
}
/**
* Collect all storage items from the current page.
*/
export function collectAll(config: ReporterConfig): DiscoveredCookie[] {
const items: DiscoveredCookie[] = [];
items.push(...parseCookies());
if (config.includeLocalStorage) {
items.push(...enumerateLocalStorage());
}
if (config.includeSessionStorage) {
items.push(...enumerateSessionStorage());
}
return items;
}
/**
* Build the report payload.
*/
export function buildReport(
config: ReporterConfig,
cookies: DiscoveredCookie[],
): CookieReport {
return {
site_id: config.siteId,
page_url: typeof window !== 'undefined' ? window.location.href : '',
cookies,
collected_at: new Date().toISOString(),
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
};
}
/**
* Send a cookie report to the API.
* Uses navigator.sendBeacon for reliability, falling back to fetch.
*/
export async function sendReport(
apiBase: string,
report: CookieReport,
): Promise<boolean> {
const url = `${apiBase}/scanner/report`;
const body = JSON.stringify(report);
// Prefer sendBeacon — fires even if page is closing
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
return navigator.sendBeacon(url, blob);
}
// Fallback to fetch
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
});
return resp.ok;
} catch {
return false;
}
}
/**
* Determine if this page load should be sampled for reporting.
*/
export function shouldSample(sampleRate: number): boolean {
return Math.random() < sampleRate;
}
/**
* Start the reporter. Call once per page load.
*
* The reporter will:
* 1. Check the sampling rate — skip if not sampled
* 2. Wait for the configured delay (to allow scripts to run)
* 3. Enumerate all cookies and storage keys
* 4. POST the report to the scanner API
*/
export function startReporter(config: Partial<ReporterConfig> & { siteId: string; apiBase: string }): void {
const fullConfig: ReporterConfig = { ...DEFAULT_CONFIG, ...config };
if (!shouldSample(fullConfig.sampleRate)) return;
// Install script observer for attribution
installScriptObserver();
// Delay collection to allow page to finish loading
setTimeout(() => {
const cookies = collectAll(fullConfig);
if (cookies.length === 0) return;
const report = buildReport(fullConfig, cookies);
sendReport(fullConfig.apiBase, report);
}, fullConfig.collectDelay);
}
/**
* Collect and report immediately (for testing or manual triggers).
*/
export async function reportNow(
config: Partial<ReporterConfig> & { siteId: string; apiBase: string },
): Promise<CookieReport | null> {
const fullConfig: ReporterConfig = { ...DEFAULT_CONFIG, ...config };
const cookies = collectAll(fullConfig);
if (cookies.length === 0) return null;
const report = buildReport(fullConfig, cookies);
await sendReport(fullConfig.apiBase, report);
return report;
}

View File

@@ -0,0 +1,88 @@
/**
* Shopify Customer Privacy API integration.
*
* Bridges CMP consent decisions to Shopify's Customer Privacy API
* (`window.Shopify.customerPrivacy`). When enabled, the CMP calls
* `setTrackingConsent()` with the mapped consent state whenever
* the visitor makes a consent choice.
*
* @see https://shopify.dev/docs/api/customer-privacy
*/
import type { CategorySlug } from './types';
/** Shopify consent values: empty = unknown, 'yes' = granted, 'no' = denied. */
type ShopifyConsent = '' | 'yes' | 'no';
/** The consent object Shopify expects. */
export interface ShopifyTrackingConsent {
analytics: ShopifyConsent;
marketing: ShopifyConsent;
preferences: ShopifyConsent;
sale_of_data: ShopifyConsent;
}
/** Shape of window.Shopify.customerPrivacy when loaded. */
interface ShopifyCustomerPrivacy {
setTrackingConsent: (consent: ShopifyTrackingConsent, callback?: () => void) => void;
currentVisitorConsent: () => ShopifyTrackingConsent;
analyticsProcessingAllowed: () => boolean;
marketingAllowed: () => boolean;
preferencesProcessingAllowed: () => boolean;
saleOfDataAllowed: () => boolean;
getRegion: () => string;
}
declare global {
interface Window {
Shopify?: {
customerPrivacy?: ShopifyCustomerPrivacy;
loadFeatures?: (
features: Array<{ name: string; version: string }>,
callback: (error?: Error) => void,
) => void;
};
}
}
/** Check whether the Shopify Customer Privacy API is available. */
export function isShopifyPrivacyAvailable(): boolean {
return typeof window.Shopify?.customerPrivacy?.setTrackingConsent === 'function';
}
/**
* Map CMP accepted categories to Shopify consent signals.
*
* Category mapping:
* functional → preferences
* analytics → analytics
* marketing → marketing + sale_of_data
* personalisation → sale_of_data (if marketing not already accepted)
*/
export function buildShopifyConsent(accepted: CategorySlug[]): ShopifyTrackingConsent {
return {
preferences: accepted.includes('functional') ? 'yes' : 'no',
analytics: accepted.includes('analytics') ? 'yes' : 'no',
marketing: accepted.includes('marketing') ? 'yes' : 'no',
sale_of_data:
accepted.includes('marketing') || accepted.includes('personalisation') ? 'yes' : 'no',
};
}
/**
* Push consent state to the Shopify Customer Privacy API.
*
* This should be called after the user makes a consent choice, only
* when `shopify_privacy_enabled` is true in the site config.
*
* If the API is not yet loaded, the call is silently skipped — Shopify's
* own consent tracking will pick up the state on next page load.
*/
export function updateShopifyConsent(accepted: CategorySlug[]): void {
if (!isShopifyPrivacyAvailable()) return;
const consent = buildShopifyConsent(accepted);
window.Shopify!.customerPrivacy!.setTrackingConsent(consent, () => {
/* Consent registered with Shopify */
});
}

749
apps/banner/src/tcf.ts Normal file
View File

@@ -0,0 +1,749 @@
/**
* IAB TCF v2.2 — TC string encoder/decoder and __tcfapi interface.
*
* Implements the Transparency & Consent Framework v2.2 specification:
* - Encode/decode TC strings (base64url-encoded bitfields)
* - Standard __tcfapi interface (getTCData, ping, addEventListener, removeEventListener)
*
* @see https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
*/
declare global {
interface Window {
__tcfapi?: (
command: string,
version: number,
callback: TcfApiCallback,
parameter?: unknown
) => void;
__tcfapiQueue?: unknown[][];
}
}
// ── Types ────────────────────────────────────────────────────────────
/** Core TC string data model. */
export interface TCModel {
/** TC string format version — always 2 for TCF v2.x. */
version: number;
/** Deciseconds since epoch when consent was first created. */
created: number;
/** Deciseconds since epoch when consent was last updated. */
lastUpdated: number;
/** Registered CMP ID from the IAB CMP List. */
cmpId: number;
/** CMP version. */
cmpVersion: number;
/** Screen number in the CMP where consent was collected. */
consentScreen: number;
/** ISO 639-1 two-letter language code (upper-case). */
consentLanguage: string;
/** Version of the GVL used to create this TC string. */
vendorListVersion: number;
/** TCF policy version — 4 for TCF v2.2. */
tcfPolicyVersion: number;
/** Whether this TC string is specific to this service. */
isServiceSpecific: boolean;
/** Whether non-standard texts were used (deprecated in 2.2). */
useNonStandardTexts: boolean;
/** Opted-in special feature IDs (1-indexed). */
specialFeatureOptIns: Set<number>;
/** Consented purpose IDs (1-indexed, up to 24). */
purposeConsents: Set<number>;
/** Purpose IDs for which legitimate interest is established. */
purposeLegitimateInterests: Set<number>;
/** Whether Purpose 1 was NOT disclosed at time of consent (EU-specific). */
purposeOneTreatment: boolean;
/** ISO 3166-1 alpha-2 publisher country code. */
publisherCC: string;
/** Vendor IDs for which consent is given. */
vendorConsents: Set<number>;
/** Vendor IDs for which legitimate interest is established. */
vendorLegitimateInterests: Set<number>;
/** Publisher restrictions by purpose. */
publisherRestrictions: PublisherRestriction[];
}
/** A publisher restriction entry. */
export interface PublisherRestriction {
purposeId: number;
restrictionType: RestrictionType;
vendorIds: Set<number>;
}
/** Restriction type values per the TCF spec. */
export enum RestrictionType {
/** Purpose is flatly not allowed by publisher. */
NOT_ALLOWED = 0,
/** Require consent (override LI). */
REQUIRE_CONSENT = 1,
/** Require legitimate interest (override consent). */
REQUIRE_LEGITIMATE_INTEREST = 2,
}
/** Return type for the __tcfapi getTCData command. */
export interface TCData {
tcString: string;
tcfPolicyVersion: number;
cmpId: number;
cmpVersion: number;
gdprApplies: boolean;
eventStatus: 'tcloaded' | 'cmpuishown' | 'useractioncomplete';
cmpStatus: 'loaded' | 'error';
listenerId?: number;
isServiceSpecific: boolean;
useNonStandardTexts: boolean;
publisherCC: string;
purposeOneTreatment: boolean;
purpose: {
consents: Record<string, boolean>;
legitimateInterests: Record<string, boolean>;
};
vendor: {
consents: Record<string, boolean>;
legitimateInterests: Record<string, boolean>;
};
specialFeatureOptins: Record<string, boolean>;
}
/** Return type for the __tcfapi ping command. */
export interface PingReturn {
gdprApplies: boolean;
cmpLoaded: boolean;
cmpStatus: 'loaded' | 'error' | 'stub';
displayStatus: 'visible' | 'hidden' | 'disabled';
apiVersion: string;
cmpVersion: number;
cmpId: number;
gvlVersion: number;
tcfPolicyVersion: number;
}
/** Callback type for __tcfapi commands. */
export type TcfApiCallback = (result: TCData | PingReturn | boolean, success: boolean) => void;
// ── Bit manipulation ─────────────────────────────────────────────────
/** Writes bits sequentially into a byte buffer. */
export class BitWriter {
private bytes: number[] = [];
private currentByte = 0;
private bitIndex = 0;
/** Write `length` bits from `value` (MSB first). */
writeInt(value: number, length: number): void {
for (let i = length - 1; i >= 0; i--) {
const bit = (value >>> i) & 1;
this.currentByte = (this.currentByte << 1) | bit;
this.bitIndex++;
if (this.bitIndex === 8) {
this.bytes.push(this.currentByte);
this.currentByte = 0;
this.bitIndex = 0;
}
}
}
/** Write a single boolean bit. */
writeBool(value: boolean): void {
this.writeInt(value ? 1 : 0, 1);
}
/** Write a Set of IDs as a bitfield of `maxId` bits. */
writeBitfield(ids: Set<number>, maxId: number): void {
for (let i = 1; i <= maxId; i++) {
this.writeBool(ids.has(i));
}
}
/** Write a two-letter code as 2 × 6-bit values (A=0, B=1, ...). */
writeLetters(code: string): void {
const upper = code.toUpperCase();
this.writeInt(upper.charCodeAt(0) - 65, 6);
this.writeInt(upper.charCodeAt(1) - 65, 6);
}
/** Finalise and return the accumulated bytes (padding the last byte). */
toBytes(): Uint8Array {
const result = new Uint8Array(this.bytes.length + (this.bitIndex > 0 ? 1 : 0));
for (let i = 0; i < this.bytes.length; i++) {
result[i] = this.bytes[i];
}
if (this.bitIndex > 0) {
result[this.bytes.length] = this.currentByte << (8 - this.bitIndex);
}
return result;
}
}
/** Reads bits sequentially from a byte buffer. */
export class BitReader {
private bytes: Uint8Array;
private bitOffset = 0;
constructor(bytes: Uint8Array) {
this.bytes = bytes;
}
/** Read `length` bits as an unsigned integer. */
readInt(length: number): number {
let value = 0;
for (let i = 0; i < length; i++) {
const byteIndex = (this.bitOffset + i) >> 3;
const bitPosition = 7 - ((this.bitOffset + i) & 7);
if (byteIndex < this.bytes.length) {
value = (value << 1) | ((this.bytes[byteIndex] >> bitPosition) & 1);
} else {
value = value << 1;
}
}
this.bitOffset += length;
return value;
}
/** Read a single bit as boolean. */
readBool(): boolean {
return this.readInt(1) === 1;
}
/** Read a bitfield of `maxId` bits into a Set. */
readBitfield(maxId: number): Set<number> {
const ids = new Set<number>();
for (let i = 1; i <= maxId; i++) {
if (this.readBool()) {
ids.add(i);
}
}
return ids;
}
/** Read 2 × 6-bit letters as a two-char string. */
readLetters(): string {
const a = this.readInt(6);
const b = this.readInt(6);
return String.fromCharCode(a + 65, b + 65);
}
/** Check whether there are at least `bits` remaining. */
hasRemaining(bits: number): boolean {
return this.bitOffset + bits <= this.bytes.length * 8;
}
}
// ── Base64url encoding ───────────────────────────────────────────────
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
/** Encode bytes to websafe base64 (no padding). */
export function bytesToBase64url(bytes: Uint8Array): string {
let result = '';
for (let i = 0; i < bytes.length; i += 3) {
const b0 = bytes[i];
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
const triple = (b0 << 16) | (b1 << 8) | b2;
result += B64_CHARS[(triple >> 18) & 0x3f];
result += B64_CHARS[(triple >> 12) & 0x3f];
if (i + 1 < bytes.length) result += B64_CHARS[(triple >> 6) & 0x3f];
if (i + 2 < bytes.length) result += B64_CHARS[triple & 0x3f];
}
return result;
}
/** Decode websafe base64 (no padding) to bytes. */
export function base64urlToBytes(str: string): Uint8Array {
const lookup = new Uint8Array(128);
for (let i = 0; i < B64_CHARS.length; i++) {
lookup[B64_CHARS.charCodeAt(i)] = i;
}
// Calculate output length (account for missing padding)
const paddedLen = str.length + ((4 - (str.length % 4)) % 4);
const byteLen = (paddedLen * 3) / 4 - (paddedLen - str.length);
const bytes = new Uint8Array(byteLen);
let j = 0;
for (let i = 0; i < str.length; i += 4) {
const a = lookup[str.charCodeAt(i)] ?? 0;
const b = i + 1 < str.length ? (lookup[str.charCodeAt(i + 1)] ?? 0) : 0;
const c = i + 2 < str.length ? (lookup[str.charCodeAt(i + 2)] ?? 0) : 0;
const d = i + 3 < str.length ? (lookup[str.charCodeAt(i + 3)] ?? 0) : 0;
if (j < byteLen) bytes[j++] = (a << 2) | (b >> 4);
if (j < byteLen) bytes[j++] = ((b & 0xf) << 4) | (c >> 2);
if (j < byteLen) bytes[j++] = ((c & 0x3) << 6) | d;
}
return bytes;
}
// ── Encoding ─────────────────────────────────────────────────────────
/** Number of purpose bits defined by the TCF spec. */
const NUM_PURPOSES = 24;
/** Number of special feature bits. */
const NUM_SPECIAL_FEATURES = 12;
/** Convert a JS timestamp (ms) to TCF deciseconds. */
export function msToDeciseconds(ms: number): number {
return Math.round(ms / 100);
}
/** Convert TCF deciseconds to a JS timestamp (ms). */
export function decisecondsToMs(ds: number): number {
return ds * 100;
}
/** Create a default (empty) TCModel with sensible defaults. */
export function createTCModel(overrides?: Partial<TCModel>): TCModel {
const now = msToDeciseconds(Date.now());
return {
version: 2,
created: now,
lastUpdated: now,
cmpId: 0,
cmpVersion: 1,
consentScreen: 1,
consentLanguage: 'EN',
vendorListVersion: 0,
tcfPolicyVersion: 4,
isServiceSpecific: true,
useNonStandardTexts: false,
specialFeatureOptIns: new Set(),
purposeConsents: new Set(),
purposeLegitimateInterests: new Set(),
purposeOneTreatment: false,
publisherCC: 'GB',
vendorConsents: new Set(),
vendorLegitimateInterests: new Set(),
publisherRestrictions: [],
...overrides,
};
}
/** Encode a TCModel into a TC string (core segment only). */
export function encodeTCString(model: TCModel): string {
const writer = new BitWriter();
// Core segment
writer.writeInt(model.version, 6);
writer.writeInt(model.created, 36);
writer.writeInt(model.lastUpdated, 36);
writer.writeInt(model.cmpId, 12);
writer.writeInt(model.cmpVersion, 12);
writer.writeInt(model.consentScreen, 6);
writer.writeLetters(model.consentLanguage);
writer.writeInt(model.vendorListVersion, 12);
writer.writeInt(model.tcfPolicyVersion, 6);
writer.writeBool(model.isServiceSpecific);
writer.writeBool(model.useNonStandardTexts);
writer.writeBitfield(model.specialFeatureOptIns, NUM_SPECIAL_FEATURES);
writer.writeBitfield(model.purposeConsents, NUM_PURPOSES);
writer.writeBitfield(model.purposeLegitimateInterests, NUM_PURPOSES);
writer.writeBool(model.purposeOneTreatment);
writer.writeLetters(model.publisherCC);
// Vendor consent section (bitfield encoding)
const maxVendorConsent = maxId(model.vendorConsents);
writer.writeInt(maxVendorConsent, 16);
if (maxVendorConsent > 0) {
writer.writeBool(false); // IsRangeEncoding = false (bitfield)
writer.writeBitfield(model.vendorConsents, maxVendorConsent);
}
// Vendor legitimate interest section (bitfield encoding)
const maxVendorLI = maxId(model.vendorLegitimateInterests);
writer.writeInt(maxVendorLI, 16);
if (maxVendorLI > 0) {
writer.writeBool(false); // IsRangeEncoding = false (bitfield)
writer.writeBitfield(model.vendorLegitimateInterests, maxVendorLI);
}
// Publisher restrictions
writer.writeInt(model.publisherRestrictions.length, 12);
for (const restriction of model.publisherRestrictions) {
writer.writeInt(restriction.purposeId, 6);
writer.writeInt(restriction.restrictionType, 2);
// Encode vendor IDs as ranges
const sortedVendors = [...restriction.vendorIds].sort((a, b) => a - b);
const ranges = toRanges(sortedVendors);
writer.writeInt(ranges.length, 12);
for (const [start, end] of ranges) {
const isRange = start !== end;
writer.writeBool(isRange);
writer.writeInt(start, 16);
if (isRange) {
writer.writeInt(end, 16);
}
}
}
return bytesToBase64url(writer.toBytes());
}
/** Decode a TC string (core segment) back into a TCModel. */
export function decodeTCString(tcString: string): TCModel {
// Only parse the core segment (first dot-separated part)
const coreSegment = tcString.split('.')[0];
const bytes = base64urlToBytes(coreSegment);
const reader = new BitReader(bytes);
const version = reader.readInt(6);
const created = reader.readInt(36);
const lastUpdated = reader.readInt(36);
const cmpId = reader.readInt(12);
const cmpVersion = reader.readInt(12);
const consentScreen = reader.readInt(6);
const consentLanguage = reader.readLetters();
const vendorListVersion = reader.readInt(12);
const tcfPolicyVersion = reader.readInt(6);
const isServiceSpecific = reader.readBool();
const useNonStandardTexts = reader.readBool();
const specialFeatureOptIns = reader.readBitfield(NUM_SPECIAL_FEATURES);
const purposeConsents = reader.readBitfield(NUM_PURPOSES);
const purposeLegitimateInterests = reader.readBitfield(NUM_PURPOSES);
const purposeOneTreatment = reader.readBool();
const publisherCC = reader.readLetters();
// Vendor consents
let vendorConsents = new Set<number>();
const maxVendorConsent = reader.readInt(16);
if (maxVendorConsent > 0) {
const isRange = reader.readBool();
if (isRange) {
vendorConsents = readRangeEntries(reader);
} else {
vendorConsents = reader.readBitfield(maxVendorConsent);
}
}
// Vendor legitimate interests
let vendorLegitimateInterests = new Set<number>();
const maxVendorLI = reader.readInt(16);
if (maxVendorLI > 0) {
const isRange = reader.readBool();
if (isRange) {
vendorLegitimateInterests = readRangeEntries(reader);
} else {
vendorLegitimateInterests = reader.readBitfield(maxVendorLI);
}
}
// Publisher restrictions
const publisherRestrictions: PublisherRestriction[] = [];
if (reader.hasRemaining(12)) {
const numRestrictions = reader.readInt(12);
for (let i = 0; i < numRestrictions; i++) {
const purposeId = reader.readInt(6);
const restrictionType = reader.readInt(2) as RestrictionType;
const numRanges = reader.readInt(12);
const vendorIds = new Set<number>();
for (let j = 0; j < numRanges; j++) {
const isRangeEntry = reader.readBool();
const startId = reader.readInt(16);
if (isRangeEntry) {
const endId = reader.readInt(16);
for (let v = startId; v <= endId; v++) vendorIds.add(v);
} else {
vendorIds.add(startId);
}
}
publisherRestrictions.push({ purposeId, restrictionType, vendorIds });
}
}
return {
version,
created,
lastUpdated,
cmpId,
cmpVersion,
consentScreen,
consentLanguage,
vendorListVersion,
tcfPolicyVersion,
isServiceSpecific,
useNonStandardTexts,
specialFeatureOptIns,
purposeConsents,
purposeLegitimateInterests,
purposeOneTreatment,
publisherCC,
vendorConsents,
vendorLegitimateInterests,
publisherRestrictions,
};
}
// ── Range helpers ────────────────────────────────────────────────────
/** Read range-encoded vendor entries from a BitReader. */
function readRangeEntries(reader: BitReader): Set<number> {
const ids = new Set<number>();
const numEntries = reader.readInt(12);
for (let i = 0; i < numEntries; i++) {
const isRange = reader.readBool();
const startId = reader.readInt(16);
if (isRange) {
const endId = reader.readInt(16);
for (let v = startId; v <= endId; v++) ids.add(v);
} else {
ids.add(startId);
}
}
return ids;
}
/** Convert sorted vendor IDs to contiguous [start, end] ranges. */
function toRanges(sorted: number[]): [number, number][] {
if (sorted.length === 0) return [];
const ranges: [number, number][] = [];
let start = sorted[0];
let end = sorted[0];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] === end + 1) {
end = sorted[i];
} else {
ranges.push([start, end]);
start = sorted[i];
end = sorted[i];
}
}
ranges.push([start, end]);
return ranges;
}
/** Get the maximum ID from a Set, or 0 if empty. */
function maxId(ids: Set<number>): number {
let max = 0;
ids.forEach((id) => {
if (id > max) max = id;
});
return max;
}
// ── __tcfapi interface ───────────────────────────────────────────────
interface TcfApiState {
cmpId: number;
cmpVersion: number;
gdprApplies: boolean;
tcModel: TCModel | null;
tcString: string;
displayStatus: 'visible' | 'hidden' | 'disabled';
listeners: Map<number, TcfApiCallback>;
nextListenerId: number;
}
let apiState: TcfApiState | null = null;
/** Build a TCData response from the current state. */
function buildTCData(
state: TcfApiState,
eventStatus: TCData['eventStatus'],
listenerId?: number
): TCData {
const model = state.tcModel;
const purposeConsents: Record<string, boolean> = {};
const purposeLI: Record<string, boolean> = {};
const vendorConsents: Record<string, boolean> = {};
const vendorLI: Record<string, boolean> = {};
const specialFeatures: Record<string, boolean> = {};
if (model) {
for (let i = 1; i <= NUM_PURPOSES; i++) {
purposeConsents[String(i)] = model.purposeConsents.has(i);
purposeLI[String(i)] = model.purposeLegitimateInterests.has(i);
}
const maxVC = maxId(model.vendorConsents);
const maxVLI = maxId(model.vendorLegitimateInterests);
const vendorMax = Math.max(maxVC, maxVLI);
for (let i = 1; i <= vendorMax; i++) {
vendorConsents[String(i)] = model.vendorConsents.has(i);
vendorLI[String(i)] = model.vendorLegitimateInterests.has(i);
}
for (let i = 1; i <= NUM_SPECIAL_FEATURES; i++) {
specialFeatures[String(i)] = model.specialFeatureOptIns.has(i);
}
}
return {
tcString: state.tcString,
tcfPolicyVersion: model?.tcfPolicyVersion ?? 4,
cmpId: state.cmpId,
cmpVersion: state.cmpVersion,
gdprApplies: state.gdprApplies,
eventStatus,
cmpStatus: 'loaded',
listenerId,
isServiceSpecific: model?.isServiceSpecific ?? true,
useNonStandardTexts: model?.useNonStandardTexts ?? false,
publisherCC: model?.publisherCC ?? 'GB',
purposeOneTreatment: model?.purposeOneTreatment ?? false,
purpose: {
consents: purposeConsents,
legitimateInterests: purposeLI,
},
vendor: {
consents: vendorConsents,
legitimateInterests: vendorLI,
},
specialFeatureOptins: specialFeatures,
};
}
/** The __tcfapi function handler. */
function tcfApiHandler(
command: string,
version: number,
callback: TcfApiCallback,
_parameter?: unknown
): void {
if (!apiState) {
callback(false, false);
return;
}
if (version !== 2) {
callback(false, false);
return;
}
switch (command) {
case 'ping': {
const pingReturn: PingReturn = {
gdprApplies: apiState.gdprApplies,
cmpLoaded: true,
cmpStatus: 'loaded',
displayStatus: apiState.displayStatus,
apiVersion: '2.2',
cmpVersion: apiState.cmpVersion,
cmpId: apiState.cmpId,
gvlVersion: apiState.tcModel?.vendorListVersion ?? 0,
tcfPolicyVersion: apiState.tcModel?.tcfPolicyVersion ?? 4,
};
callback(pingReturn, true);
break;
}
case 'getTCData': {
const eventStatus = apiState.tcString ? 'tcloaded' : 'cmpuishown';
callback(buildTCData(apiState, eventStatus), true);
break;
}
case 'addEventListener': {
const listenerId = apiState.nextListenerId++;
apiState.listeners.set(listenerId, callback);
const eventStatus = apiState.tcString ? 'tcloaded' : 'cmpuishown';
try {
callback(buildTCData(apiState, eventStatus, listenerId), true);
} catch {
// Swallow listener errors during initial notification
}
break;
}
case 'removeEventListener': {
// _parameter is the listenerId to remove
const id = _parameter as number;
const removed = apiState.listeners.delete(id);
callback(removed, removed);
break;
}
default:
callback(false, false);
}
}
/** Process any queued __tcfapi calls from the stub. */
function processQueuedCalls(): void {
if (typeof window === 'undefined') return;
const queue = window.__tcfapiQueue;
if (Array.isArray(queue)) {
for (const args of queue) {
tcfApiHandler(
args[0] as string,
args[1] as number,
args[2] as TcfApiCallback,
args[3]
);
}
queue.length = 0;
}
}
/**
* Install the __tcfapi global function and process any queued calls.
*
* @param cmpId Registered CMP ID from the IAB CMP List.
* @param cmpVersion CMP version number.
* @param gdprApplies Whether GDPR applies to the current user.
*/
export function installTcfApi(
cmpId: number,
cmpVersion: number,
gdprApplies: boolean = true
): void {
apiState = {
cmpId,
cmpVersion,
gdprApplies,
tcModel: null,
tcString: '',
displayStatus: 'hidden',
listeners: new Map(),
nextListenerId: 1,
};
if (typeof window !== 'undefined') {
window.__tcfapi = tcfApiHandler;
}
processQueuedCalls();
}
/** Remove the __tcfapi global. */
export function removeTcfApi(): void {
if (typeof window !== 'undefined') {
delete window.__tcfapi;
delete window.__tcfapiQueue;
}
apiState = null;
}
/** Update the TCF state and notify all listeners. */
export function updateTcfConsent(model: TCModel): string {
const tcString = encodeTCString(model);
if (apiState) {
apiState.tcModel = model;
apiState.tcString = tcString;
apiState.displayStatus = 'hidden';
// Notify all listeners
apiState.listeners.forEach((callback, listenerId) => {
try {
callback(buildTCData(apiState!, 'useractioncomplete', listenerId), true);
} catch {
// Swallow listener errors
}
});
}
return tcString;
}
/** Set the display status (visible when banner is shown). */
export function setTcfDisplayStatus(status: 'visible' | 'hidden' | 'disabled'): void {
if (apiState) {
apiState.displayStatus = status;
}
}
/** Get the current TC string (empty if no consent yet). */
export function getTcString(): string {
return apiState?.tcString ?? '';
}

151
apps/banner/src/types.ts Normal file
View File

@@ -0,0 +1,151 @@
/** Consent category slugs. */
export type CategorySlug =
| 'necessary'
| 'functional'
| 'analytics'
| 'marketing'
| 'personalisation';
/** Consent state stored in the first-party cookie. */
export interface ConsentState {
/** Unique visitor identifier. */
visitorId: string;
/** Categories the visitor has accepted. */
accepted: CategorySlug[];
/** Categories the visitor has rejected. */
rejected: CategorySlug[];
/** ISO 8601 timestamp of consent. */
consentedAt: string;
/** Version of the banner that collected consent. */
bannerVersion: string;
/** TC string if TCF is enabled. */
tcString?: string;
/** GPP string if GPP is enabled. */
gppString?: string;
/** Google Consent Mode state at time of consent. */
gcmState?: Record<string, 'granted' | 'denied'>;
/** Config version (site_config ID) at time of consent, used for re-consent detection. */
configVersion?: string;
/** Whether GPC signal was detected in the browser. */
gpcDetected?: boolean;
/** Whether GPC signal was honoured (auto-opt-out applied). */
gpcHonoured?: boolean;
}
/** Server-side consent profile returned by the sync API. */
export interface ServerConsentProfile {
id: string;
org_id: string;
consent_group_id: string | null;
user_identifier: string;
categories_consented: string[];
categories_rejected: string[];
tc_string: string | null;
gpp_string: string | null;
gcm_state: Record<string, 'granted' | 'denied'> | null;
last_updated_at: string;
last_site_id: string | null;
created_at: string;
}
/** A/B test variant as delivered in site config. */
export interface ABTestVariant {
id: string;
name: string;
traffic_percentage: number;
banner_config_override: Partial<BannerConfig> | null;
is_control: boolean;
}
/** Active A/B test data included in site config. */
export interface ABTestConfig {
id: string;
name: string;
variants: ABTestVariant[];
}
/** Site configuration fetched from the API/CDN. */
export interface SiteConfig {
id: string;
site_id: string;
blocking_mode: 'opt_in' | 'opt_out' | 'informational';
regional_modes: Record<string, string> | null;
tcf_enabled: boolean;
gpp_enabled: boolean;
gpp_supported_apis: string[];
gpc_enabled: boolean;
gpc_jurisdictions: string[];
gpc_global_honour: boolean;
gcm_enabled: boolean;
gcm_default: Record<string, 'granted' | 'denied'> | null;
shopify_privacy_enabled: boolean;
banner_config: BannerConfig | null;
privacy_policy_url: string | null;
terms_url: string | null;
consent_expiry_days: number;
/** Consent group ID for cross-domain sync (null if not in a group). */
consent_group_id: string | null;
/** Active A/B test (null if none running). */
ab_test: ABTestConfig | null;
/** Initiator map: root script URL → category for root-level blocking. */
initiator_map: InitiatorMapping[] | null;
}
/** Maps a root initiator script to the cookie category it ultimately sets. */
export interface InitiatorMapping {
/** Root script URL pattern (matched against script src). */
root_script: string;
/** Category of cookies set by this initiator chain. */
category: CategorySlug;
}
/** Per-button styling configuration. */
export interface ButtonConfig {
backgroundColour?: string;
textColour?: string;
borderColour?: string;
style?: 'filled' | 'outline' | 'text';
}
/** Text content configuration for the banner. */
export interface BannerTextConfig {
title?: string;
description?: string;
acceptAll?: string;
rejectAll?: string;
managePreferences?: string;
savePreferences?: string;
}
/** Visual configuration for the banner. */
export interface BannerConfig {
displayMode?: 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup';
cornerPosition?: 'left' | 'right';
primaryColour?: string;
backgroundColour?: string;
textColour?: string;
buttonStyle?: 'filled' | 'outline';
fontFamily?: string;
customFontUrl?: string;
borderRadius?: number;
showLogo?: boolean;
logoUrl?: string;
showRejectAll?: boolean;
showManagePreferences?: boolean;
showCloseButton?: boolean;
showCookieCount?: boolean;
acceptButton?: ButtonConfig;
rejectButton?: ButtonConfig;
manageButton?: ButtonConfig;
text?: BannerTextConfig;
}
/** Google Consent Mode consent types. */
export type GcmConsentType =
| 'ad_storage'
| 'ad_user_data'
| 'ad_personalization'
| 'analytics_storage'
| 'functionality_storage'
| 'personalization_storage'
| 'security_storage';