Skip to content

Commit f390789

Browse files
feat(audience)!: add IdentityType, rename alias params to id/identityType (#2836)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0253e7f commit f390789

7 files changed

Lines changed: 135 additions & 43 deletions

File tree

packages/audience/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type {
1515
ConsentStatus,
1616
ConsentUpdatePayload,
1717
} from './types';
18+
export { IdentityType } from './types';
1819

1920
export {
2021
getOrCreateAnonymousId,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { IdentityType } from './types';
2+
3+
describe('IdentityType', () => {
4+
it('matches the OAS enum exactly', () => {
5+
// Guards against drift from platform-services/services/audience/src/openapi/oas.yml
6+
expect(Object.values(IdentityType).sort()).toEqual([
7+
'apple',
8+
'custom',
9+
'discord',
10+
'email',
11+
'epic',
12+
'google',
13+
'passport',
14+
'steam',
15+
]);
16+
});
17+
18+
it('exposes PascalCase keys mapping to lowercase wire values', () => {
19+
expect(IdentityType.Passport).toBe('passport');
20+
expect(IdentityType.Steam).toBe('steam');
21+
expect(IdentityType.Epic).toBe('epic');
22+
expect(IdentityType.Google).toBe('google');
23+
expect(IdentityType.Apple).toBe('apple');
24+
expect(IdentityType.Discord).toBe('discord');
25+
expect(IdentityType.Email).toBe('email');
26+
expect(IdentityType.Custom).toBe('custom');
27+
});
28+
29+
it('has exactly 8 entries', () => {
30+
expect(Object.keys(IdentityType)).toHaveLength(8);
31+
});
32+
});

packages/audience/core/src/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,25 @@ export interface ConsentUpdatePayload {
106106
status: ConsentLevel;
107107
source: string;
108108
}
109+
110+
/**
111+
* Identity providers supported by the backend (matches the OAS `IdentityType`
112+
* enum exactly). Pass one of these values to `audience.identify()` or
113+
* `audience.alias()` to tell the backend which identity system the ID comes from.
114+
*
115+
* Keep in sync with:
116+
* `platform-services/services/audience/src/openapi/oas.yml` → `IdentityType`.
117+
*/
118+
export const IdentityType = {
119+
Passport: 'passport',
120+
Steam: 'steam',
121+
Epic: 'epic',
122+
Google: 'google',
123+
Apple: 'apple',
124+
Discord: 'discord',
125+
Email: 'email',
126+
Custom: 'custom',
127+
} as const;
128+
129+
// eslint-disable-next-line @typescript-eslint/no-redeclare
130+
export type IdentityType = typeof IdentityType[keyof typeof IdentityType];

packages/audience/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { Audience } from './sdk';
2+
export { IdentityType } from '@imtbl/audience-core';
23
export type { AudienceConfig } from './types';
34
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';

packages/audience/sdk/src/sdk.test.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { Audience } from './sdk';
55
import { LIBRARY_NAME } from './config';
66

77
// --- Test fixtures ---
8-
const TEST_USER = { uid: 'user@example.com', provider: 'email' } as const;
9-
const TEST_STEAM = { uid: '76561198012345', provider: 'steam' } as const;
8+
const TEST_USER = { id: 'user@example.com', identityType: 'email' } as const;
9+
const TEST_STEAM = { id: '76561198012345', identityType: 'steam' } as const;
1010

1111
function createSDK(overrides: Record<string, unknown> = {}) {
1212
return Audience.init({
13-
publishableKey: 'pk_imtbl_test',
13+
publishableKey: 'pk_imapik-test-local',
1414
environment: 'sandbox',
1515
consent: 'full',
1616
...overrides,
@@ -94,7 +94,7 @@ describe('Audience', () => {
9494
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
9595
const first = createSDK();
9696
const second = Audience.init({
97-
publishableKey: 'pk_imtbl_other',
97+
publishableKey: 'pk_imapik-test-other',
9898
environment: 'production',
9999
consent: 'none',
100100
});
@@ -223,15 +223,15 @@ describe('Audience', () => {
223223
it('includes userId at full consent after identify', async () => {
224224
const sdk = createSDK({ consent: 'full' });
225225

226-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
226+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
227227
sdk.track('level_up', { level: 5 });
228228
await sdk.flush();
229229

230230
const msg = sentMessages().find(
231231
(m: any) => m.type === 'track' && m.eventName === 'level_up',
232232
);
233233
expect(msg).toBeDefined();
234-
expect(msg.userId).toBe(TEST_USER.uid);
234+
expect(msg.userId).toBe(TEST_USER.id);
235235

236236
sdk.shutdown();
237237
});
@@ -279,13 +279,13 @@ describe('Audience', () => {
279279
it('includes userId at full consent after identify', async () => {
280280
const sdk = createSDK({ consent: 'full' });
281281

282-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
282+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
283283
sdk.page({ section: 'shop' });
284284
await sdk.flush();
285285

286286
const msg = sentMessages().find((m: any) => m.type === 'page');
287287
expect(msg).toBeDefined();
288-
expect(msg.userId).toBe(TEST_USER.uid);
288+
expect(msg.userId).toBe(TEST_USER.id);
289289

290290
sdk.shutdown();
291291
});
@@ -328,7 +328,7 @@ describe('Audience', () => {
328328
it('sends an identify message at full consent', async () => {
329329
const sdk = createSDK({ consent: 'full' });
330330

331-
sdk.identify(TEST_USER.uid, TEST_USER.provider, {
331+
sdk.identify(TEST_USER.id, TEST_USER.identityType, {
332332
name: 'Player One',
333333
});
334334
await sdk.flush();
@@ -337,8 +337,8 @@ describe('Audience', () => {
337337
(m: any) => m.type === 'identify',
338338
);
339339
expect(msg).toBeDefined();
340-
expect(msg.userId).toBe(TEST_USER.uid);
341-
expect(msg.identityType).toBe(TEST_USER.provider);
340+
expect(msg.userId).toBe(TEST_USER.id);
341+
expect(msg.identityType).toBe(TEST_USER.identityType);
342342
expect(msg.traits).toEqual({ name: 'Player One' });
343343

344344
sdk.shutdown();
@@ -347,7 +347,7 @@ describe('Audience', () => {
347347
it('is a no-op at none consent', async () => {
348348
const sdk = createSDK({ consent: 'none' });
349349

350-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
350+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
351351
await sdk.flush();
352352

353353
const ids = sentMessages().filter((m: any) => m.type === 'identify');
@@ -359,7 +359,7 @@ describe('Audience', () => {
359359
it('is a no-op at anonymous consent', async () => {
360360
const sdk = createSDK({ consent: 'anonymous' });
361361

362-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
362+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
363363
await sdk.flush();
364364

365365
const ids = sentMessages().filter(
@@ -399,7 +399,7 @@ describe('Audience', () => {
399399

400400
sdk.identify({
401401
source: 'steam',
402-
steamId: TEST_STEAM.uid,
402+
steamId: TEST_STEAM.id,
403403
});
404404
await sdk.flush();
405405

@@ -410,7 +410,7 @@ describe('Audience', () => {
410410
expect(msg.userId).toBeUndefined();
411411
expect(msg.traits).toEqual({
412412
source: 'steam',
413-
steamId: TEST_STEAM.uid,
413+
steamId: TEST_STEAM.id,
414414
});
415415

416416
sdk.shutdown();
@@ -428,10 +428,10 @@ describe('Audience', () => {
428428
(m: any) => m.type === 'alias',
429429
);
430430
expect(msg).toBeDefined();
431-
expect(msg.fromId).toBe(TEST_STEAM.uid);
432-
expect(msg.fromType).toBe(TEST_STEAM.provider);
433-
expect(msg.toId).toBe(TEST_USER.uid);
434-
expect(msg.toType).toBe(TEST_USER.provider);
431+
expect(msg.fromId).toBe(TEST_STEAM.id);
432+
expect(msg.fromType).toBe(TEST_STEAM.identityType);
433+
expect(msg.toId).toBe(TEST_USER.id);
434+
expect(msg.toType).toBe(TEST_USER.identityType);
435435

436436
sdk.shutdown();
437437
});
@@ -440,8 +440,8 @@ describe('Audience', () => {
440440
const sdk = createSDK({ consent: 'full' });
441441

442442
sdk.alias(
443-
{ uid: 'same_id', provider: 'steam' },
444-
{ uid: 'same_id', provider: 'steam' },
443+
{ id: 'same_id', identityType: 'steam' },
444+
{ id: 'same_id', identityType: 'steam' },
445445
);
446446
await sdk.flush();
447447

@@ -476,6 +476,41 @@ describe('Audience', () => {
476476
});
477477
});
478478

479+
describe('identify and alias type narrowing', () => {
480+
it('rejects invalid identityType at compile time', () => {
481+
const sdk = createSDK({ consent: 'full' });
482+
483+
// Valid — should type-check.
484+
sdk.identify('player-1', 'passport', { plan: 'premium' });
485+
486+
// @ts-expect-error — 'facebook' is not a valid IdentityType literal.
487+
sdk.identify('player-2', 'facebook');
488+
489+
// @ts-expect-error — arbitrary strings are rejected.
490+
sdk.identify('player-3', 'not-a-real-type' as string);
491+
492+
sdk.shutdown();
493+
});
494+
495+
it('rejects invalid identityType in alias at compile time', () => {
496+
const sdk = createSDK({ consent: 'full' });
497+
498+
// Valid.
499+
sdk.alias(
500+
{ id: 'steam-id', identityType: 'steam' },
501+
{ id: 'passport-id', identityType: 'passport' },
502+
);
503+
504+
// @ts-expect-error — 'facebook' is not a valid IdentityType.
505+
sdk.alias(
506+
{ id: 'fb-id', identityType: 'facebook' },
507+
{ id: 'passport-id', identityType: 'passport' },
508+
);
509+
510+
sdk.shutdown();
511+
});
512+
});
513+
479514
describe('setConsent', () => {
480515
it('is a no-op when setting the same level', async () => {
481516
const sdk = createSDK({ consent: 'full' });
@@ -501,15 +536,15 @@ describe('Audience', () => {
501536
sdk.setConsent('full');
502537
expect(document.cookie).toContain(`${COOKIE_NAME}=`);
503538

504-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
539+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
505540
sdk.track('purchase', { value: 9.99 });
506541
await sdk.flush();
507542

508543
const trackMsg = sentMessages().find(
509544
(m: any) => m.type === 'track' && m.eventName === 'purchase',
510545
);
511546
expect(trackMsg).toBeDefined();
512-
expect(trackMsg.userId).toBe(TEST_USER.uid);
547+
expect(trackMsg.userId).toBe(TEST_USER.id);
513548

514549
sdk.shutdown();
515550
});
@@ -543,7 +578,7 @@ describe('Audience', () => {
543578
it('purges identify/alias, strips userId on downgrade', async () => {
544579
const sdk = createSDK({ consent: 'full' });
545580

546-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
581+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
547582
sdk.alias(TEST_STEAM, TEST_USER);
548583
sdk.track('purchase', { currency: 'USD', value: 9.99 });
549584

@@ -709,7 +744,7 @@ describe('Audience', () => {
709744
)?.anonymousId;
710745
fetchCalls.length = 0;
711746

712-
sdk.identify(TEST_USER.uid, TEST_USER.provider);
747+
sdk.identify(TEST_USER.id, TEST_USER.identityType);
713748
await sdk.flush();
714749
fetchCalls.length = 0;
715750

packages/audience/sdk/src/sdk.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
Attribution,
33
ConsentLevel,
44
ConsentManager,
5+
IdentityType,
56
Message,
67
UserTraits,
78
} from '@imtbl/audience-core';
@@ -263,13 +264,13 @@ export class Audience {
263264
*
264265
* Requires 'full' consent.
265266
*/
266-
identify(uid: string, provider: string, traits?: UserTraits): void;
267+
identify(id: string, identityType: IdentityType, traits?: UserTraits): void;
267268

268269
identify(traits: UserTraits): void;
269270

270271
identify(
271-
uidOrTraits: string | UserTraits,
272-
provider?: string,
272+
idOrTraits: string | UserTraits,
273+
identityType?: IdentityType,
273274
traits?: UserTraits,
274275
): void {
275276
if (this.consent.level !== 'full') {
@@ -278,24 +279,24 @@ export class Audience {
278279
}
279280
getOrCreateSession(this.cookieDomain);
280281

281-
if (uidOrTraits !== null && typeof uidOrTraits === 'object' && !Array.isArray(uidOrTraits)) {
282+
if (idOrTraits !== null && typeof idOrTraits === 'object' && !Array.isArray(idOrTraits)) {
282283
this.enqueue('identify', {
283284
...this.baseMessage(),
284285
type: 'identify',
285-
traits: uidOrTraits,
286+
traits: idOrTraits,
286287
});
287288
return;
288289
}
289290

290-
if (typeof uidOrTraits !== 'string') return;
291+
if (typeof idOrTraits !== 'string') return;
291292

292-
const uid = truncate(uidOrTraits);
293-
this.userId = uid;
293+
const id = truncate(idOrTraits);
294+
this.userId = id;
294295
this.enqueue('identify', {
295296
...this.baseMessage(),
296297
type: 'identify',
297-
userId: uid,
298-
identityType: provider,
298+
userId: id,
299+
identityType,
299300
traits,
300301
});
301302
}
@@ -309,14 +310,14 @@ export class Audience {
309310
* Requires 'full' consent. `from` and `to` must differ.
310311
*/
311312
alias(
312-
from: { uid: string; provider: string },
313-
to: { uid: string; provider: string },
313+
from: { id: string; identityType: IdentityType },
314+
to: { id: string; identityType: IdentityType },
314315
): void {
315316
if (this.consent.level !== 'full') {
316317
this.debug.logWarning('alias() requires full consent — call ignored.');
317318
return;
318319
}
319-
if (!isAliasValid(from.uid, from.provider, to.uid, to.provider)) {
320+
if (!isAliasValid(from.id, from.identityType, to.id, to.identityType)) {
320321
this.debug.logWarning('alias() from and to are identical — call ignored.');
321322
return;
322323
}
@@ -325,10 +326,10 @@ export class Audience {
325326
this.enqueue('alias', {
326327
...this.baseMessage(),
327328
type: 'alias',
328-
fromId: truncate(from.uid),
329-
fromType: from.provider,
330-
toId: truncate(to.uid),
331-
toType: to.provider,
329+
fromId: truncate(from.id),
330+
fromType: from.identityType,
331+
toId: truncate(to.id),
332+
toType: to.identityType,
332333
});
333334
}
334335

packages/audience/sdk/src/types.ts

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

33
/** Configuration for the Immutable Web SDK. */
44
export interface AudienceConfig {
5-
/** Publishable API key from Immutable Hub (pk_imtbl_...). */
5+
/** Publishable API key from Immutable Hub (pk_imapik-...). */
66
publishableKey: string;
77
/** Target environment — controls which backend receives events. */
88
environment: Environment;

0 commit comments

Comments
 (0)