Skip to content

Commit 3a66130

Browse files
feat(audience): add ConsentManager with composable transport to sdk
Adds the consent state machine and documents the shared SDK config type. ConsentManager: owns the three-tier consent model (none/anonymous/full) and transition semantics. Platform-specific I/O is injected via ConsentTransport interface and ConsentCallbacks — pure composition, no abstract base classes. Web SDK provides fetch transport; future game SDKs plug in platform HTTP clients. AudienceSDKConfig: base config type with JSDoc on every field. Surface SDKs extend this (e.g. WebSDKConfig adds consentSource). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b0fee26 commit 3a66130

4 files changed

Lines changed: 216 additions & 3 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { ConsentTransport } from './consent';
2+
import { ConsentManager } from './consent';
3+
4+
function createMockTransport(): ConsentTransport & { calls: any[] } {
5+
const calls: any[] = [];
6+
return {
7+
calls,
8+
async syncConsent(url, publishableKey, body) {
9+
calls.push({ url, publishableKey, body });
10+
},
11+
};
12+
}
13+
14+
describe('ConsentManager', () => {
15+
it('initialises with the provided consent level', () => {
16+
const transport = createMockTransport();
17+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK', transport);
18+
expect(manager.getLevel()).toBe('anonymous');
19+
});
20+
21+
it('calls onPurgeQueue and onClearIdentity when downgrading to none', () => {
22+
const transport = createMockTransport();
23+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK', transport);
24+
const onPurge = jest.fn();
25+
const onClear = jest.fn();
26+
27+
manager.setLevel('none', 'anon-123', {
28+
onPurgeQueue: onPurge,
29+
onClearIdentity: onClear,
30+
});
31+
32+
expect(onPurge).toHaveBeenCalled();
33+
expect(onClear).toHaveBeenCalled();
34+
expect(manager.getLevel()).toBe('none');
35+
});
36+
37+
it('calls onStripIdentity when downgrading from full to anonymous', () => {
38+
const transport = createMockTransport();
39+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK', transport);
40+
const onStrip = jest.fn();
41+
42+
manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip });
43+
44+
expect(onStrip).toHaveBeenCalled();
45+
expect(manager.getLevel()).toBe('anonymous');
46+
});
47+
48+
it('does not call onStripIdentity when upgrading from anonymous to full', () => {
49+
const transport = createMockTransport();
50+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK', transport);
51+
const onStrip = jest.fn();
52+
53+
manager.setLevel('full', 'anon-123', { onStripIdentity: onStrip });
54+
55+
expect(onStrip).not.toHaveBeenCalled();
56+
expect(manager.getLevel()).toBe('full');
57+
});
58+
59+
it('syncs consent to server via transport on setLevel', () => {
60+
const transport = createMockTransport();
61+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK', transport);
62+
manager.setLevel('full', 'anon-123');
63+
64+
expect(transport.calls).toHaveLength(1);
65+
expect(transport.calls[0]).toEqual({
66+
url: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
67+
publishableKey: 'pk_test',
68+
body: {
69+
anonymousId: 'anon-123',
70+
status: 'full',
71+
source: 'TestSDK',
72+
},
73+
});
74+
});
75+
76+
it('truncates source to 128 characters', () => {
77+
const transport = createMockTransport();
78+
const longSource = 'x'.repeat(200);
79+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', longSource, transport);
80+
manager.setLevel('full', 'anon-123');
81+
82+
expect(transport.calls[0].body.source).toHaveLength(128);
83+
});
84+
85+
it('uses correct base URL per environment', () => {
86+
const devTransport = createMockTransport();
87+
const devManager = new ConsentManager('dev', 'pk_test', 'none', 'SDK', devTransport);
88+
devManager.setLevel('full', 'anon-123');
89+
expect(devTransport.calls[0].url).toContain('api.dev.immutable.com');
90+
91+
const prodTransport = createMockTransport();
92+
const prodManager = new ConsentManager('production', 'pk_test', 'none', 'SDK', prodTransport);
93+
prodManager.setLevel('full', 'anon-123');
94+
expect(prodTransport.calls[0].url).toContain('api.immutable.com');
95+
});
96+
97+
it('does not throw when transport rejects', async () => {
98+
const transport: ConsentTransport = {
99+
async syncConsent() {
100+
throw new Error('network error');
101+
},
102+
};
103+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'SDK', transport);
104+
105+
// Should not throw — fire-and-forget
106+
expect(() => manager.setLevel('full', 'anon-123')).not.toThrow();
107+
108+
// Let the rejected promise settle
109+
await Promise.resolve();
110+
await Promise.resolve();
111+
});
112+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type {
2+
ConsentLevel,
3+
Environment,
4+
} from '@imtbl/audience-core';
5+
import {
6+
CONSENT_PATH,
7+
getBaseUrl,
8+
truncateSource,
9+
} from '@imtbl/audience-core';
10+
11+
/** Pluggable transport for syncing consent state to the server. */
12+
export interface ConsentTransport {
13+
syncConsent(
14+
url: string,
15+
publishableKey: string,
16+
body: { anonymousId: string; status: ConsentLevel; source: string },
17+
): Promise<void>;
18+
}
19+
20+
/** Side-effect callbacks invoked by the consent state machine on transitions. */
21+
export interface ConsentCallbacks {
22+
/** Called on any downgrade to 'none' — stop queue, purge all messages. */
23+
onPurgeQueue?: () => void;
24+
/** Called on full → anonymous — remove identify/alias, strip userId. */
25+
onStripIdentity?: () => void;
26+
/** Called on any downgrade to 'none' — clear persisted identity (cookies, storage). */
27+
onClearIdentity?: () => void;
28+
}
29+
30+
/**
31+
* Consent state machine shared across all surface SDKs.
32+
*
33+
* Owns the three-tier consent model (none/anonymous/full) and transition
34+
* semantics. Platform-specific I/O (HTTP transport, cookie/storage clearing)
35+
* is injected via ConsentTransport and ConsentCallbacks.
36+
*/
37+
export class ConsentManager {
38+
private level: ConsentLevel;
39+
40+
private readonly baseUrl: string;
41+
42+
private readonly source: string;
43+
44+
constructor(
45+
environment: Environment,
46+
private readonly publishableKey: string,
47+
initialConsent: ConsentLevel,
48+
rawSource: string,
49+
private readonly transport: ConsentTransport,
50+
) {
51+
this.baseUrl = getBaseUrl(environment);
52+
this.source = truncateSource(rawSource);
53+
this.level = initialConsent;
54+
}
55+
56+
getLevel(): ConsentLevel {
57+
return this.level;
58+
}
59+
60+
setLevel(
61+
level: ConsentLevel,
62+
anonymousId: string,
63+
callbacks?: ConsentCallbacks,
64+
): void {
65+
const previous = this.level;
66+
this.level = level;
67+
68+
// Downgrade: any → none — purge everything + clear persisted identity
69+
if (level === 'none') {
70+
callbacks?.onPurgeQueue?.();
71+
callbacks?.onClearIdentity?.();
72+
} else if (level === 'anonymous' && previous === 'full') {
73+
// Downgrade: full → anonymous — strip PII, keep anonymous events
74+
callbacks?.onStripIdentity?.();
75+
}
76+
77+
// Sync to server (fire-and-forget)
78+
this.syncToServer(anonymousId, level);
79+
}
80+
81+
private syncToServer(anonymousId: string, status: ConsentLevel): void {
82+
const url = `${this.baseUrl}${CONSENT_PATH}`;
83+
this.transport.syncConsent(url, this.publishableKey, {
84+
anonymousId,
85+
status,
86+
source: this.source,
87+
}).catch(() => {
88+
// Fire-and-forget — transport implementation handles error logging
89+
});
90+
}
91+
}

