diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index f1f775f317..00a8f0cdef 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -17,4 +17,7 @@ export const SESSION_COOKIE = '_imtbl_sid'; export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds +export const SESSION_START = 'session_start'; +export const SESSION_END = 'session_end'; + export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment]; diff --git a/packages/audience/core/src/consent.ts b/packages/audience/core/src/consent.ts index 8d4d30ee26..02e14095d9 100644 --- a/packages/audience/core/src/consent.ts +++ b/packages/audience/core/src/consent.ts @@ -62,7 +62,8 @@ export function createConsentManager( // Purge all queued messages queue.purge(() => true); } else if (next === 'anonymous') { - // Strip userId from queued messages + // Remove identify/alias messages and strip userId from the rest + queue.purge((msg: Message) => msg.type === 'identify' || msg.type === 'alias'); queue.transform((msg: Message) => { if ('userId' in msg) { const { userId, ...rest } = msg; diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index d5e77b568e..7d3526aed0 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -34,6 +34,8 @@ export { COOKIE_NAME, SESSION_COOKIE, SESSION_MAX_AGE, + SESSION_START, + SESSION_END, } from './config'; export { generateId, getTimestamp, isBrowser } from './utils'; diff --git a/packages/audience/sdk/package.json b/packages/audience/sdk/package.json index 6365fce448..28c37fda44 100644 --- a/packages/audience/sdk/package.json +++ b/packages/audience/sdk/package.json @@ -1,6 +1,6 @@ { - "name": "@imtbl/audience-sdk", - "description": "Immutable Audience SDK — consent-aware event tracking and identity management", + "name": "@imtbl/audience", + "description": "Immutable Audience — consent-aware event tracking and identity management", "version": "0.0.0", "author": "Immutable", "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", diff --git a/packages/audience/sdk/src/config.ts b/packages/audience/sdk/src/config.ts index a889a5c232..71775d60c9 100644 --- a/packages/audience/sdk/src/config.ts +++ b/packages/audience/sdk/src/config.ts @@ -1,6 +1,12 @@ // SDK-specific constants. // Backend endpoints and base URLs come from @imtbl/audience-core. -export const LIBRARY_NAME = '@imtbl/audience-sdk'; -// Replaced at build time by esbuild replace plugin +export const LIBRARY_NAME = '@imtbl/audience'; +/** Replaced at build time by esbuild replace plugin. */ export const LIBRARY_VERSION = '__SDK_VERSION__'; + +/** Log prefix for console messages from this package. */ +export const LOG_PREFIX = '[audience]'; + +/** Default consent source when consentSource is not provided in config. */ +export const DEFAULT_CONSENT_SOURCE = 'WebSDK'; diff --git a/packages/audience/sdk/src/context.test.ts b/packages/audience/sdk/src/context.test.ts deleted file mode 100644 index 2444425e0e..0000000000 --- a/packages/audience/sdk/src/context.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { collectContext } from './context'; -import { LIBRARY_NAME, LIBRARY_VERSION } from './config'; - -describe('collectContext', () => { - it('should return context with SDK library name and version', () => { - const ctx = collectContext(); - - expect(ctx.library).toBe(LIBRARY_NAME); - expect(ctx.libraryVersion).toBe(LIBRARY_VERSION); - }); -}); diff --git a/packages/audience/sdk/src/context.ts b/packages/audience/sdk/src/context.ts deleted file mode 100644 index 7fec3e8ef6..0000000000 --- a/packages/audience/sdk/src/context.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { EventContext } from '@imtbl/audience-core'; -import { collectContext as coreCollectContext } from '@imtbl/audience-core'; -import { LIBRARY_NAME, LIBRARY_VERSION } from './config'; - -export function collectContext(): EventContext { - return coreCollectContext(LIBRARY_NAME, LIBRARY_VERSION); -} diff --git a/packages/audience/sdk/src/debug.test.ts b/packages/audience/sdk/src/debug.test.ts index afecf4bb04..795f6c40cf 100644 --- a/packages/audience/sdk/src/debug.test.ts +++ b/packages/audience/sdk/src/debug.test.ts @@ -1,5 +1,6 @@ import type { Message } from '@imtbl/audience-core'; import { DebugLogger } from './debug'; +import { LOG_PREFIX } from './config'; describe('DebugLogger', () => { let logSpy: jest.SpyInstance; @@ -22,7 +23,7 @@ describe('DebugLogger', () => { anonymousId: 'anon-1', surface: 'web', context: { library: 'test', libraryVersion: '0.0.0' }, - event: 'click', + eventName: 'click', properties: {}, }; @@ -42,7 +43,7 @@ describe('DebugLogger', () => { logger.logEvent('track', stubMessage); expect(logSpy).toHaveBeenCalledWith( - '[Immutable Audience] track', + `${LOG_PREFIX} track`, stubMessage, ); }); @@ -52,12 +53,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)`, ); }); @@ -66,7 +67,7 @@ describe('DebugLogger', () => { logger.logConsent('none', 'full'); expect(logSpy).toHaveBeenCalledWith( - '[Immutable Audience] consent none → full', + `${LOG_PREFIX} consent none \u2192 full`, ); }); @@ -75,7 +76,7 @@ describe('DebugLogger', () => { logger.logWarning('something went wrong'); expect(warnSpy).toHaveBeenCalledWith( - '[Immutable Audience] something went wrong', + `${LOG_PREFIX} something went wrong`, ); }); }); diff --git a/packages/audience/sdk/src/debug.ts b/packages/audience/sdk/src/debug.ts index 7d67bb335e..8c16488bc3 100644 --- a/packages/audience/sdk/src/debug.ts +++ b/packages/audience/sdk/src/debug.ts @@ -1,6 +1,5 @@ import type { ConsentLevel, Message } from '@imtbl/audience-core'; - -const PREFIX = '[Immutable Audience]'; +import { LOG_PREFIX } from './config'; export class DebugLogger { private enabled: boolean; @@ -12,24 +11,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}`); } } diff --git a/packages/audience/sdk/src/index.ts b/packages/audience/sdk/src/index.ts index 23dad10615..b36562b789 100644 --- a/packages/audience/sdk/src/index.ts +++ b/packages/audience/sdk/src/index.ts @@ -1,3 +1,3 @@ -export type { AudienceSDKConfig } from './types'; -export { DebugLogger } from './debug'; -export { collectContext } from './context'; +export { Audience } from './sdk'; +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 new file mode 100644 index 0000000000..7e67a045e5 --- /dev/null +++ b/packages/audience/sdk/src/sdk.test.ts @@ -0,0 +1,757 @@ +import { + COOKIE_NAME, SESSION_COOKIE, INGEST_PATH, CONSENT_PATH, SESSION_START, SESSION_END, +} from '@imtbl/audience-core'; +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; + +function createSDK(overrides: Record = {}) { + return Audience.init({ + publishableKey: 'pk_imtbl_test', + environment: 'sandbox', + consent: 'full', + ...overrides, + }); +} + +const originalLocation = window.location; +const fetchCalls: { url: string; init: RequestInit }[] = []; + +const mockFetch = jest.fn().mockImplementation( + async (url: string, init?: RequestInit) => { + fetchCalls.push({ url: url as string, init: init ?? {} }); + return { ok: true, json: async () => ({}) }; + }, +); +global.fetch = mockFetch; + +function sentMessages(): any[] { + return fetchCalls + .filter((c) => c.url.includes(INGEST_PATH)) + .flatMap((c) => JSON.parse(c.init.body as string).messages); +} + +beforeEach(() => { + jest.clearAllMocks(); + fetchCalls.length = 0; + jest.useFakeTimers(); + document.cookie.split(';').forEach((c) => { + document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`; + }); + localStorage.clear(); + sessionStorage.clear(); +}); + +afterEach(() => { + // Reset instance counter so each test starts fresh + (Audience as any).liveInstances = 0; + jest.useRealTimers(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); +}); + +describe('Audience', () => { + describe('init', () => { + it('creates an SDK instance via static init()', () => { + const sdk = createSDK(); + expect(sdk).toBeInstanceOf(Audience); + sdk.shutdown(); + }); + + it('creates anonymous ID cookie when consent allows', () => { + const sdk = createSDK({ consent: 'anonymous' }); + expect(document.cookie).toContain(`${COOKIE_NAME}=`); + sdk.shutdown(); + }); + + it('does not create identity cookies at none consent', () => { + const sdk = createSDK({ consent: 'none' }); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + sdk.shutdown(); + }); + + it('throws if publishableKey is empty', () => { + expect(() => Audience.init({ + publishableKey: '', + environment: 'sandbox', + })).toThrow('publishableKey is required'); + }); + + it('throws if publishableKey is whitespace only', () => { + expect(() => Audience.init({ + publishableKey: ' ', + environment: 'sandbox', + })).toThrow('publishableKey is required'); + }); + + it('warns on double init but still creates a new instance', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const first = createSDK(); + const second = Audience.init({ + publishableKey: 'pk_imtbl_other', + environment: 'production', + consent: 'none', + }); + + expect(second).not.toBe(first); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Multiple SDK instances detected'), + ); + + warnSpy.mockRestore(); + first.shutdown(); + second.shutdown(); + }); + + it('does not warn after previous instance is shut down', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const first = createSDK(); + first.shutdown(); + warnSpy.mockClear(); + + const second = createSDK(); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Multiple SDK instances'), + ); + + warnSpy.mockRestore(); + second.shutdown(); + }); + + it('emits session_start on new session', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(msg).toBeDefined(); + expect(msg.properties).toHaveProperty('sessionId'); + + sdk.shutdown(); + }); + + it('includes attribution on session_start', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=youtube&utm_campaign=launch', + href: 'https://studio.com/?utm_source=youtube&utm_campaign=launch', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(msg).toBeDefined(); + expect(msg.properties).toHaveProperty('sessionId'); + expect(msg.properties).toHaveProperty('utm_source', 'youtube'); + expect(msg.properties).toHaveProperty('utm_campaign', 'launch'); + + sdk.shutdown(); + }); + }); + + describe('track', () => { + it('enqueues an event and flushes', async () => { + const sdk = createSDK(); + + sdk.track('purchase', { + currency: 'USD', + value: 9.99, + itemId: 'sword_01', + }); + + await sdk.flush(); + + const msgs = sentMessages(); + const msg = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + currency: 'USD', + value: 9.99, + itemId: 'sword_01', + }); + expect(msg.surface).toBe('web'); + expect(msg.context.library).toBe(LIBRARY_NAME); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + expect(sentMessages()).toHaveLength(0); + sdk.shutdown(); + }); + + it('excludes userId at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.track('sign_in', { method: 'passport' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'sign_in', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('includes userId at full consent after identify', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + 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); + + sdk.shutdown(); + }); + }); + + describe('page', () => { + it('enqueues a page message', async () => { + const sdk = createSDK(); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.properties).toMatchObject({ section: 'shop' }); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const pages = sentMessages().filter((m: any) => m.type === 'page'); + expect(pages).toHaveLength(0); + + sdk.shutdown(); + }); + + it('excludes userId at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('includes userId at full consent after identify', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + 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); + + sdk.shutdown(); + }); + + it('attaches attribution to the first page view', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=youtube', + href: 'https://studio.com/?utm_source=youtube', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK(); + sdk.page(); + sdk.page(); + await sdk.flush(); + + const pages = sentMessages().filter( + (m: any) => m.type === 'page', + ); + expect(pages[0].properties).toHaveProperty( + 'utm_source', + 'youtube', + ); + if (pages[1]) { + expect(pages[1].properties?.utm_source).toBeUndefined(); + } + + sdk.shutdown(); + }); + }); + + describe('identify', () => { + it('sends an identify message at full consent', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider, { + name: 'Player One', + }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'identify', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBe(TEST_USER.uid); + expect(msg.identityType).toBe(TEST_USER.provider); + expect(msg.traits).toEqual({ name: 'Player One' }); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + await sdk.flush(); + + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + expect(ids).toHaveLength(0); + + sdk.shutdown(); + }); + + it('is a no-op at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + await sdk.flush(); + + const ids = sentMessages().filter( + (m: any) => m.type === 'identify', + ); + expect(ids).toHaveLength(0); + sdk.shutdown(); + }); + + it('ignores null passed as traits', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(null as any); + await sdk.flush(); + + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + // null is not a valid traits object — should not enqueue + expect(ids).toHaveLength(0); + + sdk.shutdown(); + }); + + it('ignores array passed as traits', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(['not', 'traits'] as any); + await sdk.flush(); + + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + expect(ids).toHaveLength(0); + + sdk.shutdown(); + }); + + it('sends anonymous identify with traits only', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify({ + source: 'steam', + steamId: TEST_STEAM.uid, + }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'identify', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + expect(msg.traits).toEqual({ + source: 'steam', + steamId: TEST_STEAM.uid, + }); + + sdk.shutdown(); + }); + }); + + describe('alias', () => { + it('sends alias with fromId/fromType/toId/toType', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const msg = sentMessages().find( + (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); + + sdk.shutdown(); + }); + + it('rejects alias when from and to are identical', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.alias( + { uid: 'same_id', provider: 'steam' }, + { uid: 'same_id', provider: 'steam' }, + ); + await sdk.flush(); + + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + sdk.shutdown(); + }); + + it('is a no-op at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + sdk.shutdown(); + }); + }); + + describe('setConsent', () => { + it('is a no-op when setting the same level', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.setConsent('full'); + await sdk.flush(); + + // No consent sync PUT should fire for same-level call + const consentCalls = fetchCalls.filter( + (c) => c.url.includes(CONSENT_PATH), + ); + expect(consentCalls).toHaveLength(0); + + sdk.shutdown(); + }); + + it('creates cookies and enables tracking on upgrade from none to full', async () => { + const sdk = createSDK({ consent: 'none' }); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + + sdk.setConsent('full'); + expect(document.cookie).toContain(`${COOKIE_NAME}=`); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + 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); + + sdk.shutdown(); + }); + + it('starts queue and emits session_start when upgrading from none', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + expect(sentMessages()).toHaveLength(0); + + sdk.setConsent('anonymous'); + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + const msgs = sentMessages(); + const sessionStart = msgs.find( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(sessionStart).toBeDefined(); + expect(sessionStart.properties).toHaveProperty('sessionId'); + + const signUp = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'sign_up', + ); + expect(signUp).toBeDefined(); + + sdk.shutdown(); + }); + + it('purges identify/alias, strips userId on downgrade', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.alias(TEST_STEAM, TEST_USER); + sdk.track('purchase', { currency: 'USD', value: 9.99 }); + + sdk.setConsent('anonymous'); + await sdk.flush(); + + const msgs = sentMessages(); + expect( + msgs.every((m: any) => m.type !== 'identify'), + ).toBe(true); + expect( + msgs.every((m: any) => m.type !== 'alias'), + ).toBe(true); + const trackMsg = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + expect(trackMsg).toBeDefined(); + expect(trackMsg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('clears identity cookies on downgrade to none', () => { + const sdk = createSDK({ consent: 'anonymous' }); + expect(document.cookie).toContain(`${COOKIE_NAME}=`); + expect(document.cookie).toContain(`${SESSION_COOKIE}=`); + + sdk.setConsent('none'); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + expect(document.cookie).not.toContain(`${SESSION_COOKIE}=`); + + sdk.shutdown(); + }); + + it('stops queue and makes track no-op on anonymous to none downgrade', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.track('before', { step: 1 }); + await sdk.flush(); + const beforeMsgs = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === 'before', + ); + expect(beforeMsgs.length).toBeGreaterThan(0); + fetchCalls.length = 0; + + sdk.setConsent('none'); + sdk.track('after', { step: 2 }); + await sdk.flush(); + + const afterMsgs = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === 'after', + ); + expect(afterMsgs).toHaveLength(0); + + sdk.shutdown(); + }); + + it('re-attaches attribution after consent downgrade and re-upgrade', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=tiktok', + href: 'https://studio.com/?utm_source=tiktok', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK({ consent: 'anonymous' }); + sdk.page(); + await sdk.flush(); + fetchCalls.length = 0; + + // Downgrade then re-upgrade + sdk.setConsent('none'); + sdk.setConsent('anonymous'); + sdk.page(); + await sdk.flush(); + + const pages = sentMessages().filter((m: any) => m.type === 'page'); + expect(pages[0]?.properties).toHaveProperty('utm_source', 'tiktok'); + + sdk.shutdown(); + }); + }); + + describe('shutdown', () => { + it('emits session_end with duration', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + fetchCalls.length = 0; + + jest.advanceTimersByTime(5000); + sdk.shutdown(); + + // destroy() calls flushUnload() which fires a keepalive fetch synchronously. + // Yield to ensure all microtasks settle before reading fetchCalls. + await Promise.resolve(); + await Promise.resolve(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === SESSION_END, + ); + expect(msg).toBeDefined(); + expect(msg.properties).toHaveProperty('sessionId'); + expect(msg.properties.duration).toBe(5); + }); + + it('is safe to call twice (React strict mode)', async () => { + const sdk = createSDK({ consent: 'full' }); + sdk.shutdown(); + + await Promise.resolve(); + await Promise.resolve(); + fetchCalls.length = 0; + + sdk.shutdown(); + await Promise.resolve(); + await Promise.resolve(); + + const sessionEnds = sentMessages().filter( + (m: any) => m.eventName === SESSION_END, + ); + expect(sessionEnds).toHaveLength(0); + }); + + it('does not emit session_end at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + sdk.shutdown(); + + expect(sentMessages().filter( + (m: any) => m.eventName === SESSION_END, + )).toHaveLength(0); + }); + }); + + describe('reset', () => { + it('clears pending messages from the queue', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.track('before_reset', { step: 1 }); + sdk.reset(); + await sdk.flush(); + + const msgs = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === 'before_reset', + ); + expect(msgs).toHaveLength(0); + + sdk.shutdown(); + }); + + it('clears userId and generates new anonymousId', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.track('sign_in', { method: 'passport' }); + await sdk.flush(); + const originalAnonId = sentMessages().find( + (m: any) => m.type === 'track', + )?.anonymousId; + fetchCalls.length = 0; + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.reset(); + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + expect(msg.anonymousId).toBeDefined(); + expect(msg.anonymousId).not.toBe(originalAnonId); + + sdk.shutdown(); + }); + + it('emits session_start after reset', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.reset(); + await sdk.flush(); + + const sessionStarts = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(sessionStarts).toHaveLength(1); + + sdk.shutdown(); + }); + + it('works at none consent without creating cookies', () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.reset(); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + expect(document.cookie).not.toContain(`${SESSION_COOKIE}=`); + + sdk.shutdown(); + }); + }); +}); diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts new file mode 100644 index 0000000000..3343c348e7 --- /dev/null +++ b/packages/audience/sdk/src/sdk.ts @@ -0,0 +1,428 @@ +import type { + Attribution, + ConsentLevel, + ConsentManager, + Message, + UserTraits, +} from '@imtbl/audience-core'; +import { + INGEST_PATH, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + COOKIE_NAME, + SESSION_COOKIE, + MessageQueue, + httpTransport, + getBaseUrl, + getOrCreateAnonymousId, + getCookie, + deleteCookie, + generateId, + getTimestamp, + isAliasValid, + isTimestampValid, + truncate, + collectContext, + collectAttribution, + getOrCreateSession, + createConsentManager, + SESSION_START, + SESSION_END, +} from '@imtbl/audience-core'; +import { DebugLogger } from './debug'; +import type { AudienceConfig } from './types'; +import { + LIBRARY_NAME, LIBRARY_VERSION, LOG_PREFIX, DEFAULT_CONSENT_SOURCE, +} from './config'; + +/** + * Track player activity on your website — page views, purchases, sign-ups — + * and tie it to player identity when they log in. + * + * Create via `Audience.init()`. Call `shutdown()` when done. + */ +export class Audience { + private static liveInstances = 0; + + private readonly queue: MessageQueue; + + private readonly consent: ConsentManager; + + private readonly attribution: Attribution; + + private readonly debug: DebugLogger; + + private readonly cookieDomain?: string; + + private anonymousId: string; + + private sessionId: string | undefined; + + private sessionStartTime: number | undefined; + + private userId: string | undefined; + + private isFirstPage = true; + + private destroyed = false; + + private constructor(config: AudienceConfig) { + const { + cookieDomain, + environment, + publishableKey, + } = config; + const consentLevel = config.consent ?? 'none'; + const consentSource = DEFAULT_CONSENT_SOURCE; + const flushInterval = config.flushInterval ?? FLUSH_INTERVAL_MS; + const flushSize = config.flushSize ?? FLUSH_SIZE; + + this.cookieDomain = cookieDomain; + this.debug = new DebugLogger(config.debug ?? false); + + let isNewSession = false; + if (consentLevel !== 'none') { + this.anonymousId = getOrCreateAnonymousId(cookieDomain); + isNewSession = this.startSession(); + } else { + this.anonymousId = getCookie(COOKIE_NAME) ?? generateId(); + } + + const endpointUrl = `${getBaseUrl(environment)}${INGEST_PATH}`; + this.queue = new MessageQueue( + httpTransport, + endpointUrl, + publishableKey, + flushInterval, + flushSize, + { + onFlush: (ok, count) => this.debug.logFlush(ok, count), + staleFilter: (m) => isTimestampValid(m.eventTimestamp), + storagePrefix: '__imtbl_web_', + }, + ); + + this.consent = createConsentManager( + this.queue, + publishableKey, + this.anonymousId, + environment, + consentSource, + consentLevel, + ); + + this.attribution = collectAttribution(); + + if (!this.isTrackingDisabled()) { + this.queue.start(); + if (isNewSession) this.trackSessionStart(); + } + } + + /** + * Create and start the SDK. Warns if another instance is already active — + * call `shutdown()` on the previous one first. + */ + static init(config: AudienceConfig): Audience { + if (!config.publishableKey?.trim()) { + throw new Error(`${LOG_PREFIX} publishableKey is required`); + } + if (Audience.liveInstances > 0) { + // eslint-disable-next-line no-console + console.warn( + `${LOG_PREFIX} Multiple SDK instances detected.` + + ' Ensure previous instances are shut down to avoid duplicate events.', + ); + } + Audience.liveInstances += 1; + return new Audience(config); + } + + // --- Helpers --- + + /** True when consent is 'none' — SDK should not enqueue anything. */ + private isTrackingDisabled(): boolean { + return this.consent.level === 'none'; + } + + /** Returns userId if consent is full, undefined otherwise. */ + private effectiveUserId(): string | undefined { + return this.consent.level === 'full' ? this.userId : undefined; + } + + /** Create or resume a session, returning whether it's new. */ + private startSession(): boolean { + const session = getOrCreateSession(this.cookieDomain); + this.sessionId = session.sessionId; + this.sessionStartTime = Date.now(); + return session.isNew; + } + + // --- Message factory --- + + /** Common fields shared by every message. */ + private baseMessage() { + return { + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web' as const, + context: collectContext(LIBRARY_NAME, LIBRARY_VERSION), + }; + } + + private enqueue(label: string, message: Message): void { + this.queue.enqueue(message); + this.debug.logEvent(label, message); + } + + // --- Session lifecycle --- + + private trackSessionStart(): void { + if (!this.sessionId) return; + this.enqueue('track(session_start)', { + ...this.baseMessage(), + type: 'track', + eventName: SESSION_START, + properties: { + sessionId: this.sessionId, + ...this.attribution, + }, + }); + } + + private trackSessionEnd(): void { + if (!this.sessionId) return; + this.enqueue('track(session_end)', { + ...this.baseMessage(), + type: 'track', + eventName: SESSION_END, + properties: { + sessionId: this.sessionId, + ...(this.sessionStartTime && { + duration: Math.round((Date.now() - this.sessionStartTime) / 1000), + }), + }, + }); + } + + // --- Page tracking --- + + /** + * Record a page view. Call this on every route change in your app. + * The first call automatically captures how the player arrived + * (UTM params, ad click IDs, referrer). No-op when consent is 'none'. + */ + page(properties?: Record): void { + if (this.isTrackingDisabled()) return; + getOrCreateSession(this.cookieDomain); + + const mergedProps: Record = { ...properties }; + if (this.isFirstPage) { + Object.assign(mergedProps, this.attribution); + this.isFirstPage = false; + } + + this.enqueue('page', { + ...this.baseMessage(), + type: 'page', + properties: Object.keys(mergedProps).length > 0 ? mergedProps : undefined, + userId: this.effectiveUserId(), + }); + } + + // --- Event tracking --- + + /** + * Record a player action like a purchase, sign-up, or game launch. + * Pass the event name and any properties you want to analyse later. + * No-op when consent is 'none'. + */ + track(event: string, properties?: Record): void { + if (this.isTrackingDisabled()) return; + getOrCreateSession(this.cookieDomain); + + this.enqueue('track', { + ...this.baseMessage(), + type: 'track', + eventName: truncate(event), + properties, + userId: this.effectiveUserId(), + }); + } + + // --- Identity --- + + /** + * Tell the SDK who this player is. Call when a player logs in or links + * an account. Before identify(), the SDK only knows an anonymous cookie ID. + * After, all future events are tied to this player. + * + * Named: `sdk.identify('user@example.com', 'email', { name: 'Jane' })` + * Traits only: `sdk.identify({ source: 'steam', steamId: '765...' })` + * + * Requires 'full' consent. + */ + identify(uid: string, provider: string, traits?: UserTraits): void; + + identify(traits: UserTraits): void; + + identify( + uidOrTraits: string | UserTraits, + provider?: string, + traits?: UserTraits, + ): void { + if (this.consent.level !== 'full') { + this.debug.logWarning('identify() requires full consent — call ignored.'); + return; + } + getOrCreateSession(this.cookieDomain); + + if (uidOrTraits !== null && typeof uidOrTraits === 'object' && !Array.isArray(uidOrTraits)) { + this.enqueue('identify', { + ...this.baseMessage(), + type: 'identify', + traits: uidOrTraits, + }); + return; + } + + if (typeof uidOrTraits !== 'string') return; + + const uid = truncate(uidOrTraits); + this.userId = uid; + this.enqueue('identify', { + ...this.baseMessage(), + type: 'identify', + userId: uid, + identityType: provider, + traits, + }); + } + + /** + * Connect two accounts that belong to the same player. Use when a player + * previously known by one identity (e.g. Steam ID) creates or links a + * different account (e.g. Passport email). This tells the backend they're + * the same person so analytics aren't split across two profiles. + * + * Requires 'full' consent. `from` and `to` must differ. + */ + alias( + from: { uid: string; provider: string }, + to: { uid: string; provider: string }, + ): 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)) { + this.debug.logWarning('alias() from and to are identical — call ignored.'); + return; + } + getOrCreateSession(this.cookieDomain); + + this.enqueue('alias', { + ...this.baseMessage(), + type: 'alias', + fromId: truncate(from.uid), + fromType: from.provider, + toId: truncate(to.uid), + toType: to.provider, + }); + } + + // --- Consent --- + + /** + * Update tracking consent, typically in response to a cookie banner. + * Call whenever your consent management platform reports a change. + * + * - 'none': all tracking stops, cookies are cleared. + * - 'anonymous': track activity without knowing who the player is. + * - 'full': track everything including player identity. + */ + setConsent(level: ConsentLevel): void { + const previous = this.consent.level; + if (level === previous) return; + + this.debug.logConsent(previous, level); + + const isUpgradeFromNone = previous === 'none' && level !== 'none'; + + // When upgrading from none, create the persisted anonymousId first + // so the consent sync sends the correct ID to the server. + if (isUpgradeFromNone) { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + } + + // Web-specific cleanup before core handles queue purge/transform and server sync. + // session_end is intentionally not emitted — no events should be sent after opt-out. + if (level === 'none') { + this.queue.stop(); + } + + // Core handles: queue purge on →none, identify/alias purge + userId strip + // on →anonymous, server sync. + this.consent.setLevel(level); + + // Web-specific cleanup after core's transition. + if (level === 'none') { + deleteCookie(COOKIE_NAME, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + } else if (level === 'anonymous' && previous === 'full') { + this.userId = undefined; + } + + if (isUpgradeFromNone) { + this.isFirstPage = true; + const isNewSession = this.startSession(); + this.queue.start(); + if (isNewSession) this.trackSessionStart(); + } + } + + // --- Lifecycle --- + + /** + * Call on player logout. Generates a fresh anonymous ID so the next + * player on this device isn't confused with the previous one. Queued + * events from the previous session are discarded. + */ + reset(): void { + this.userId = undefined; + this.queue.clear(); + deleteCookie(COOKIE_NAME, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + if (!this.isTrackingDisabled()) { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + const isNewSession = this.startSession(); + if (isNewSession) this.trackSessionStart(); + } else { + this.anonymousId = generateId(); + this.sessionId = undefined; + this.sessionStartTime = undefined; + } + this.isFirstPage = true; + } + + /** + * Send all queued events now instead of waiting for the next automatic + * flush. Useful before navigating away from a critical page. + */ + async flush(): Promise { + await this.queue.flush(); + } + + /** + * Stop the SDK and send any remaining events. Call when your app + * unmounts or the player leaves. + */ + shutdown(): void { + if (this.destroyed) return; + this.destroyed = true; + if (!this.isTrackingDisabled()) this.trackSessionEnd(); + this.queue.destroy(); + Audience.liveInstances = Math.max(0, Audience.liveInstances - 1); + } +} diff --git a/packages/audience/sdk/src/types.ts b/packages/audience/sdk/src/types.ts index 2126c90c57..6d02bcc3c4 100644 --- a/packages/audience/sdk/src/types.ts +++ b/packages/audience/sdk/src/types.ts @@ -1,13 +1,19 @@ import type { Environment, ConsentLevel } from '@imtbl/audience-core'; -/** Configuration for the Immutable Audience SDK. */ -export interface AudienceSDKConfig { +/** Configuration for the Immutable Web SDK. */ +export interface AudienceConfig { + /** 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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf636b2d46..21af326087 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 0.25.21(@emotion/react@11.11.3(@types/react@18.3.12)(react@18.3.1))(@rive-app/react-canvas-lite@4.9.0(react@18.3.1))(embla-carousel-react@8.1.5(react@18.3.1))(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@imtbl/sdk': specifier: latest - version: 2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10) + version: 2.14.3(bufferutil@4.0.8)(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10) next: specifier: 14.2.25 version: 14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1106,7 +1106,7 @@ importers: version: 15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^5.0.0-beta.30 - version: 5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -14665,7 +14665,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qr-code-styling@1.6.0-rc.1: @@ -20364,12 +20363,12 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/checkout-sdk@2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/checkout-sdk@2.14.3(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/blockchain-data': 2.14.3 '@imtbl/bridge-sdk': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@imtbl/config': 2.14.3 - '@imtbl/dex-sdk': 2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) + '@imtbl/dex-sdk': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@imtbl/generated-clients': 2.14.3 '@imtbl/metrics': 2.14.3 '@imtbl/orderbook': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20435,12 +20434,12 @@ snapshots: - typescript - utf-8-validate - '@imtbl/dex-sdk@2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)': + '@imtbl/dex-sdk@2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@imtbl/config': 2.14.3 '@uniswap/sdk-core': 3.2.3 - '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) - '@uniswap/v3-sdk': 3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) + '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) + '@uniswap/v3-sdk': 3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -20519,13 +20518,13 @@ snapshots: - encoding - supports-color - '@imtbl/sdk@2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/sdk@2.14.3(bufferutil@4.0.8)(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/auth': 2.14.3 '@imtbl/auth-next-client': 2.14.3(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@imtbl/auth-next-server': 2.14.3(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@imtbl/blockchain-data': 2.14.3 - '@imtbl/checkout-sdk': 2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/checkout-sdk': 2.14.3(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) '@imtbl/config': 2.14.3 '@imtbl/minting-backend': 2.14.3 '@imtbl/orderbook': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20750,43 +20749,6 @@ snapshots: - ts-node - utf-8-validate - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(node-notifier@8.0.2) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.14.13 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.8.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.14.13)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.5 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - optionalDependencies: - node-notifier: 8.0.2 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))': dependencies: '@jest/console': 29.7.0 @@ -25204,17 +25166,6 @@ snapshots: tiny-invariant: 1.3.1 toformat: 2.0.0 - '@uniswap/swap-router-contracts@1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': - dependencies: - '@openzeppelin/contracts': 3.4.2 - '@uniswap/v2-core': 1.0.1 - '@uniswap/v3-core': 1.0.0 - '@uniswap/v3-periphery': 1.4.4 - dotenv: 14.3.2 - hardhat-watcher: 2.5.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) - transitivePeerDependencies: - - hardhat - '@uniswap/swap-router-contracts@1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': dependencies: '@openzeppelin/contracts': 3.4.2 @@ -25246,19 +25197,6 @@ snapshots: '@uniswap/v3-core': 1.0.0 base64-sol: 1.0.1 - '@uniswap/v3-sdk@3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': - dependencies: - '@ethersproject/abi': 5.7.0 - '@ethersproject/solidity': 5.7.0 - '@uniswap/sdk-core': 4.0.6 - '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) - '@uniswap/v3-periphery': 1.4.3 - '@uniswap/v3-staker': 1.0.0 - tiny-invariant: 1.3.1 - tiny-warning: 1.0.3 - transitivePeerDependencies: - - hardhat - '@uniswap/v3-sdk@3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/abi': 5.7.0 @@ -27400,21 +27338,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)): dependencies: '@jest/types': 29.6.3 @@ -30020,11 +29943,6 @@ snapshots: - debug - utf-8-validate - hardhat-watcher@2.5.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)): - dependencies: - chokidar: 3.6.0 - hardhat: 2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10) - hardhat-watcher@2.5.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)): dependencies: chokidar: 3.6.0 @@ -31027,11 +30945,11 @@ snapshots: jest-cli@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0) + create-jest: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)) exit: 0.1.2 import-local: 3.1.0 jest-config: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)) @@ -32095,7 +32013,7 @@ snapshots: jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2)) '@jest/types': 29.6.3 import-local: 3.1.0 jest-cli: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) @@ -33251,12 +33169,6 @@ snapshots: react: 18.3.1 optional: true - next-auth@5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): - dependencies: - '@auth/core': 0.41.0 - next: 15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - next-auth@5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106): dependencies: '@auth/core': 0.41.0 @@ -33266,7 +33178,7 @@ snapshots: next-auth@5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@auth/core': 0.41.0 - next: 15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -33341,29 +33253,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@next/env': 15.5.10 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001760 - postcss: 8.4.31 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.7 - '@next/swc-darwin-x64': 15.5.7 - '@next/swc-linux-arm64-gnu': 15.5.7 - '@next/swc-linux-arm64-musl': 15.5.7 - '@next/swc-linux-x64-gnu': 15.5.7 - '@next/swc-linux-x64-musl': 15.5.7 - '@next/swc-win32-arm64-msvc': 15.5.7 - '@next/swc-win32-x64-msvc': 15.5.7 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nice-napi@1.0.2: dependencies: node-addon-api: 3.2.1