diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index 0ef355ce17..df6dd0f552 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -13,6 +13,7 @@ export const FLUSH_INTERVAL_MS = 5_000; export const FLUSH_SIZE = 20; export const COOKIE_NAME = 'imtbl_anon_id'; +export const SESSION_COOKIE = '_imtbl_sid'; export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment]; diff --git a/packages/audience/core/src/context.test.ts b/packages/audience/core/src/context.test.ts index 54fe7d0649..8e3b18c298 100644 --- a/packages/audience/core/src/context.test.ts +++ b/packages/audience/core/src/context.test.ts @@ -1,12 +1,18 @@ import { collectContext } from './context'; describe('collectContext', () => { - it('includes library name and version', () => { + it('defaults to @imtbl/audience library name', () => { const ctx = collectContext(); expect(ctx.library).toBe('@imtbl/audience'); expect(ctx.libraryVersion).toBeDefined(); }); + it('accepts custom library name and version', () => { + const ctx = collectContext('@imtbl/audience-web-sdk', '1.0.0'); + expect(ctx.library).toBe('@imtbl/audience-web-sdk'); + expect(ctx.libraryVersion).toBe('1.0.0'); + }); + it('collects browser signals in jsdom', () => { const ctx = collectContext(); expect(ctx.userAgent).toBeDefined(); diff --git a/packages/audience/core/src/context.ts b/packages/audience/core/src/context.ts index 826f97fed8..4c449b5a75 100644 --- a/packages/audience/core/src/context.ts +++ b/packages/audience/core/src/context.ts @@ -4,10 +4,20 @@ import { isBrowser } from './utils'; // WARNING: DO NOT CHANGE THE STRING BELOW. IT GETS REPLACED AT BUILD TIME. const SDK_VERSION = '__SDK_VERSION__'; -export function collectContext(): EventContext { +/** + * Collect browser context for event payloads. + * + * Callers may pass their own library name and version when multiple surfaces + * (web SDK, pixel, Unity, Unreal) share this function and each must identify + * itself. Defaults to '@imtbl/audience' with the build-time SDK version. + */ +export function collectContext( + library = '@imtbl/audience', + version = SDK_VERSION, +): EventContext { const context: EventContext = { - library: '@imtbl/audience', - libraryVersion: SDK_VERSION, + library, + libraryVersion: version, }; if (!isBrowser()) return context; diff --git a/packages/audience/core/src/cookie.ts b/packages/audience/core/src/cookie.ts index 3f0057545c..7ccd9f5f98 100644 --- a/packages/audience/core/src/cookie.ts +++ b/packages/audience/core/src/cookie.ts @@ -1,16 +1,22 @@ import { COOKIE_NAME, COOKIE_MAX_AGE_SECONDS } from './config'; import { isBrowser, generateId } from './utils'; -function getCookie(name: string): string | undefined { +export function getCookie(name: string): string | undefined { if (!isBrowser()) return undefined; const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : undefined; } -function setCookie(name: string, value: string, maxAge: number): void { +export function setCookie(name: string, value: string, maxAge: number, domain?: string): void { if (!isBrowser()) return; const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`; + const domainAttr = domain ? `; domain=${domain}` : ''; + document.cookie = `${name}=${encodeURIComponent(value)}` + + `; path=/; max-age=${maxAge}; SameSite=Lax${domainAttr}${secure}`; +} + +export function deleteCookie(name: string, domain?: string): void { + setCookie(name, '', 0, domain); } /** @@ -18,12 +24,12 @@ function setCookie(name: string, value: string, maxAge: number): void { * Both the web SDK and pixel read/write the same cookie so identity stitching * works across surfaces on the same domain. */ -export function getOrCreateAnonymousId(): string { +export function getOrCreateAnonymousId(domain?: string): string { const existing = getCookie(COOKIE_NAME); if (existing) return existing; const id = generateId(); - setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS); + setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS, domain); return id; } diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 7cd23d9245..0ae370d0d8 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -11,9 +11,17 @@ export type { AliasMessage, Message, BatchPayload, + ConsentLevel, + ConsentStatus, } from './types'; -export { getOrCreateAnonymousId, getAnonymousId } from './cookie'; +export { + getOrCreateAnonymousId, + getAnonymousId, + getCookie, + setCookie, + deleteCookie, +} from './cookie'; export * as storage from './storage'; export { @@ -23,6 +31,7 @@ export { FLUSH_INTERVAL_MS, FLUSH_SIZE, COOKIE_NAME, + SESSION_COOKIE, } from './config'; export { generateId, getTimestamp, isBrowser } from './utils'; @@ -31,3 +40,9 @@ export type { Transport } from './transport'; export { httpTransport, httpSend } from './transport'; export { MessageQueue } from './queue'; export { collectContext } from './context'; +export { + isTimestampValid, + isAliasValid, + truncate, + truncateSource, +} from './validation'; diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index bf9abbe462..f5bbd624c3 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -15,9 +15,16 @@ function makeMessage(id: string): Message { }; } +interface QueueOpts { + flushIntervalMs?: number; + flushSize?: number; + onFlush?: (ok: boolean, count: number) => void; + staleFilter?: (msg: Message) => boolean; +} + function createQueue( transport: Transport, - opts: { flushIntervalMs?: number; flushSize?: number } = {}, + opts: QueueOpts = {}, ) { return new MessageQueue( transport, @@ -25,6 +32,7 @@ function createQueue( 'pk_imx_test', opts.flushIntervalMs ?? 5_000, opts.flushSize ?? 20, + { onFlush: opts.onFlush, staleFilter: opts.staleFilter }, ); } @@ -110,6 +118,17 @@ describe('MessageQueue', () => { expect(queue.length).toBe(1); }); + it('filters stale messages on restore', () => { + storage.setItem('queue', [makeMessage('stale'), makeMessage('fresh')]); + + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }, { + staleFilter: (m) => m.messageId === 'fresh', + }); + + expect(queue.length).toBe(1); + }); + it('does not flush concurrently', async () => { let resolveFirst: () => void; const firstCall = new Promise((r) => { resolveFirst = () => r(true); }); @@ -144,7 +163,6 @@ describe('MessageQueue', () => { it('handles messages enqueued during flush', async () => { let queue: ReturnType; const send = jest.fn().mockImplementation(async () => { - // Simulate a message arriving during the network request queue.enqueue(makeMessage('late')); return true; }); @@ -154,28 +172,52 @@ describe('MessageQueue', () => { await queue.flush(); - // The original message was sent, but the late one should remain expect(queue.length).toBe(1); }); -}); -describe('page-unload flush', () => { - let sendBeaconSpy: jest.SpyInstance; + it('calls onFlush callback', async () => { + const onFlush = jest.fn(); + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }, { onFlush }); - beforeEach(() => { - sendBeaconSpy = jest.fn().mockReturnValue(true); - Object.defineProperty(navigator, 'sendBeacon', { - value: sendBeaconSpy, - writable: true, - configurable: true, - }); + queue.enqueue(makeMessage('1')); + await queue.flush(); + + expect(onFlush).toHaveBeenCalledWith(true, 1); }); - afterEach(() => { - sendBeaconSpy.mockRestore?.(); + it('purges messages matching a predicate', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + + queue.enqueue(makeMessage('1')); + queue.enqueue({ ...makeMessage('2'), type: 'identify' } as any); + queue.enqueue(makeMessage('3')); + + queue.purge((m) => m.type === 'identify'); + expect(queue.length).toBe(2); }); - it('flushes via sendBeacon on visibilitychange to hidden', () => { + it('transforms messages in place', async () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + + queue.enqueue({ ...makeMessage('1'), userId: 'should-strip' } as any); + + queue.transform((m) => { + const cleaned = { ...m }; + delete (cleaned as any).userId; + return cleaned; + }); + + await queue.flush(); + const msg = send.mock.calls[0][2].messages[0]; + expect((msg as any).userId).toBeUndefined(); + }); +}); + +describe('page-unload flush (keepalive)', () => { + it('flushes via keepalive fetch on visibilitychange to hidden', () => { const send = jest.fn().mockResolvedValue(true); const queue = createQueue({ send }); queue.start(); @@ -189,10 +231,11 @@ describe('page-unload flush', () => { }); document.dispatchEvent(new Event('visibilitychange')); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); - expect(sendBeaconSpy).toHaveBeenCalledWith( + expect(send).toHaveBeenCalledWith( 'https://api.immutable.com/v1/audience/messages', - expect.any(Blob), + 'pk_imx_test', + expect.objectContaining({ messages: expect.any(Array) }), + { keepalive: true }, ); expect(queue.length).toBe(0); @@ -204,7 +247,7 @@ describe('page-unload flush', () => { }); }); - it('flushes via sendBeacon on pagehide', () => { + it('flushes via keepalive fetch on pagehide', () => { const send = jest.fn().mockResolvedValue(true); const queue = createQueue({ send }); queue.start(); @@ -212,20 +255,25 @@ describe('page-unload flush', () => { queue.enqueue(makeMessage('1')); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + 'https://api.immutable.com/v1/audience/messages', + 'pk_imx_test', + expect.objectContaining({ messages: expect.any(Array) }), + { keepalive: true }, + ); expect(queue.length).toBe(0); queue.stop(); }); - it('does not fire beacon when queue is empty', () => { + it('does not fire unload flush when queue is empty', () => { const send = jest.fn().mockResolvedValue(true); const queue = createQueue({ send }); queue.start(); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); queue.stop(); }); @@ -239,7 +287,7 @@ describe('page-unload flush', () => { queue.enqueue(makeMessage('1')); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); }); it('destroy stops the queue and flushes remaining messages', () => { @@ -251,52 +299,21 @@ describe('page-unload flush', () => { queue.enqueue(makeMessage('2')); queue.destroy(); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ messages: expect.any(Array) }), + { keepalive: true }, + ); expect(queue.length).toBe(0); // Listeners removed — no double flush queue.enqueue(makeMessage('3')); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); - }); - - it('falls back to async flush if sendBeacon returns false', async () => { - sendBeaconSpy.mockReturnValue(false); - const send = jest.fn().mockResolvedValue(true); - const queue = createQueue({ send }); - queue.start(); - - queue.enqueue(makeMessage('1')); - window.dispatchEvent(new Event('pagehide')); - - // sendBeacon failed, so async flush should have been triggered - await Promise.resolve(); - expect(send).toHaveBeenCalledTimes(1); - - queue.stop(); - }); - - it('falls back to async flush if sendBeacon is unavailable', async () => { - Object.defineProperty(navigator, 'sendBeacon', { - value: undefined, - writable: true, - configurable: true, - }); - - const send = jest.fn().mockResolvedValue(true); - const queue = createQueue({ send }); - queue.start(); - - queue.enqueue(makeMessage('1')); - window.dispatchEvent(new Event('pagehide')); - - await Promise.resolve(); expect(send).toHaveBeenCalledTimes(1); - - queue.stop(); }); - it('skips beacon if an async flush is already in flight', async () => { + it('skips unload flush if an async flush is already in flight', async () => { let resolveFlush: () => void; const flushPromise = new Promise((r) => { resolveFlush = () => r(true); }); const send = jest.fn().mockReturnValueOnce(flushPromise); @@ -308,9 +325,10 @@ describe('page-unload flush', () => { // Start an async flush (sets flushing = true) const pending = queue.flush(); - // pagehide fires while async flush is in flight — beacon should be skipped + // pagehide fires while async flush is in flight — unload flush should be skipped window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).not.toHaveBeenCalled(); + // Only 1 call (the async flush), no keepalive call + expect(send).toHaveBeenCalledTimes(1); resolveFlush!(); await pending; diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index e165aef83c..c8c0508fe2 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -4,6 +4,19 @@ import * as storage from './storage'; import { isBrowser } from './utils'; const STORAGE_KEY = 'queue'; +const MAX_BATCH_SIZE = 100; // Backend maxItems limit per OAS + +export interface MessageQueueOptions { + onFlush?: (ok: boolean, count: number) => void; + staleFilter?: (msg: Message) => boolean; + /** + * Override the localStorage key prefix (default: '__imtbl_audience_'). + * Use when multiple SDK surfaces run on the same page to prevent + * queue collision — e.g. web SDK uses '__imtbl_web_' so its queued + * messages don't interfere with the shared SDK's queue. + */ + storagePrefix?: string; +} /** * Batched message queue with localStorage durability. @@ -14,11 +27,13 @@ const STORAGE_KEY = 'queue'; * * localStorage is used as a write-through cache so messages survive * page navigations. On construction, any previously-persisted messages - * are restored into memory. + * are restored into memory (optionally filtered by `staleFilter`). * - * When started, the queue also listens for page-unload events - * (`visibilitychange` and `pagehide`) and flushes via `sendBeacon` - * to ensure events are not lost when the user navigates away. + * When started, the queue listens for page-unload events + * (`visibilitychange` and `pagehide`) and flushes via `fetch` with + * `keepalive: true` to ensure events are not lost when the user + * navigates away. sendBeacon is NOT used because the backend requires + * the `x-immutable-publishable-key` header which sendBeacon cannot set. */ export class MessageQueue { private messages: Message[]; @@ -27,15 +42,15 @@ export class MessageQueue { private flushing = false; - private readonly onVisibilityChange = (): void => { - if (document.visibilityState === 'hidden') { - this.flushBeacon(); - } - }; + private unloadBound = false; - private readonly onPageHide = (): void => { - this.flushBeacon(); - }; + private visibilityHandler?: () => void; + + private pagehideHandler?: () => void; + + private readonly onFlush?: (ok: boolean, count: number) => void; + + private readonly storagePrefix?: string; constructor( private readonly transport: Transport, @@ -43,35 +58,35 @@ export class MessageQueue { private readonly publishableKey: string, private readonly flushIntervalMs: number, private readonly flushSize: number, + options?: MessageQueueOptions, ) { - this.messages = (storage.getItem(STORAGE_KEY) as Message[] | undefined) ?? []; + this.onFlush = options?.onFlush; + this.storagePrefix = options?.storagePrefix; + + const restored = (storage.getItem(STORAGE_KEY, this.storagePrefix) as Message[] | undefined) ?? []; + this.messages = options?.staleFilter + ? restored.filter(options.staleFilter) + : restored; } start(): void { if (this.timer) return; this.timer = setInterval(() => this.flush(), this.flushIntervalMs); - - if (isBrowser()) { - document.addEventListener('visibilitychange', this.onVisibilityChange); - window.addEventListener('pagehide', this.onPageHide); - } + this.registerUnload(); } stop(): void { - if (!this.timer) return; - clearInterval(this.timer); - this.timer = null; - - if (isBrowser()) { - document.removeEventListener('visibilitychange', this.onVisibilityChange); - window.removeEventListener('pagehide', this.onPageHide); + if (this.timer) { + clearInterval(this.timer); + this.timer = null; } + this.removeUnload(); } - /** Stops the queue, flushes remaining messages via beacon, and removes listeners. */ + /** Stops the queue, flushes remaining messages via keepalive fetch, and removes listeners. */ destroy(): void { this.stop(); - this.flushBeacon(); + this.flushUnload(); } enqueue(message: Message): void { @@ -83,61 +98,96 @@ export class MessageQueue { } } - /** Guard prevents concurrent flushes from racing on the same batch. */ + /** + * Send queued messages to the backend and wait for the response. + * On success, sent messages are removed from the queue. On failure, + * messages stay queued and retry on the next flush cycle. + * Use this for normal operation. For page-unload scenarios, use + * flushUnload() instead — it's fire-and-forget and survives navigation. + */ async flush(): Promise { if (this.flushing || this.messages.length === 0) return; this.flushing = true; try { - const batch = [...this.messages]; + const batch = this.messages.slice(0, MAX_BATCH_SIZE); const payload: BatchPayload = { messages: batch }; const ok = await this.transport.send(this.endpointUrl, this.publishableKey, payload); if (ok) { - // Slice rather than clear — new messages may have been enqueued during the request. this.messages = this.messages.slice(batch.length); this.persist(); } + this.onFlush?.(ok, batch.length); } finally { this.flushing = false; } } + /** + * Fire-and-forget flush for page-unload scenarios. + * + * Uses `fetch` with `keepalive: true` so the request survives page + * navigation. Unlike `flush()`, this is synchronous and does not wait + * for the response — use it only in `visibilitychange`/`pagehide` + * handlers or in `shutdown()`. + */ + flushUnload(): void { + if (this.flushing || this.messages.length === 0) return; + + const batch = this.messages.slice(0, MAX_BATCH_SIZE); + const payload: BatchPayload = { messages: batch }; + + this.transport.send(this.endpointUrl, this.publishableKey, payload, { keepalive: true }); + this.messages = this.messages.slice(batch.length); + this.persist(); + } + + /** Remove all messages matching a predicate. */ + purge(predicate: (msg: Message) => boolean): void { + this.messages = this.messages.filter((m) => !predicate(m)); + this.persist(); + } + + /** Transform messages in place (e.g., strip userId on consent downgrade). */ + transform(fn: (msg: Message) => Message): void { + this.messages = this.messages.map(fn); + this.persist(); + } + get length(): number { return this.messages.length; } clear(): void { this.messages = []; - storage.removeItem(STORAGE_KEY); + storage.removeItem(STORAGE_KEY, this.storagePrefix); } - /** - * Synchronous flush using sendBeacon for page-unload scenarios. - * sendBeacon is fire-and-forget and survives page navigation. - * Falls back to the normal async flush if sendBeacon is unavailable. - */ - private flushBeacon(): void { - if (this.flushing || this.messages.length === 0) return; + private registerUnload(): void { + if (!isBrowser() || this.unloadBound) return; + this.unloadBound = true; - const payload: BatchPayload = { messages: [...this.messages] }; - const body = JSON.stringify(payload); + this.pagehideHandler = () => this.flushUnload(); + this.visibilityHandler = () => { + if (document.visibilityState === 'hidden') this.flushUnload(); + }; + document.addEventListener('visibilitychange', this.visibilityHandler); + window.addEventListener('pagehide', this.pagehideHandler); + } - if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { - const blob = new Blob([body], { type: 'application/json' }); - const sent = navigator.sendBeacon(this.endpointUrl, blob); - if (sent) { - this.messages = []; - this.persist(); - return; - } + private removeUnload(): void { + if (!this.unloadBound) return; + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler); } - - // Fallback: trigger async flush (best-effort, may not complete before unload) - this.flush(); + if (this.pagehideHandler) { + window.removeEventListener('pagehide', this.pagehideHandler); + } + this.unloadBound = false; } private persist(): void { - storage.setItem(STORAGE_KEY, this.messages); + storage.setItem(STORAGE_KEY, this.messages, this.storagePrefix); } } diff --git a/packages/audience/core/src/storage.ts b/packages/audience/core/src/storage.ts index 8c2150074a..1b76f04f3e 100644 --- a/packages/audience/core/src/storage.ts +++ b/packages/audience/core/src/storage.ts @@ -1,6 +1,6 @@ import { isBrowser } from './utils'; -const PREFIX = '__imtbl_audience_'; +const DEFAULT_PREFIX = '__imtbl_audience_'; function hasLocalStorage(): boolean { try { @@ -10,29 +10,29 @@ function hasLocalStorage(): boolean { } } -export function getItem(key: string): unknown | undefined { +export function getItem(key: string, prefix = DEFAULT_PREFIX): unknown | undefined { if (!hasLocalStorage()) return undefined; try { - const raw = localStorage.getItem(`${PREFIX}${key}`); + const raw = localStorage.getItem(`${prefix}${key}`); return raw ? JSON.parse(raw) : undefined; } catch { return undefined; } } -export function setItem(key: string, value: unknown): void { +export function setItem(key: string, value: unknown, prefix = DEFAULT_PREFIX): void { if (!hasLocalStorage()) return; try { - localStorage.setItem(`${PREFIX}${key}`, JSON.stringify(value)); + localStorage.setItem(`${prefix}${key}`, JSON.stringify(value)); } catch { // Storage full or unavailable. } } -export function removeItem(key: string): void { +export function removeItem(key: string, prefix = DEFAULT_PREFIX): void { if (!hasLocalStorage()) return; try { - localStorage.removeItem(`${PREFIX}${key}`); + localStorage.removeItem(`${prefix}${key}`); } catch { // Ignore. } diff --git a/packages/audience/core/src/transport.test.ts b/packages/audience/core/src/transport.test.ts index c64d80caeb..22e9fd6324 100644 --- a/packages/audience/core/src/transport.test.ts +++ b/packages/audience/core/src/transport.test.ts @@ -36,9 +36,21 @@ describe('httpSend', () => { 'x-immutable-publishable-key': 'pk_imx_test', }, body: JSON.stringify(payload), + keepalive: undefined, }); }); + it('passes keepalive option when specified', async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + await httpSend('https://example.com', 'pk', payload, { keepalive: true }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ + keepalive: true, + })); + }); + it('returns true on success', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true }); expect(await httpSend('https://example.com', 'pk', payload)).toBe(true); diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index a352e9e4c1..65a99d9609 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -1,14 +1,19 @@ import { track, trackError } from '@imtbl/metrics'; import type { BatchPayload } from './types'; +export interface TransportOptions { + keepalive?: boolean; +} + export interface Transport { - send(url: string, publishableKey: string, payload: BatchPayload): Promise; + send(url: string, publishableKey: string, payload: BatchPayload, options?: TransportOptions): Promise; } export async function httpSend( url: string, publishableKey: string, payload: BatchPayload, + options?: TransportOptions, ): Promise { try { const response = await fetch(url, { @@ -18,6 +23,7 @@ export async function httpSend( 'x-immutable-publishable-key': publishableKey, }, body: JSON.stringify(payload), + keepalive: options?.keepalive, }); if (!response.ok) { diff --git a/packages/audience/core/src/types.ts b/packages/audience/core/src/types.ts index 277ed533da..6bc7e26b7c 100644 --- a/packages/audience/core/src/types.ts +++ b/packages/audience/core/src/types.ts @@ -77,3 +77,23 @@ export type Message = export interface BatchPayload { messages: Message[]; } + +/** + * The consent level a studio sets via setConsent(). + * + * - `'none'` — No tracking. SDK does nothing. + * - `'anonymous'` — Track activity but not who the user is. + * - `'full'` — Track everything including user identity. + */ +export type ConsentLevel = 'none' | 'anonymous' | 'full'; + +/** + * The consent status the backend stores and returns. + * Includes `'not_set'` for users who haven't been asked yet. + * + * - `'not_set'` — No consent decision recorded yet. + * - `'none'` — User declined tracking. + * - `'anonymous'` — User accepted anonymous tracking. + * - `'full'` — User accepted full tracking. + */ +export type ConsentStatus = 'not_set' | 'none' | 'anonymous' | 'full'; diff --git a/packages/audience/core/src/utils.ts b/packages/audience/core/src/utils.ts index 701b9bf61c..2951eb4695 100644 --- a/packages/audience/core/src/utils.ts +++ b/packages/audience/core/src/utils.ts @@ -4,7 +4,11 @@ export const generateId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } - return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.trunc(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }); }; export const getTimestamp = (): string => new Date().toISOString(); diff --git a/packages/audience/core/src/validation.test.ts b/packages/audience/core/src/validation.test.ts new file mode 100644 index 0000000000..9564acdaad --- /dev/null +++ b/packages/audience/core/src/validation.test.ts @@ -0,0 +1,76 @@ +import { + isTimestampValid, + isAliasValid, + truncate, + truncateSource, +} from './validation'; + +describe('isTimestampValid', () => { + it('accepts a current timestamp', () => { + expect(isTimestampValid(new Date().toISOString())).toBe(true); + }); + + it('accepts a timestamp 23 hours in the future', () => { + const future = new Date(Date.now() + 23 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(future)).toBe(true); + }); + + it('rejects a timestamp 25 hours in the future', () => { + const future = new Date(Date.now() + 25 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(future)).toBe(false); + }); + + it('accepts a timestamp 29 days in the past', () => { + const past = new Date(Date.now() - 29 * 24 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(past)).toBe(true); + }); + + it('rejects a timestamp 31 days in the past', () => { + const past = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(past)).toBe(false); + }); + + it('rejects an invalid date string', () => { + expect(isTimestampValid('not-a-date')).toBe(false); + }); +}); + +describe('isAliasValid', () => { + it('returns true when from and to differ', () => { + expect(isAliasValid('steam_123', 'steam', 'user@example.com', 'email')).toBe(true); + }); + + it('returns true when same ID but different type', () => { + expect(isAliasValid('123', 'steam', '123', 'email')).toBe(true); + }); + + it('returns false when from and to are identical', () => { + expect(isAliasValid('user@example.com', 'email', 'user@example.com', 'email')).toBe(false); + }); +}); + +describe('truncate', () => { + it('returns the original string when within the limit', () => { + expect(truncate('hello', 256)).toBe('hello'); + }); + + it('truncates to the default max length of 256', () => { + const long = 'x'.repeat(300); + expect(truncate(long)).toHaveLength(256); + }); + + it('truncates to a custom max length', () => { + expect(truncate('hello world', 5)).toBe('hello'); + }); +}); + +describe('truncateSource', () => { + it('truncates to the consent source max length of 128', () => { + const long = 'x'.repeat(200); + expect(truncateSource(long)).toHaveLength(128); + }); + + it('returns the original when within 128 chars', () => { + expect(truncateSource('CookieBannerV2')).toBe('CookieBannerV2'); + }); +}); diff --git a/packages/audience/core/src/validation.ts b/packages/audience/core/src/validation.ts new file mode 100644 index 0000000000..3f85d67e99 --- /dev/null +++ b/packages/audience/core/src/validation.ts @@ -0,0 +1,41 @@ +const MAX_FUTURE_MS = 24 * 60 * 60 * 1000; // 24 hours +const MAX_PAST_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Backend maxLength constraints from OAS +const MAX_STRING_LENGTH = 256; // anonymousId, eventName, userId, fromId, toId +const MAX_SOURCE_LENGTH = 128; // consent source + +/** + * Validate that an event timestamp is within the backend's accepted range: + * no more than 24 hours in the future, no more than 30 days in the past. + */ +export function isTimestampValid(eventTimestamp: string): boolean { + const ts = new Date(eventTimestamp).getTime(); + if (Number.isNaN(ts)) return false; + const now = Date.now(); + return ts <= now + MAX_FUTURE_MS && ts >= now - MAX_PAST_MS; +} + +/** + * Validate that alias from and to are not the same identity. + */ +export function isAliasValid( + fromId: string, + fromType: string, + toId: string, + toType: string, +): boolean { + return fromId !== toId || fromType !== toType; +} + +/** + * Truncate a string to the backend's max length for the given field. + * Returns the original string if within limits. + */ +export function truncate(value: string, maxLength = MAX_STRING_LENGTH): string { + return value.length > maxLength ? value.slice(0, maxLength) : value; +} + +export function truncateSource(value: string): string { + return truncate(value, MAX_SOURCE_LENGTH); +}