packages/audience/sdk/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export type { AudienceSDKConfig } from './types';
22
export { DebugLogger } from './debug';
3-
export { collectContext } from './context';
3+
export { ConsentManager } from './consent';
4+
export type { ConsentTransport, ConsentCallbacks } from './consent';

packages/audience/sdk/src/types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import type { Environment, ConsentLevel } from '@imtbl/audience-core';
22

3-
/** Configuration for the Immutable Audience SDK. */
3+
/**
4+
* Base configuration shared by all Audience surface SDKs.
5+
* Surface-specific configs (e.g. WebSDKConfig) extend this.
6+
*/
47
export interface AudienceSDKConfig {
8+
/** Publishable API key from Immutable Hub (pk_imtbl_...). */
59
publishableKey: string;
10+
/** Target environment — controls which backend receives events. */
611
environment: Environment;
7-
/** Defaults to 'none' no tracking until explicitly opted in. */
12+
/** Initial consent level. Defaults to 'none' (no tracking until opted in). */
813
consent?: ConsentLevel;
14+
/** Enable console logging of all events, flushes, and consent changes. */
915
debug?: boolean;
16+
/** Cookie domain for cross-subdomain sharing (e.g. '.studio.com'). */
1017
cookieDomain?: string;
18+
/** Queue flush interval in milliseconds. Defaults to 5000. */
1119
flushInterval?: number;
20+
/** Number of queued messages that triggers an automatic flush. Defaults to 20. */
1221
flushSize?: number;
1322
}

0 commit comments

Comments
 (0)