Skip to content
Draft
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
112 changes: 112 additions & 0 deletions packages/audience/sdk/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ConsentTransport } from './consent';
import { ConsentManager } from './consent';

function createMockTransport(): ConsentTransport & { calls: any[] } {
const calls: any[] = [];
return {
calls,
async syncConsent(url, publishableKey, body) {
calls.push({ url, publishableKey, body });
},
};
}

describe('ConsentManager', () => {
it('initialises with the provided consent level', () => {
const transport = createMockTransport();
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK', transport);
expect(manager.getLevel()).toBe('anonymous');
});

it('calls onPurgeQueue and onClearIdentity when downgrading to none', () => {
const transport = createMockTransport();
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK', transport);
const onPurge = jest.fn();
const onClear = jest.fn();

manager.setLevel('none', 'anon-123', {
onPurgeQueue: onPurge,
onClearIdentity: onClear,
});

expect(onPurge).toHaveBeenCalled();
expect(onClear).toHaveBeenCalled();
expect(manager.getLevel()).toBe('none');
});

it('calls onStripIdentity when downgrading from full to anonymous', () => {
const transport = createMockTransport();
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK', transport);
const onStrip = jest.fn();

manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip });

expect(onStrip).toHaveBeenCalled();
expect(manager.getLevel()).toBe('anonymous');
});

it('does not call onStripIdentity when upgrading from anonymous to full', () => {
const transport = createMockTransport();
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK', transport);
const onStrip = jest.fn();

manager.setLevel('full', 'anon-123', { onStripIdentity: onStrip });

expect(onStrip).not.toHaveBeenCalled();
expect(manager.getLevel()).toBe('full');
});

it('syncs consent to server via transport on setLevel', () => {
const transport = createMockTransport();
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK', transport);
manager.setLevel('full', 'anon-123');

expect(transport.calls).toHaveLength(1);
expect(transport.calls[0]).toEqual({
url: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
publishableKey: 'pk_test',
body: {
anonymousId: 'anon-123',
status: 'full',
source: 'TestSDK',
},
});
});

it('truncates source to 128 characters', () => {
const transport = createMockTransport();
const longSource = 'x'.repeat(200);
const manager = new ConsentManager('sandbox', 'pk_test', 'none', longSource, transport);
manager.setLevel('full', 'anon-123');

expect(transport.calls[0].body.source).toHaveLength(128);
});

it('uses correct base URL per environment', () => {
const devTransport = createMockTransport();
const devManager = new ConsentManager('dev', 'pk_test', 'none', 'SDK', devTransport);
devManager.setLevel('full', 'anon-123');
expect(devTransport.calls[0].url).toContain('api.dev.immutable.com');

const prodTransport = createMockTransport();
const prodManager = new ConsentManager('production', 'pk_test', 'none', 'SDK', prodTransport);
prodManager.setLevel('full', 'anon-123');
expect(prodTransport.calls[0].url).toContain('api.immutable.com');
});

it('does not throw when transport rejects', async () => {
const transport: ConsentTransport = {
async syncConsent() {
throw new Error('network error');
},
};
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'SDK', transport);

// Should not throw — fire-and-forget
expect(() => manager.setLevel('full', 'anon-123')).not.toThrow();

// Let the rejected promise settle
await Promise.resolve();
await Promise.resolve();
});
});
91 changes: 91 additions & 0 deletions packages/audience/sdk/src/consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type {
ConsentLevel,
Environment,
} from '@imtbl/audience-core';
import {
CONSENT_PATH,
getBaseUrl,
truncateSource,
} from '@imtbl/audience-core';

/** Pluggable transport for syncing consent state to the server. */
export interface ConsentTransport {
syncConsent(
url: string,
publishableKey: string,
body: { anonymousId: string; status: ConsentLevel; source: string },
): Promise<void>;
}

/** Side-effect callbacks invoked by the consent state machine on transitions. */
export interface ConsentCallbacks {
/** Called on any downgrade to 'none' — stop queue, purge all messages. */
onPurgeQueue?: () => void;
/** Called on full → anonymous — remove identify/alias, strip userId. */
onStripIdentity?: () => void;
/** Called on any downgrade to 'none' — clear persisted identity (cookies, storage). */
onClearIdentity?: () => void;
}

/**
* Consent state machine shared across all surface SDKs.
*
* Owns the three-tier consent model (none/anonymous/full) and transition
* semantics. Platform-specific I/O (HTTP transport, cookie/storage clearing)
* is injected via ConsentTransport and ConsentCallbacks.
*/
export class ConsentManager {
private level: ConsentLevel;

private readonly baseUrl: string;

private readonly source: string;

constructor(
environment: Environment,
private readonly publishableKey: string,
initialConsent: ConsentLevel,
rawSource: string,
private readonly transport: ConsentTransport,
) {
this.baseUrl = getBaseUrl(environment);
this.source = truncateSource(rawSource);
this.level = initialConsent;
}

getLevel(): ConsentLevel {
return this.level;
}

setLevel(
level: ConsentLevel,
anonymousId: string,
callbacks?: ConsentCallbacks,
): void {
const previous = this.level;
this.level = level;

// Downgrade: any → none — purge everything + clear persisted identity
if (level === 'none') {
callbacks?.onPurgeQueue?.();
callbacks?.onClearIdentity?.();
} else if (level === 'anonymous' && previous === 'full') {
// Downgrade: full → anonymous — strip PII, keep anonymous events
callbacks?.onStripIdentity?.();
}

// Sync to server (fire-and-forget)
this.syncToServer(anonymousId, level);
}

private syncToServer(anonymousId: string, status: ConsentLevel): void {
const url = `${this.baseUrl}${CONSENT_PATH}`;
this.transport.syncConsent(url, this.publishableKey, {
anonymousId,
status,
source: this.source,
}).catch(() => {
// Fire-and-forget — transport implementation handles error logging
});
}
}
11 changes: 0 additions & 11 deletions packages/audience/sdk/src/context.test.ts

