Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/audience/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export const FLUSH_SIZE = 20;
export const COOKIE_NAME = 'imtbl_anon_id';
export const SESSION_COOKIE = '_imtbl_sid';
export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years
export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds

export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment];
116 changes: 116 additions & 0 deletions packages/audience/core/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { createConsentManager } from './consent';

// Mock fetch globally
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch;

function createMockQueue() {
return {
purge: jest.fn(),
transform: jest.fn(),
enqueue: jest.fn(),
flush: jest.fn(),
flushUnload: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
destroy: jest.fn(),
clear: jest.fn(),
get length() { return 0; },
} as any;
}

beforeEach(() => {
jest.clearAllMocks();
});

describe('createConsentManager', () => {
it('defaults to none when no initial level provided', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
expect(manager.level).toBe('none');
});

it('uses the initial level when provided', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
expect(manager.level).toBe('anonymous');
});

it('upgrades consent without modifying queue', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');

manager.setLevel('anonymous');
expect(manager.level).toBe('anonymous');
expect(queue.purge).not.toHaveBeenCalled();
expect(queue.transform).not.toHaveBeenCalled();
});

it('purges queue on downgrade to none', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');

manager.setLevel('none');
expect(manager.level).toBe('none');
expect(queue.purge).toHaveBeenCalledWith(expect.any(Function));

// Verify the purge predicate matches all messages
const purgeFn = queue.purge.mock.calls[0][0];
expect(purgeFn({ type: 'page' })).toBe(true);
});

it('strips userId on downgrade from full to anonymous', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');

manager.setLevel('anonymous');
expect(manager.level).toBe('anonymous');
expect(queue.transform).toHaveBeenCalledWith(expect.any(Function));

// Verify the transform strips userId
const transformFn = queue.transform.mock.calls[0][0];
const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' };
const result = transformFn(withUserId);
expect(result.userId).toBeUndefined();
expect(result.anonymousId).toBe('a-1');
});

it('fires PUT to consent endpoint on level change', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');

manager.setLevel('anonymous');

expect(mockFetch).toHaveBeenCalledWith(
'https://api.dev.immutable.com/v1/audience/tracking-consent',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-immutable-publishable-key': 'pk_test',
}),
body: JSON.stringify({ anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' }),
}),
);
});

it('does nothing when setting the same level', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');

manager.setLevel('anonymous');
expect(queue.purge).not.toHaveBeenCalled();
expect(queue.transform).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});

it('respects DNT by defaulting to none', () => {
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });

const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
expect(manager.level).toBe('none');

Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
});
});
90 changes: 90 additions & 0 deletions packages/audience/core/src/consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { ConsentLevel, Message, Environment } from './types';
import type { MessageQueue } from './queue';
import { CONSENT_PATH, getBaseUrl } from './config';

export interface ConsentManager {
level: ConsentLevel;
setLevel(next: ConsentLevel): void;
}

export function detectDoNotTrack(): boolean {
if (typeof navigator === 'undefined') return false;
// DNT header
if (navigator.doNotTrack === '1') return true;
// Global Privacy Control
if ((navigator as unknown as Record<string, unknown>).globalPrivacyControl === true) return true;
return false;
}

/**
* Create a consent state machine.
*
* - Default level is `'none'` (no collection).
* - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`.
* - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages.
* - On downgrade to `'none'`, purges the queue entirely.
* - Fires PUT to `/v1/audience/tracking-consent` on every state change.
*/
export function createConsentManager(
queue: MessageQueue,
publishableKey: string,
anonymousId: string,
environment: Environment,
source: string,
initialLevel?: ConsentLevel,
): ConsentManager {
const dntDetected = detectDoNotTrack();
let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none');

const LEVELS: Record<ConsentLevel, number> = { none: 0, anonymous: 1, full: 2 };

function notifyBackend(level: ConsentLevel): void {
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
const payload = { anonymousId, status: level, source };
// Uses fetch directly rather than httpSend because this is a PUT
// to a different endpoint with a different payload shape than the
// message ingest POST that httpSend is designed for.
fetch(url, {
Comment thread
bkbooth marked this conversation as resolved.
Outdated
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-immutable-publishable-key': publishableKey,
},
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
}

const manager: ConsentManager = {
get level() {
return current;
},

setLevel(next: ConsentLevel): void {
if (next === current) return;

const isDowngrade = LEVELS[next] < LEVELS[current];

if (isDowngrade) {
if (next === 'none') {
// Purge all queued messages
queue.purge(() => true);
} else if (next === 'anonymous') {
// Strip userId from queued messages
queue.transform((msg: Message) => {
if ('userId' in msg) {
const { userId, ...rest } = msg;
return rest as Message;
}
return msg;
});
}
}

current = next;
notifyBackend(next);
},
};

return manager;
}
10 changes: 10 additions & 0 deletions packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
FLUSH_SIZE,
COOKIE_NAME,
SESSION_COOKIE,
SESSION_MAX_AGE,
} from './config';

export { generateId, getTimestamp, isBrowser } from './utils';
Expand All @@ -46,3 +47,12 @@ export {
truncate,
truncateSource,
} from './validation';

export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
export type { SessionResult } from './session';

export { collectAttribution, clearAttribution } from './attribution';
export type { Attribution } from './attribution';

