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/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
ConsentStatus,
ConsentUpdatePayload,
} from './types';
export { IdentityType } from './types';

export {
getOrCreateAnonymousId,
Expand Down
32 changes: 32 additions & 0 deletions packages/audience/core/src/types.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 22 additions & 0 deletions packages/audience/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
1 change: 1 addition & 0 deletions packages/audience/sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
85 changes: 60 additions & 25 deletions packages/audience/sdk/src/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
return Audience.init({
publishableKey: 'pk_imtbl_test',
publishableKey: 'pk_imapik-test-local',
environment: 'sandbox',
consent: 'full',
...overrides,
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -223,15 +223,15 @@ 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();

const msg = sentMessages().find(
(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();
});
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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');
Expand All @@ -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(
Expand Down Expand Up @@ -399,7 +399,7 @@ describe('Audience', () => {

sdk.identify({
source: 'steam',
steamId: TEST_STEAM.uid,
steamId: TEST_STEAM.id,
});
await sdk.flush();

Expand All @@ -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();
Expand All @@ -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();
});
Expand All @@ -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();

Expand Down Expand Up @@ -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' });
Expand All @@ -501,15 +536,15 @@ 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();

const trackMsg = sentMessages().find(
(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();
});
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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;

Expand Down
35 changes: 18 additions & 17 deletions packages/audience/sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Attribution,
ConsentLevel,
ConsentManager,
IdentityType,
Message,
UserTraits,
} from '@imtbl/audience-core';
Expand Down Expand Up @@ -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') {
Expand All @@ -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,
});
}
Expand All @@ -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;
}
Expand All @@ -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,
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/audience/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading