Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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];
114 changes: 114 additions & 0 deletions packages/audience/core/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createConsentManager } from './consent';
import { httpSend } from './transport';

jest.mock('./transport', () => ({
httpSend: jest.fn().mockResolvedValue(true),
}));

const mockHttpSend = httpSend as jest.MockedFunction<typeof httpSend>;

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(mockHttpSend).toHaveBeenCalledWith(
'https://api.dev.immutable.com/v1/audience/tracking-consent',
'pk_test',
{ anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' },
{ method: 'PUT', keepalive: true },
);
});

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(mockHttpSend).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 });
});
});
82 changes: 82 additions & 0 deletions packages/audience/core/src/consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type {
ConsentLevel, ConsentUpdatePayload, Message, Environment,
} from './types';
import type { MessageQueue } from './queue';
import { CONSENT_PATH, getBaseUrl } from './config';
import { httpSend } from './transport';

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: ConsentUpdatePayload = { anonymousId, status: level, source };
httpSend(url, publishableKey, payload, { method: 'PUT', keepalive: true });
}

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;
}
13 changes: 12 additions & 1 deletion packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type {
BatchPayload,
ConsentLevel,
ConsentStatus,
ConsentUpdatePayload,
} from './types';

export {
Expand All @@ -32,11 +33,12 @@ export {
FLUSH_SIZE,
COOKIE_NAME,
SESSION_COOKIE,
SESSION_MAX_AGE,
} from './config';

export { generateId, getTimestamp, isBrowser } from './utils';

export type { Transport } from './transport';
export type { Transport, TransportOptions } from './transport';
export { httpTransport, httpSend } from './transport';
export { MessageQueue } from './queue';
export { collectContext } from './context';
Expand All @@ -46,3 +48,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);
}
14 changes: 14 additions & 0 deletions packages/audience/core/src/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ describe('httpSend', () => {
}));
});

it('uses specified method from options', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch;

const consentPayload = { anonymousId: 'anon-1', status: 'anonymous' as const, source: 'pixel' };
await httpSend('https://example.com/consent', 'pk', consentPayload, { method: 'PUT', keepalive: true });

expect(mockFetch).toHaveBeenCalledWith('https://example.com/consent', expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify(consentPayload),
}));
});

it('returns true on success', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true });
expect(await httpSend('https://example.com', 'pk', payload)).toBe(true);
Expand Down
7 changes: 4 additions & 3 deletions packages/audience/core/src/transport.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { track, trackError } from '@imtbl/metrics';
import type { BatchPayload } from './types';
import type { BatchPayload, ConsentUpdatePayload } from './types';

export interface TransportOptions {
method?: string;
keepalive?: boolean;
}

Expand All @@ -12,12 +13,12 @@ export interface Transport {
export async function httpSend(
url: string,
publishableKey: string,
payload: BatchPayload,
payload: BatchPayload | ConsentUpdatePayload,
options?: TransportOptions,
): Promise<boolean> {
try {
const response = await fetch(url, {
method: 'POST',
method: options?.method ?? 'POST',
headers: {
'Content-Type': 'application/json',
'x-immutable-publishable-key': publishableKey,
Expand Down
Loading
Loading