Skip to content

Commit f8e493a

Browse files
feat(audience): scaffold @imtbl/audience-web-sdk with consent and cookies
- Package scaffold: package.json, tsconfig, eslint, jest config - Add packages/audience/sdk and packages/audience/web to pnpm workspace - Cookie helpers: session ID (30min rolling) - 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 f54168f commit f8e493a

16 files changed

Lines changed: 529 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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
});
10+
11+
describe('ConsentManager', () => {
12+
it('initialises with the provided consent level', () => {
13+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
14+
expect(manager.getLevel()).toBe('anonymous');
15+
});
16+
17+
it('calls onPurgeQueue when downgrading to none', () => {
18+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
19+
const onPurge = jest.fn();
20+
const onClear = jest.fn();
21+
22+
manager.setLevel('none', 'anon-123', {
23+
onPurgeQueue: onPurge,
24+
onClearCookies: onClear,
25+
});
26+
27+
expect(onPurge).toHaveBeenCalled();
28+
expect(onClear).toHaveBeenCalled();
29+
expect(manager.getLevel()).toBe('none');
30+
});
31+
32+
it('calls onStripIdentity when downgrading from full to anonymous', () => {
33+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
34+
const onStrip = jest.fn();
35+
36+
manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip });
37+
38+
expect(onStrip).toHaveBeenCalled();
39+
expect(manager.getLevel()).toBe('anonymous');
40+
});
41+
42+
it('syncs consent to server via PUT on setLevel', () => {
43+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
44+
manager.setLevel('full', 'anon-123');
45+
46+
expect(mockFetch).toHaveBeenCalledWith(
47+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
48+
expect.objectContaining({
49+
method: 'PUT',
50+
body: JSON.stringify({
51+
anonymousId: 'anon-123',
52+
status: 'full',
53+
source: 'TestSDK',
54+
}),
55+
}),
56+
);
57+
});
58+
59+
describe('DNT / GPC', () => {
60+
it('forces consent to none when DNT is set', () => {
61+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
62+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
63+
expect(manager.getLevel()).toBe('none');
64+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
65+
});
66+
67+
it('forces consent to none when GPC is set', () => {
68+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: true, configurable: true });
69+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
70+
expect(manager.getLevel()).toBe('none');
71+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: false, configurable: true });
72+
});
73+
74+
it('blocks setLevel upgrade when DNT is active', () => {
75+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
76+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
77+
manager.setLevel('full', 'anon-123');
78+
expect(manager.getLevel()).toBe('none');
79+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
80+
});
81+
82+
it('still allows downgrade to none when DNT is active', () => {
83+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
84+
expect(manager.getLevel()).toBe('full');
85+
86+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
87+
const onPurge = jest.fn();
88+
manager.setLevel('none', 'anon-123', { onPurgeQueue: onPurge });
89+
expect(manager.getLevel()).toBe('none');
90+
expect(onPurge).toHaveBeenCalled();
91+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
92+
});
93+
94+
it('detectPrivacySignal returns false when no signals set', () => {
95+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
96+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: false, configurable: true });
97+
expect(detectPrivacySignal()).toBe(false);
98+
});
99+
});
100+
101+
it('fetches server consent status', async () => {
102+
mockFetch.mockResolvedValueOnce({
103+
ok: true,
104+
json: async () => ({ status: 'anonymous' }),
105+
});
106+
107+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
108+
const status = await manager.fetchServerConsent('anon-123');
109+
110+
expect(status).toBe('anonymous');
111+
expect(mockFetch).toHaveBeenCalledWith(
112+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent?anonymousId=anon-123',
113+
expect.objectContaining({
114+
headers: { 'x-immutable-publishable-key': 'pk_test' },
115+
}),
116+
);
117+
});
118+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
16+
/**
17+
* Check if the browser signals a Do Not Track or Global Privacy Control
18+
* preference. When either is active, consent should be capped at 'none'.
19+
*/
20+
export function detectPrivacySignal(): boolean {
21+
if (!isBrowser()) return false;
22+
const nav = navigator as any;
23+
if (nav.doNotTrack === '1' || (window as any).doNotTrack === '1') return true;
24+
if (nav.globalPrivacyControl === true) return true;
25+
return false;
26+
}
27+
28+
export interface ConsentCallbacks {
29+
onPurgeQueue?: () => void;
30+
onStripIdentity?: () => void;
31+
onClearCookies?: () => void;
32+
}
33+
34+
export class ConsentManager {
35+
private level: ConsentLevel;
36+
37+
private readonly baseUrl: string;
38+
39+
private readonly publishableKey: string;
40+
41+
private readonly source: string;
42+
43+
private readonly cookieDomain?: string;
44+
45+
constructor(
46+
environment: Environment,
47+
publishableKey: string,
48+
initialConsent: ConsentLevel,
49+
rawSource: string,
50+
cookieDomain?: string,
51+
) {
52+
this.baseUrl = getBaseUrl(environment);
53+
this.publishableKey = publishableKey;
54+
this.source = truncateSource(rawSource);
55+
this.cookieDomain = cookieDomain;
56+
57+
// DNT / GPC: auto-downgrade to 'none' if browser signals tracking opt-out
58+
if (detectPrivacySignal()) {
59+
this.level = 'none';
60+
return;
61+
}
62+
63+
this.level = initialConsent;
64+
}
65+
66+
getLevel(): ConsentLevel {
67+
return this.level;
68+
}
69+
70+
setLevel(
71+
level: ConsentLevel,
72+
anonymousId: string,
73+
callbacks?: ConsentCallbacks,
74+
): void {
75+
// DNT / GPC active: refuse to upgrade consent
76+
if (level !== 'none' && detectPrivacySignal()) return;
77+
78+
const { level: previous } = this;
79+
this.level = level;
80+
81+
// Downgrade: full/anonymous -> none — purge everything
82+
if (level === 'none') {
83+
callbacks?.onPurgeQueue?.();
84+
callbacks?.onClearCookies?.();
85+
} else if (level === 'anonymous' && previous === 'full') {
86+
// Downgrade: full -> anonymous — strip PII, keep anonymous events
87+
callbacks?.onStripIdentity?.();
88+
}
89+
90+
// Sync to server (fire-and-forget)
91+
this.syncToServer(anonymousId, level);
92+
}
93+
94+
/** Fetch server-side consent status for reconciliation. */
95+
async fetchServerConsent(anonymousId: string): Promise<ConsentStatus | undefined> {
96+
try {
97+
const url = `${this.baseUrl}${CONSENT_PATH}?anonymousId=${encodeURIComponent(anonymousId)}`;
98+
const res = await fetch(url, {
99+
headers: { 'x-immutable-publishable-key': this.publishableKey },
100+
});
101+
if (!res.ok) return undefined;
102+
const body = (await res.json()) as { status: ConsentStatus };
103+
return body.status;
104+
} catch {
105+
return undefined;
106+
}
107+
}
108+
109+
clearCookies(): void {
110+
deleteCookie(COOKIE_NAME, this.cookieDomain);
111+
deleteCookie(SESSION_COOKIE, this.cookieDomain);
112+
}
113+
114+
private async syncToServer(anonymousId: string, status: ConsentLevel): Promise<void> {
115+
try {
116+
await fetch(`${this.baseUrl}${CONSENT_PATH}`, {
117+
method: 'PUT',
118+
headers: {
119+
'Content-Type': 'application/json',
120+
'x-immutable-publishable-key': this.publishableKey,
121+
},
122+
body: JSON.stringify({ anonymousId, status, source: this.source }),
123+
});
124+
} catch {
125+
// Fire-and-forget — consent sync failure shouldn't break the SDK
126+
}
127+
}
128+
}
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)