diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 7d3526aed0..3ab824a4ea 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -15,6 +15,7 @@ export type { ConsentStatus, ConsentUpdatePayload, } from './types'; +export { IdentityType } from './types'; export { getOrCreateAnonymousId, diff --git a/packages/audience/core/src/types.test.ts b/packages/audience/core/src/types.test.ts new file mode 100644 index 0000000000..31e3aa06f5 --- /dev/null +++ b/packages/audience/core/src/types.test.ts @@ -0,0 +1,32 @@ +import { IdentityType } from './types'; + +describe('IdentityType', () => { + it('matches the OAS enum exactly', () => { + // Guards against drift from platform-services/services/audience/src/openapi/oas.yml + expect(Object.values(IdentityType).sort()).toEqual([ + 'apple', + 'custom', + 'discord', + 'email', + 'epic', + 'google', + 'passport', + 'steam', + ]); + }); + + it('exposes PascalCase keys mapping to lowercase wire values', () => { + expect(IdentityType.Passport).toBe('passport'); + expect(IdentityType.Steam).toBe('steam'); + expect(IdentityType.Epic).toBe('epic'); + expect(IdentityType.Google).toBe('google'); + expect(IdentityType.Apple).toBe('apple'); + expect(IdentityType.Discord).toBe('discord'); + expect(IdentityType.Email).toBe('email'); + expect(IdentityType.Custom).toBe('custom'); + }); + + it('has exactly 8 entries', () => { + expect(Object.keys(IdentityType)).toHaveLength(8); + }); +}); diff --git a/packages/audience/core/src/types.ts b/packages/audience/core/src/types.ts index 198de03d55..841a76f8d1 100644 --- a/packages/audience/core/src/types.ts +++ b/packages/audience/core/src/types.ts @@ -106,3 +106,25 @@ export interface ConsentUpdatePayload { status: ConsentLevel; source: string; } + +/** + * Identity providers supported by the backend (matches the OAS `IdentityType` + * enum exactly). Pass one of these values to `audience.identify()` or + * `audience.alias()` to tell the backend which identity system the ID comes from. + * + * Keep in sync with: + * `platform-services/services/audience/src/openapi/oas.yml` → `IdentityType`. + */ +export const IdentityType = { + Passport: 'passport', + Steam: 'steam', + Epic: 'epic', + Google: 'google', + Apple: 'apple', + Discord: 'discord', + Email: 'email', + Custom: 'custom', +} as const; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type IdentityType = typeof IdentityType[keyof typeof IdentityType]; diff --git a/packages/audience/sdk/src/index.ts b/packages/audience/sdk/src/index.ts index b36562b789..a76ca58a07 100644 --- a/packages/audience/sdk/src/index.ts +++ b/packages/audience/sdk/src/index.ts @@ -1,3 +1,4 @@ export { Audience } from './sdk'; +export { IdentityType } from '@imtbl/audience-core'; export type { AudienceConfig } from './types'; export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core'; diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index 7e67a045e5..1b8b370b58 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -5,12 +5,12 @@ import { Audience } from './sdk'; import { LIBRARY_NAME } from './config'; // --- Test fixtures --- -const TEST_USER = { uid: 'user@example.com', provider: 'email' } as const; -const TEST_STEAM = { uid: '76561198012345', provider: 'steam' } as const; +const TEST_USER = { id: 'user@example.com', identityType: 'email' } as const; +const TEST_STEAM = { id: '76561198012345', identityType: 'steam' } as const; function createSDK(overrides: Record = {}) { return Audience.init({ - publishableKey: 'pk_imtbl_test', + publishableKey: 'pk_imapik-test-local', environment: 'sandbox', consent: 'full', ...overrides, @@ -94,7 +94,7 @@ describe('Audience', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); const first = createSDK(); const second = Audience.init({ - publishableKey: 'pk_imtbl_other', + publishableKey: 'pk_imapik-test-other', environment: 'production', consent: 'none', }); @@ -223,7 +223,7 @@ describe('Audience', () => { it('includes userId at full consent after identify', async () => { const sdk = createSDK({ consent: 'full' }); - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); sdk.track('level_up', { level: 5 }); await sdk.flush(); @@ -231,7 +231,7 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'level_up', ); expect(msg).toBeDefined(); - expect(msg.userId).toBe(TEST_USER.uid); + expect(msg.userId).toBe(TEST_USER.id); sdk.shutdown(); }); @@ -279,13 +279,13 @@ describe('Audience', () => { it('includes userId at full consent after identify', async () => { const sdk = createSDK({ consent: 'full' }); - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); sdk.page({ section: 'shop' }); await sdk.flush(); const msg = sentMessages().find((m: any) => m.type === 'page'); expect(msg).toBeDefined(); - expect(msg.userId).toBe(TEST_USER.uid); + expect(msg.userId).toBe(TEST_USER.id); sdk.shutdown(); }); @@ -328,7 +328,7 @@ describe('Audience', () => { it('sends an identify message at full consent', async () => { const sdk = createSDK({ consent: 'full' }); - sdk.identify(TEST_USER.uid, TEST_USER.provider, { + sdk.identify(TEST_USER.id, TEST_USER.identityType, { name: 'Player One', }); await sdk.flush(); @@ -337,8 +337,8 @@ describe('Audience', () => { (m: any) => m.type === 'identify', ); expect(msg).toBeDefined(); - expect(msg.userId).toBe(TEST_USER.uid); - expect(msg.identityType).toBe(TEST_USER.provider); + expect(msg.userId).toBe(TEST_USER.id); + expect(msg.identityType).toBe(TEST_USER.identityType); expect(msg.traits).toEqual({ name: 'Player One' }); sdk.shutdown(); @@ -347,7 +347,7 @@ describe('Audience', () => { it('is a no-op at none consent', async () => { const sdk = createSDK({ consent: 'none' }); - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); await sdk.flush(); const ids = sentMessages().filter((m: any) => m.type === 'identify'); @@ -359,7 +359,7 @@ describe('Audience', () => { it('is a no-op at anonymous consent', async () => { const sdk = createSDK({ consent: 'anonymous' }); - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); await sdk.flush(); const ids = sentMessages().filter( @@ -399,7 +399,7 @@ describe('Audience', () => { sdk.identify({ source: 'steam', - steamId: TEST_STEAM.uid, + steamId: TEST_STEAM.id, }); await sdk.flush(); @@ -410,7 +410,7 @@ describe('Audience', () => { expect(msg.userId).toBeUndefined(); expect(msg.traits).toEqual({ source: 'steam', - steamId: TEST_STEAM.uid, + steamId: TEST_STEAM.id, }); sdk.shutdown(); @@ -428,10 +428,10 @@ describe('Audience', () => { (m: any) => m.type === 'alias', ); expect(msg).toBeDefined(); - expect(msg.fromId).toBe(TEST_STEAM.uid); - expect(msg.fromType).toBe(TEST_STEAM.provider); - expect(msg.toId).toBe(TEST_USER.uid); - expect(msg.toType).toBe(TEST_USER.provider); + expect(msg.fromId).toBe(TEST_STEAM.id); + expect(msg.fromType).toBe(TEST_STEAM.identityType); + expect(msg.toId).toBe(TEST_USER.id); + expect(msg.toType).toBe(TEST_USER.identityType); sdk.shutdown(); }); @@ -440,8 +440,8 @@ describe('Audience', () => { const sdk = createSDK({ consent: 'full' }); sdk.alias( - { uid: 'same_id', provider: 'steam' }, - { uid: 'same_id', provider: 'steam' }, + { id: 'same_id', identityType: 'steam' }, + { id: 'same_id', identityType: 'steam' }, ); await sdk.flush(); @@ -476,6 +476,41 @@ describe('Audience', () => { }); }); + describe('identify and alias type narrowing', () => { + it('rejects invalid identityType at compile time', () => { + const sdk = createSDK({ consent: 'full' }); + + // Valid — should type-check. + sdk.identify('player-1', 'passport', { plan: 'premium' }); + + // @ts-expect-error — 'facebook' is not a valid IdentityType literal. + sdk.identify('player-2', 'facebook'); + + // @ts-expect-error — arbitrary strings are rejected. + sdk.identify('player-3', 'not-a-real-type' as string); + + sdk.shutdown(); + }); + + it('rejects invalid identityType in alias at compile time', () => { + const sdk = createSDK({ consent: 'full' }); + + // Valid. + sdk.alias( + { id: 'steam-id', identityType: 'steam' }, + { id: 'passport-id', identityType: 'passport' }, + ); + + // @ts-expect-error — 'facebook' is not a valid IdentityType. + sdk.alias( + { id: 'fb-id', identityType: 'facebook' }, + { id: 'passport-id', identityType: 'passport' }, + ); + + sdk.shutdown(); + }); + }); + describe('setConsent', () => { it('is a no-op when setting the same level', async () => { const sdk = createSDK({ consent: 'full' }); @@ -501,7 +536,7 @@ describe('Audience', () => { sdk.setConsent('full'); expect(document.cookie).toContain(`${COOKIE_NAME}=`); - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); sdk.track('purchase', { value: 9.99 }); await sdk.flush(); @@ -509,7 +544,7 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'purchase', ); expect(trackMsg).toBeDefined(); - expect(trackMsg.userId).toBe(TEST_USER.uid); + expect(trackMsg.userId).toBe(TEST_USER.id); sdk.shutdown(); }); @@ -543,7 +578,7 @@ describe('Audience', () => { it('purges identify/alias, strips userId on downgrade', async () => { const sdk = createSDK({ consent: 'full' }); - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); sdk.alias(TEST_STEAM, TEST_USER); sdk.track('purchase', { currency: 'USD', value: 9.99 }); @@ -709,7 +744,7 @@ describe('Audience', () => { )?.anonymousId; fetchCalls.length = 0; - sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.identify(TEST_USER.id, TEST_USER.identityType); await sdk.flush(); fetchCalls.length = 0; diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index 3343c348e7..ead8e3b9e7 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -2,6 +2,7 @@ import type { Attribution, ConsentLevel, ConsentManager, + IdentityType, Message, UserTraits, } from '@imtbl/audience-core'; @@ -263,13 +264,13 @@ export class Audience { * * Requires 'full' consent. */ - identify(uid: string, provider: string, traits?: UserTraits): void; + identify(id: string, identityType: IdentityType, traits?: UserTraits): void; identify(traits: UserTraits): void; identify( - uidOrTraits: string | UserTraits, - provider?: string, + idOrTraits: string | UserTraits, + identityType?: IdentityType, traits?: UserTraits, ): void { if (this.consent.level !== 'full') { @@ -278,24 +279,24 @@ export class Audience { } getOrCreateSession(this.cookieDomain); - if (uidOrTraits !== null && typeof uidOrTraits === 'object' && !Array.isArray(uidOrTraits)) { + if (idOrTraits !== null && typeof idOrTraits === 'object' && !Array.isArray(idOrTraits)) { this.enqueue('identify', { ...this.baseMessage(), type: 'identify', - traits: uidOrTraits, + traits: idOrTraits, }); return; } - if (typeof uidOrTraits !== 'string') return; + if (typeof idOrTraits !== 'string') return; - const uid = truncate(uidOrTraits); - this.userId = uid; + const id = truncate(idOrTraits); + this.userId = id; this.enqueue('identify', { ...this.baseMessage(), type: 'identify', - userId: uid, - identityType: provider, + userId: id, + identityType, traits, }); } @@ -309,14 +310,14 @@ export class Audience { * Requires 'full' consent. `from` and `to` must differ. */ alias( - from: { uid: string; provider: string }, - to: { uid: string; provider: string }, + from: { id: string; identityType: IdentityType }, + to: { id: string; identityType: IdentityType }, ): void { if (this.consent.level !== 'full') { this.debug.logWarning('alias() requires full consent — call ignored.'); return; } - if (!isAliasValid(from.uid, from.provider, to.uid, to.provider)) { + if (!isAliasValid(from.id, from.identityType, to.id, to.identityType)) { this.debug.logWarning('alias() from and to are identical — call ignored.'); return; } @@ -325,10 +326,10 @@ export class Audience { this.enqueue('alias', { ...this.baseMessage(), type: 'alias', - fromId: truncate(from.uid), - fromType: from.provider, - toId: truncate(to.uid), - toType: to.provider, + fromId: truncate(from.id), + fromType: from.identityType, + toId: truncate(to.id), + toType: to.identityType, }); } diff --git a/packages/audience/sdk/src/types.ts b/packages/audience/sdk/src/types.ts index 6d02bcc3c4..2e6ad62f46 100644 --- a/packages/audience/sdk/src/types.ts +++ b/packages/audience/sdk/src/types.ts @@ -2,7 +2,7 @@ import type { Environment, ConsentLevel } from '@imtbl/audience-core'; /** Configuration for the Immutable Web SDK. */ export interface AudienceConfig { - /** Publishable API key from Immutable Hub (pk_imtbl_...). */ + /** Publishable API key from Immutable Hub (pk_imapik-...). */ publishableKey: string; /** Target environment — controls which backend receives events. */ environment: Environment;