Skip to content

Commit b90f61a

Browse files
feat(audience): scaffold @imtbl/audience-web-sdk with consent and cookies
- Package scaffold: package.json, tsconfig, eslint, jest config - WebSDKConfig type, library name/version constants - Context wrapper (passes web SDK name to core's collectContext) - Cookie helpers: session ID (30min rolling), consent cookie (1yr) - ConsentManager: 3-tier consent, DNT/GPC detection, server sync - Debug logger for dev mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 24a1dd6 commit b90f61a

14 files changed

Lines changed: 547 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
extends: ['../../../.eslintrc'],
3+
parserOptions: {
4+
project: './tsconfig.eslint.json',
5+
tsconfigRootDir: __dirname,
6+
},
7+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Config } from 'jest';
2+
3+
const config: Config = {
4+
roots: ['<rootDir>/src'],
5+
moduleDirectories: ['node_modules', 'src'],
6+
testEnvironment: 'jsdom',
7+
transform: {
8+
'^.+\\.(t|j)sx?$': '@swc/jest',
9+
},
10+
moduleNameMapper: {
11+
'^@imtbl/audience-core$': '<rootDir>/../core/src/index.ts',
12+
},
13+
};
14+
15+
export default config;

packages/audience/web/package.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"name": "@imtbl/audience-web-sdk",
3+
"description": "Immutable Audience Web SDK — consent-aware event tracking and identity management",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
7+
"dependencies": {
8+
"@imtbl/audience-core": "workspace:*"
9+
},
10+
"devDependencies": {
11+
"@swc/core": "^1.4.2",
12+
"@swc/jest": "^0.2.37",
13+
"@types/jest": "^29.5.12",
14+
"@types/node": "^22.10.7",
15+
"eslint": "^8.56.0",
16+
"jest": "^29.7.0",
17+
"jest-environment-jsdom": "^29.4.3",
18+
"ts-jest": "^29.1.0",
19+
"tsup": "^8.3.0",
20+
"typescript": "^5.6.2"
21+
},
22+
"engines": {
23+
"node": ">=20.11.0"
24+
},
25+
"exports": {
26+
"development": {
27+
"types": "./src/index.ts",
28+
"browser": "./dist/browser/index.js",
29+
"require": "./dist/node/index.cjs",
30+
"default": "./dist/node/index.js"
31+
},
32+
"default": {
33+
"types": "./dist/types/index.d.ts",
34+
"browser": "./dist/browser/index.js",
35+
"require": "./dist/node/index.cjs",
36+
"default": "./dist/node/index.js"
37+
}
38+
},
39+
"files": ["dist"],
40+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
41+
"main": "dist/node/index.cjs",
42+
"module": "dist/node/index.js",
43+
"browser": "dist/browser/index.js",
44+
"publishConfig": {
45+
"access": "public"
46+
},
47+
"repository": "immutable/ts-immutable-sdk.git",
48+
"scripts": {
49+
"build": "pnpm transpile && pnpm typegen",
50+
"transpile": "tsup src/index.ts --config ../../../tsup.config.js",
51+
"transpile:cdn": "tsup --config tsup.cdn.js",
52+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
53+
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
54+
"test": "jest --passWithNoTests",
55+
"test:watch": "jest --watch",
56+
"demo": "pnpm build && npx serve -l 3456 --cors ..",
57+
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
58+
},
59+
"type": "module",
60+
"types": "./dist/types/index.d.ts"
61+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Web SDK-specific constants.
2+
// Backend endpoints and base URLs come from @imtbl/audience-core.
3+
4+
export const LIBRARY_NAME = '@imtbl/audience-web-sdk';
5+
// Replaced at build time by esbuild replace plugin
6+
export const LIBRARY_VERSION = '__SDK_VERSION__';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ConsentManager, detectPrivacySignal } from './consent';
2+
3+
// Mock fetch for server sync
4+
const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
5+
global.fetch = mockFetch;
6+
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
document.cookie.split(';').forEach((c) => {
10+
document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`;
11+
});
12+
});
13+
14+
describe('ConsentManager', () => {
15+
it('initialises with the provided consent level', () => {
16+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
17+
expect(manager.getLevel()).toBe('anonymous');
18+
});
19+
20+
it('honours existing consent cookie over initial config', () => {
21+
document.cookie = '_imtbl_consent=full;path=/';
22+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
23+
expect(manager.getLevel()).toBe('full');
24+
});
25+
26+
it('persists consent to cookie on init', () => {
27+
// eslint-disable-next-line no-new
28+
new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
29+
expect(document.cookie).toContain('_imtbl_consent=anonymous');
30+
});
31+
32+
it('calls onPurgeQueue when downgrading to none', () => {
33+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
34+
const onPurge = jest.fn();
35+
const onClear = jest.fn();
36+
37+
manager.setLevel('none', 'anon-123', {
38+
onPurgeQueue: onPurge,
39+
onClearCookies: onClear,
40+
});
41+
42+
expect(onPurge).toHaveBeenCalled();
43+
expect(onClear).toHaveBeenCalled();
44+
expect(manager.getLevel()).toBe('none');
45+
});
46+
47+
it('calls onStripIdentity when downgrading from full to anonymous', () => {
48+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
49+
const onStrip = jest.fn();
50+
51+
manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip });
52+
53+
expect(onStrip).toHaveBeenCalled();
54+
expect(manager.getLevel()).toBe('anonymous');
55+
});
56+
57+
it('syncs consent to server via PUT on setLevel', () => {
58+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
59+
manager.setLevel('full', 'anon-123');
60+
61+
expect(mockFetch).toHaveBeenCalledWith(
62+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
63+
expect.objectContaining({
64+
method: 'PUT',
65+
body: JSON.stringify({
66+
anonymousId: 'anon-123',
67+
status: 'full',
68+
source: 'TestSDK',
69+
}),
70+
}),
71+
);
72+
});
73+
74+
describe('DNT / GPC', () => {
75+
it('forces consent to none when DNT is set', () => {
76+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
77+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
78+
expect(manager.getLevel()).toBe('none');
79+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
80+
});
81+
82+
it('forces consent to none when GPC is set', () => {
83+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: true, configurable: true });
84+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
85+
expect(manager.getLevel()).toBe('none');
86+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: false, configurable: true });
87+
});
88+
89+
it('blocks setLevel upgrade when DNT is active', () => {
90+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
91+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
92+
manager.setLevel('full', 'anon-123');
93+
expect(manager.getLevel()).toBe('none');
94+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
95+
});
96+
97+
it('still allows downgrade to none when DNT is active', () => {
98+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
99+
expect(manager.getLevel()).toBe('full');
100+
101+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
102+
const onPurge = jest.fn();
103+
manager.setLevel('none', 'anon-123', { onPurgeQueue: onPurge });
104+
expect(manager.getLevel()).toBe('none');
105+
expect(onPurge).toHaveBeenCalled();
106+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
107+
});
108+
109+
it('detectPrivacySignal returns false when no signals set', () => {
110+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
111+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: false, configurable: true });
112+
expect(detectPrivacySignal()).toBe(false);
113+
});
114+
});
115+
116+
it('fetches server consent status', async () => {
117+
mockFetch.mockResolvedValueOnce({
118+
ok: true,
119+
json: async () => ({ status: 'anonymous' }),
120+
});
121+
122+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
123+
const status = await manager.fetchServerConsent('anon-123');
124+
125+
expect(status).toBe('anonymous');
126+
expect(mockFetch).toHaveBeenCalledWith(
127+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent?anonymousId=anon-123',
128+
expect.objectContaining({
129+
headers: { 'x-immutable-publishable-key': 'pk_test' },
130+
}),
131+
);
132+
});
133+
});
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import type {
2+
ConsentLevel,
3+
ConsentStatus,
4+
Environment,
5+
} from '@imtbl/audience-core';
6+
import {
7+
CONSENT_PATH,
8+
COOKIE_NAME,
9+
SESSION_COOKIE,
10+
getBaseUrl,
11+
isBrowser,
12+
deleteCookie,
13+
truncateSource,
14+
} from '@imtbl/audience-core';
15+
import { getConsentCookie, setConsentCookie } from './cookie';
16+
17+
/**
18+
* Check if the browser signals a Do Not Track or Global Privacy Control
19+
* preference. When either is active, consent should be capped at 'none'.
20+
*/
21+
export function detectPrivacySignal(): boolean {
22+
if (!isBrowser()) return false;
23+
const nav = navigator as any;
24+
if (nav.doNotTrack === '1' || (window as any).doNotTrack === '1') return true;
25+
if (nav.globalPrivacyControl === true) return true;
26+
return false;
27+
}
28+
29+
export interface ConsentCallbacks {
30+
onPurgeQueue?: () => void;
31+
onStripIdentity?: () => void;
32+
onClearCookies?: () => void;
33+
}
34+
35+
export class ConsentManager {
36+
private level: ConsentLevel;
37+
38+
private readonly baseUrl: string;
39+
40+
private readonly publishableKey: string;
41+
42+
private readonly source: string;
43+
44+
private readonly cookieDomain?: string;
45+
46+
constructor(
47+
environment: Environment,
48+
publishableKey: string,
49+
initialConsent: ConsentLevel,
50+
rawSource: string,
51+
cookieDomain?: string,
52+
) {
53+
this.baseUrl = getBaseUrl(environment);
54+
this.publishableKey = publishableKey;
55+
this.source = truncateSource(rawSource);
56+
this.cookieDomain = cookieDomain;
57+
58+
// DNT / GPC: auto-downgrade to 'none' if browser signals tracking opt-out
59+
if (detectPrivacySignal()) {
60+
this.level = 'none';
61+
this.persistLocal();
62+
return;
63+
}
64+
65+
// Honour existing consent cookie if set (shared with pixel)
66+
const persisted = getConsentCookie() as ConsentLevel | undefined;
67+
if (persisted && ['none', 'anonymous', 'full'].includes(persisted)) {
68+
this.level = persisted;
69+
} else {
70+
this.level = initialConsent;
71+
}
72+
this.persistLocal();
73+
}
74+
75+
getLevel(): ConsentLevel {
76+
return this.level;
77+
}
78+
79+
setLevel(
80+
level: ConsentLevel,
81+
anonymousId: string,
82+
callbacks?: ConsentCallbacks,
83+
): void {
84+
// DNT / GPC active: refuse to upgrade consent
85+
if (level !== 'none' && detectPrivacySignal()) return;
86+
87+
const { level: previous } = this;
88+
this.level = level;
89+
this.persistLocal();
90+
91+
// Downgrade: full/anonymous -> none — purge everything
92+
if (level === 'none') {
93+
callbacks?.onPurgeQueue?.();
94+
callbacks?.onClearCookies?.();
95+
} else if (level === 'anonymous' && previous === 'full') {
96+
// Downgrade: full -> anonymous — strip PII, keep anonymous events
97+
callbacks?.onStripIdentity?.();
98+
}
99+
100+
// Sync to server (fire-and-forget)
101+
this.syncToServer(anonymousId, level);
102+
}
103+
104+
/** Fetch server-side consent status for reconciliation. */
105+
async fetchServerConsent(anonymousId: string): Promise<ConsentStatus | undefined> {
106+
try {
107+
const url = `${this.baseUrl}${CONSENT_PATH}?anonymousId=${encodeURIComponent(anonymousId)}`;
108+
const res = await fetch(url, {
109+
headers: { 'x-immutable-publishable-key': this.publishableKey },
110+
});
111+
if (!res.ok) return undefined;
112+
const body = (await res.json()) as { status: ConsentStatus };
113+
return body.status;
114+
} catch {
115+
return undefined;
116+
}
117+
}
118+
119+
clearCookies(): void {
120+
deleteCookie(COOKIE_NAME, this.cookieDomain);
121+
deleteCookie(SESSION_COOKIE, this.cookieDomain);
122+
// Keep consent cookie — remembers the "none" choice
123+
}
124+
125+
private persistLocal(): void {
126+
setConsentCookie(this.level, this.cookieDomain);
127+
}
128+
129+
private async syncToServer(anonymousId: string, status: ConsentLevel): Promise<void> {
130+
try {
131+
await fetch(`${this.baseUrl}${CONSENT_PATH}`, {
132+
method: 'PUT',
133+
headers: {
134+
'Content-Type': 'application/json',
135+
'x-immutable-publishable-key': this.publishableKey,
136+
},
137+
body: JSON.stringify({ anonymousId, status, source: this.source }),
138+
});
139+
} catch {
140+
// Fire-and-forget — consent sync failure shouldn't break the SDK
141+
}
142+
}
143+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { EventContext } from '@imtbl/audience-core';
2+
import { collectContext as coreCollectContext } from '@imtbl/audience-core';
3+
import { LIBRARY_NAME, LIBRARY_VERSION } from './config';
4+
5+
export function collectContext(): EventContext {
6+
return coreCollectContext(LIBRARY_NAME, LIBRARY_VERSION);
7+
}

0 commit comments

Comments
 (0)