export { createConsentManager, detectDoNotTrack } from './consent';
export type { ConsentManager } from './consent';
76 changes: 76 additions & 0 deletions packages/audience/core/src/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';

const SESSION_COOKIE = '_imtbl_sid';

// Mock internal modules
const mockGetCookie = jest.fn();
const mockSetCookie = jest.fn();
const mockGenerateId = jest.fn();

jest.mock('./cookie', () => ({
getCookie: (...args: unknown[]) => mockGetCookie(...args),
setCookie: (...args: unknown[]) => mockSetCookie(...args),
}));

jest.mock('./utils', () => ({
generateId: (...args: unknown[]) => mockGenerateId(...args),
}));

beforeEach(() => {
jest.clearAllMocks();
mockGenerateId.mockReturnValue('new-session-id');
});

describe('getOrCreateSession', () => {
it('creates a new session when no cookie exists', () => {
mockGetCookie.mockReturnValue(undefined);

const result = getOrCreateSession();
expect(result.sessionId).toBe('new-session-id');
expect(result.isNew).toBe(true);
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined);
});

it('returns existing session and refreshes expiry', () => {
mockGetCookie.mockReturnValue('existing-sid');

const result = getOrCreateSession();
expect(result.sessionId).toBe('existing-sid');
expect(result.isNew).toBe(false);
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined);
expect(mockGenerateId).not.toHaveBeenCalled();
});

it('passes domain to setCookie', () => {
mockGetCookie.mockReturnValue(undefined);

getOrCreateSession('.example.com');
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com');
});
});

describe('getOrCreateSessionId', () => {
it('returns the session ID string', () => {
mockGetCookie.mockReturnValue(undefined);

const id = getOrCreateSessionId();
expect(id).toBe('new-session-id');
});

it('returns existing session ID', () => {
mockGetCookie.mockReturnValue('existing-sid');
expect(getOrCreateSessionId()).toBe('existing-sid');
});
});

describe('getSessionId', () => {
it('returns the session ID from cookie', () => {
mockGetCookie.mockReturnValue('existing-sid');
expect(getSessionId()).toBe('existing-sid');
});

it('returns undefined when no session cookie exists', () => {
mockGetCookie.mockReturnValue(undefined);
expect(getSessionId()).toBeUndefined();
});
});
36 changes: 36 additions & 0 deletions packages/audience/core/src/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getCookie, setCookie } from './cookie';
import { generateId } from './utils';
import { SESSION_COOKIE, SESSION_MAX_AGE } from './config';

export interface SessionResult {
sessionId: string;
isNew: boolean;
}

/**
* Get or create a session ID.
*
* The session cookie has a 30-minute rolling expiry — each call refreshes it.
* Returns whether the session is new so the caller can fire a `session_start` event.
*/
export function getOrCreateSession(domain?: string): SessionResult {
const existing = getCookie(SESSION_COOKIE);
if (existing) {
// Refresh the rolling expiry
setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain);
return { sessionId: existing, isNew: false };
}

const id = generateId();
setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain);
return { sessionId: id, isNew: true };
}

/** Convenience wrapper that returns just the session ID string. */
export function getOrCreateSessionId(domain?: string): string {
return getOrCreateSession(domain).sessionId;
}

export function getSessionId(): string | undefined {
return getCookie(SESSION_COOKIE);
}
1 change: 1 addition & 0 deletions packages/audience/pixel/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Config } from 'jest';
const config: Config = {
roots: ['<rootDir>/src'],
moduleDirectories: ['node_modules', 'src'],
moduleNameMapper: { '^@imtbl/(.*)$': '<rootDir>/../../../node_modules/@imtbl/$1/src' },
testEnvironment: 'jsdom',
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
Expand Down
10 changes: 6 additions & 4 deletions packages/audience/pixel/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"name": "@imtbl/pixel",
"description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data",
"version": "0.0.0",
"version": "1.0.0",
"author": "Immutable",
"private": true,
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
"dependencies": {},
"dependencies": {
"@imtbl/audience-core": "workspace:*"
},
"devDependencies": {
"@swc/core": "^1.4.2",
"@swc/jest": "^0.2.37",
Expand Down Expand Up @@ -33,10 +35,10 @@
"scripts": {
"build": "pnpm transpile && pnpm typegen",
"transpile": "tsup",
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
"typegen": "tsc --customConditions development --emitDeclarationOnly --outDir dist/types",
"lint": "eslint ./src --ext .ts --max-warnings=0",
"test": "jest --passWithNoTests",
"typecheck": "tsc --customConditions default --noEmit"
"typecheck": "tsc --customConditions development --noEmit"
},
"type": "module",
"types": "./dist/types/index.d.ts"
Expand Down
2 changes: 2 additions & 0 deletions packages/audience/pixel/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Injected at build time by tsup `define` — see tsup.config.ts */
declare const PIXEL_VERSION_INJECTED: string;
4 changes: 2 additions & 2 deletions packages/audience/pixel/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { collectAttribution, clearAttribution } from './attribution';
export type { Attribution } from './attribution';
export { Pixel } from './pixel';
export type { PixelInitOptions } from './pixel';

export { createLoader } from './loader';
export type { Command, ImtblGlobal } from './loader';
Expand Down
Loading
Loading