This file was deleted.

7 changes: 0 additions & 7 deletions packages/audience/sdk/src/context.ts

This file was deleted.

14 changes: 7 additions & 7 deletions packages/audience/sdk/src/debug.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Message } from '@imtbl/audience-core';
import { DebugLogger } from './debug';
import { DebugLogger, LOG_PREFIX } from './debug';

describe('DebugLogger', () => {
let logSpy: jest.SpyInstance;
Expand All @@ -22,7 +22,7 @@ describe('DebugLogger', () => {
anonymousId: 'anon-1',
surface: 'web',
context: { library: 'test', libraryVersion: '0.0.0' },
event: 'click',
eventName: 'click',
properties: {},
};

Expand All @@ -42,7 +42,7 @@ describe('DebugLogger', () => {
logger.logEvent('track', stubMessage);

expect(logSpy).toHaveBeenCalledWith(
'[Immutable Audience] track',
`${LOG_PREFIX} track`,
stubMessage,
);
});
Expand All @@ -52,12 +52,12 @@ describe('DebugLogger', () => {

logger.logFlush(true, 5);
expect(logSpy).toHaveBeenCalledWith(
'[Immutable Audience] flush ok (5 messages)',
`${LOG_PREFIX} flush ok (5 messages)`,
);

logger.logFlush(false, 3);
expect(logSpy).toHaveBeenCalledWith(
'[Immutable Audience] flush failed (3 messages)',
`${LOG_PREFIX} flush failed (3 messages)`,
);
});

Expand All @@ -66,7 +66,7 @@ describe('DebugLogger', () => {
logger.logConsent('none', 'full');

expect(logSpy).toHaveBeenCalledWith(
'[Immutable Audience] consent none full',
`${LOG_PREFIX} consent none \u2192 full`,
);
});

Expand All @@ -75,7 +75,7 @@ describe('DebugLogger', () => {
logger.logWarning('something went wrong');

expect(warnSpy).toHaveBeenCalledWith(
'[Immutable Audience] something went wrong',
`${LOG_PREFIX} something went wrong`,
);
});
});
10 changes: 5 additions & 5 deletions packages/audience/sdk/src/debug.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ConsentLevel, Message } from '@imtbl/audience-core';

const PREFIX = '[Immutable Audience]';
export const LOG_PREFIX = '[audience-sdk]';

export class DebugLogger {
private enabled: boolean;
Expand All @@ -12,24 +12,24 @@ export class DebugLogger {
logEvent(method: string, message: Message): void {
if (!this.enabled) return;
// eslint-disable-next-line no-console
console.log(`${PREFIX} ${method}`, message);
console.log(`${LOG_PREFIX} ${method}`, message);
}

logFlush(ok: boolean, count: number): void {
if (!this.enabled) return;
// eslint-disable-next-line no-console
console.log(`${PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`);
console.log(`${LOG_PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`);
}

logConsent(from: ConsentLevel, to: ConsentLevel): void {
if (!this.enabled) return;
// eslint-disable-next-line no-console
console.log(`${PREFIX} consent ${from} → ${to}`);
console.log(`${LOG_PREFIX} consent ${from} → ${to}`);
}

logWarning(msg: string): void {
if (!this.enabled) return;
// eslint-disable-next-line no-console
console.warn(`${PREFIX} ${msg}`);
console.warn(`${LOG_PREFIX} ${msg}`);
}
}
3 changes: 2 additions & 1 deletion packages/audience/sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { AudienceSDKConfig } from './types';
export { DebugLogger } from './debug';
export { collectContext } from './context';
export { ConsentManager } from './consent';
export type { ConsentTransport, ConsentCallbacks } from './consent';
13 changes: 11 additions & 2 deletions packages/audience/sdk/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import type { Environment, ConsentLevel } from '@imtbl/audience-core';

/** Configuration for the Immutable Audience SDK. */
/**
* Base configuration shared by all Audience surface SDKs.
* Surface-specific configs (e.g. WebSDKConfig) extend this.
*/
export interface AudienceSDKConfig {
/** Publishable API key from Immutable Hub (pk_imtbl_...). */
publishableKey: string;
/** Target environment — controls which backend receives events. */
environment: Environment;
/** Defaults to 'none' no tracking until explicitly opted in. */
/** Initial consent level. Defaults to 'none' (no tracking until opted in). */
consent?: ConsentLevel;
/** Enable console logging of all events, flushes, and consent changes. */
debug?: boolean;
/** Cookie domain for cross-subdomain sharing (e.g. '.studio.com'). */
cookieDomain?: string;
/** Queue flush interval in milliseconds. Defaults to 5000. */
flushInterval?: number;
/** Number of queued messages that triggers an automatic flush. Defaults to 20. */
flushSize?: number;
}
7 changes: 7 additions & 0 deletions packages/audience/web/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
extends: ['../../../.eslintrc'],
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigRootDir: __dirname,
},
};
Loading
Loading