From 804eee4d5d27700a79131928dbaf37e9a5357ac8 Mon Sep 17 00:00:00 2001 From: atroci Date: Wed, 16 Sep 2026 23:28:50 +0100 Subject: [PATCH 1/2] fix: complete attribution handoff contracts --- docs/TUTORIALS.md | 33 ++ examples/astro-demo/README.md | 12 +- integrations/astro/README.md | 37 ++- integrations/astro/src/client.ts | 46 ++- integrations/astro/src/config.ts | 20 ++ integrations/astro/src/index.ts | 52 ++- integrations/astro/src/server.ts | 99 +++++- integrations/astro/tests/client-boot.test.ts | 16 + .../astro/tests/integration-factory.test.ts | 22 ++ .../astro/tests/server-helpers.test.ts | 51 ++- packages/browser/src/browser/index.ts | 19 ++ packages/browser/src/browser/serialize.ts | 8 +- .../browser/tests/browser-serialize.test.ts | 11 + packages/server/README.md | 14 +- packages/server/src/server.ts | 75 ++++- packages/server/src/tenant-adapter.ts | 59 +++- .../tests/fixtures/supletivo-handoff.json | 47 +++ packages/server/tests/server.test.ts | 87 ++++- .../server/tests/supletivo-handoff.test.ts | 314 ++++++++++++++++++ packages/server/tests/tenant-adapter.test.ts | 29 ++ 20 files changed, 1019 insertions(+), 32 deletions(-) create mode 100644 packages/server/tests/fixtures/supletivo-handoff.json create mode 100644 packages/server/tests/supletivo-handoff.test.ts diff --git a/docs/TUTORIALS.md b/docs/TUTORIALS.md index 022d452..c40f0f6 100644 --- a/docs/TUTORIALS.md +++ b/docs/TUTORIALS.md @@ -121,6 +121,39 @@ Separate origins need shared provisioning or explicit matching `sign` and `verify` functions. Test an approved destination and an unapproved destination before enabling the path. +## Astro -> Django/OTP/Pix handoff recipe + +Use the synthetic fixture at +[`packages/server/tests/fixtures/supletivo-handoff.json`](../packages/server/tests/fixtures/supletivo-handoff.json) +and its replay test at +[`packages/server/tests/supletivo-handoff.test.ts`](../packages/server/tests/supletivo-handoff.test.ts) +as the source-level reference. It uses no live client, CRM, payment, Meta, or +WhatsApp credentials. + +The ownership boundary is: + +| Owner | Fields and responsibility | +|---|---| +| Browser / ClickTrail | Consent decision, UTM and click IDs, first-party session IDs, and best-effort `ft_*` / `lt_*` payload. | +| Host application | Allowlisted opaque `ref`, opaque host-owned `attribution_id`, immutable first-touch snapshot, OTP identity association, lead record, and invoice record. | +| Server / provider authority | Canonical `event_id`, `occurred_at`, `lead_id`, `order_id`, signed Pix webhook verification, invoice amount/currency, and duplicate-delivery handling. | + +At lead creation, validate `ref` in host code and create the immutable +snapshot. Keep `attribution_id` in the host database and, only when useful for +correlation, in `properties.attribution_id`; it is not a ClickTrail field. +After OTP, carry the correlation key through the host session. At checkout, +load the price and currency from the host invoice record, not from the browser +or webhook body. For a delayed Pix event, verify the provider signature, +resolve the stored invoice, compare amount/currency, and reuse the provider +idempotency key as `eventId`. A repeated webhook must be a no-op. + +The Astro wrapper accepts `storage.cookieDomain` and +`crossDomain.domains` for approved sibling subdomains. The default continuation +token is readable and integrity-protected, not confidential. Never put PII, +host IDs, prices, or commission data in it. If the host cannot provision a +shared parent-domain cookie, use the stable browser API with explicit matching +sign/verify functions or disable the cross-domain path. + ## Related package docs - [package README](../packages/clicktrail/README.md) diff --git a/examples/astro-demo/README.md b/examples/astro-demo/README.md index aa2ad2d..ed71796 100644 --- a/examples/astro-demo/README.md +++ b/examples/astro-demo/README.md @@ -51,6 +51,9 @@ export default defineConfig({ | `proxy: false` | — | Disable the first-party proxy entirely. | | `consentRequired` | `false` | With `true`, nothing starts or persists until consent is granted (see below). | | `debug` | `false` | Verbose diagnostics during setup. | +| `storage.cookieDomain` | — | Shared parent-domain cookie scope for approved sibling-subdomain handoff. | +| `storage.retentionDays` | `90` | Expiry of the local mirror (1-400 days). | +| `crossDomain.domains` | — | Exact approved domain suffixes for continuation-token decoration. | ## 4. Consent gating @@ -79,7 +82,14 @@ export const POST: APIRoute = async ({ request }) => { const identity = parseIdentityFromCookies(request.headers.get('cookie')); await server.trackPurchase({ identity, - data: { transactionId: 't-1234', value: 49.9, currency: 'EUR' }, + // Reuse this provider/invoice key for webhook retries. + eventId: 'pix-event-1234', + data: { + transactionId: 't-1234', + orderId: 'invoice-1234', + value: 49.9, + currency: 'EUR', + }, }); return new Response(null, { status: 204 }); }; diff --git a/integrations/astro/README.md b/integrations/astro/README.md index 89c9a17..e7b28d9 100644 --- a/integrations/astro/README.md +++ b/integrations/astro/README.md @@ -70,6 +70,15 @@ interface ClickTrailAstroOptions { | false; consentRequired?: boolean; // default false debug?: boolean; // default false + storage?: { + cookieDomain?: string; // shared parent domain for sibling handoff + retentionDays?: number; // 1-400; default 90 + }; + crossDomain?: false | { + domains: readonly string[]; + tokenParam?: string; // default 'ct_token' + skipSignedUrls?: boolean; // default true + }; } ``` @@ -84,6 +93,23 @@ interface ClickTrailAstroOptions { Denying (`__clicktrailSetConsent(false)`) before grant leaves no persisted state; the SDK's denial path also wipes attribution storage. +## Approved sibling-subdomain handoff + +Set a shared parent-domain cookie and an explicit approved-domain list. The +Astro wrapper uses the browser SDK's readable HMAC token with the shared +`ct_signing_key`; the token carries attribution context, not PII, prices, or +host records. Do not include a partner `ref` in the ClickTrail payload. Parse +and validate that host-owned opaque value in the landing/app code instead. + +```js +clicktrail({ + consentRequired: true, + storage: { cookieDomain: '.example.com' }, + crossDomain: { domains: ['example.com'] }, + proxy: { upstream: 'https://collector.example.com/v1/events' }, +}); +``` + ## Server-side conversions In an API route or action handler: @@ -100,13 +126,20 @@ export const POST: APIRoute = async ({ request }) => { const identity = parseIdentityFromCookies(request.headers.get('cookie')); await server.trackPurchase({ identity, - data: { transactionId: 't-1234', value: 49.9, currency: 'EUR' }, + // Reuse the provider/invoice key for every retry of this sale. + eventId: 'pix-event-1234', + data: { + transactionId: 't-1234', + orderId: 'invoice-1234', + value: 49.9, + currency: 'EUR', + }, }); return new Response(null, { status: 204 }); }; ``` -`trackPurchase` validates that `transactionId`, `value`, and `currency` are present and well-formed before sending. Delivery failures resolve to `{ ok: false }` instead of throwing so an analytics outage never breaks checkout. +`trackPurchase` validates that `transactionId`, `value`, and `currency` are present and well-formed before sending. `orderId` maps to canonical `order_id`; `leadId` and `bookingId` map to `lead_id` and `booking_id`. `eventId` is normalized and reused across retries; when it is omitted, the helper derives an ID from the conversion key or occurrence timestamp, so hosts must pass the provider's stable key for webhook retries. The canonical wire timestamp is `occurred_at`. Delivery failures resolve to `{ ok: false }` instead of throwing so an analytics outage never breaks checkout. ## Related packages diff --git a/integrations/astro/src/client.ts b/integrations/astro/src/client.ts index bb772a4..78eee3b 100644 --- a/integrations/astro/src/client.ts +++ b/integrations/astro/src/client.ts @@ -106,16 +106,36 @@ export function bootClickTrailClient( httpDestination({ endpoint: config.endpoint }), ]; + const storage: ClickTrailConfig['storage'] = { + ...(config.storage?.cookieDomain !== undefined + ? { cookieAttrs: { domain: config.storage.cookieDomain } } + : {}), + ...(config.storage?.retentionDays !== undefined + ? { retentionDays: config.storage.retentionDays } + : {}), + }; + const crossDomain: ClickTrailConfig['crossDomain'] = config.crossDomain && { + domains: config.crossDomain.domains, + ...(config.crossDomain.tokenParam !== undefined + ? { tokenParam: config.crossDomain.tokenParam } + : {}), + ...(config.crossDomain.skipSignedUrls !== undefined + ? { skipSignedUrls: config.crossDomain.skipSignedUrls } + : {}), + }; + const instance = createClickTrail({ destinations, ...(config.siteId !== undefined ? { siteId: config.siteId } : {}), ...(config.workspaceId !== undefined ? { workspaceId: config.workspaceId } : {}), consentGate: () => !config.consentRequired || readStoredConsent(resolved.storageLike) === true, - storage: {}, + storage, + ...(crossDomain ? { crossDomain } : {}), }); - // Touches merge in memory pre-start; nothing persists before consent. - const detachPageViews = attachPageViewTracking(instance, resolved.navigationSeam, resolved.eventTarget.addEventListener, resolved.eventTarget.removeEventListener); + // Attach page views only after the SDK starts. This keeps the initial URL + // touch from being discarded by start() hydration when consent is deferred. + let detachPageViews: () => void = () => {}; let started = false; let resolveStart!: () => void; @@ -127,7 +147,15 @@ export function bootClickTrailClient( }); const startNow = (): void => { - if (!instance.isStarted()) instance.start(); + if (!instance.isStarted()) { + instance.start(); + detachPageViews = attachPageViewTracking( + instance, + resolved.navigationSeam, + resolved.eventTarget.addEventListener, + resolved.eventTarget.removeEventListener, + ); + } resolveStart(); }; @@ -136,6 +164,8 @@ export function bootClickTrailClient( const state = readStoredConsent(resolved.storageLike); if (state === true) startNow(); else if (state === false) { + detachPageViews(); + detachPageViews = () => {}; instance.clearData(); if (instance.isStarted()) instance.stop(); } @@ -150,7 +180,13 @@ export function bootClickTrailClient( console.info(`[clicktrail] endpoint=${config.endpoint} started=${started}`); } - return { instance, whenStarted: () => whenStarted, detachPageViews }; + return { + instance, + whenStarted: () => whenStarted, + // Resolve the current lifecycle-bound disposer so deferred consent and + // later re-grants remain detachable through the public handle. + detachPageViews: () => detachPageViews(), + }; } /** Entry point invoked by the injected page script. */ diff --git a/integrations/astro/src/config.ts b/integrations/astro/src/config.ts index 5d1c6f4..6427ab4 100644 --- a/integrations/astro/src/config.ts +++ b/integrations/astro/src/config.ts @@ -21,6 +21,22 @@ export const DEFAULT_PROXY_PATTERN = '/api/clicktrail'; /** Default browser event endpoint (the injected proxy route). */ export const DEFAULT_ENDPOINT = DEFAULT_PROXY_PATTERN; +export interface ClickTrailClientStorageConfig { + /** Parent-domain cookie scope used for approved sibling-subdomain handoff. */ + cookieDomain?: string; + /** Client-side mirror retention in whole days (1-400). */ + retentionDays?: number; +} + +export interface ClickTrailClientCrossDomainConfig { + /** Approved target domains. Tokens are never decorated for other hosts. */ + domains: readonly string[]; + /** Continuation-token URL parameter. Default `ct_token`. */ + tokenParam?: string; + /** Skip URLs that already carry a continuation token. Default true. */ + skipSignedUrls?: boolean; +} + export interface ClickTrailClientConfig { /** Event delivery endpoint. Default `/api/clicktrail`. */ endpoint: string; @@ -28,6 +44,10 @@ export interface ClickTrailClientConfig { workspaceId?: string; consentRequired: boolean; debug: boolean; + /** Serializable subset of browser storage settings. */ + storage?: ClickTrailClientStorageConfig; + /** Serializable cross-domain settings; signing uses the shared cookie key. */ + crossDomain?: ClickTrailClientCrossDomainConfig | false; } export interface ClickTrailProxyConfig { diff --git a/integrations/astro/src/index.ts b/integrations/astro/src/index.ts index 3ea0d47..5ea6c76 100644 --- a/integrations/astro/src/index.ts +++ b/integrations/astro/src/index.ts @@ -18,7 +18,12 @@ import { defaultClientConfig, defaultProxyConfig, } from './config.js'; -import type { ClickTrailClientConfig, ClickTrailProxyConfig } from './config.js'; +import type { + ClickTrailClientConfig, + ClickTrailClientCrossDomainConfig, + ClickTrailClientStorageConfig, + ClickTrailProxyConfig, +} from './config.js'; export interface ClickTrailAstroOptions { /** Site identifier copied into normalized marketing trail envelopes. */ @@ -53,6 +58,16 @@ export interface ClickTrailAstroOptions { consentRequired?: boolean; /** Log boot diagnostics to console. Default false. */ debug?: boolean; + /** + * Serializable browser storage settings. Set `cookieDomain` to the shared + * parent domain when continuity must cross approved sibling subdomains. + */ + storage?: ClickTrailClientStorageConfig; + /** + * Approved sibling-subdomain continuation. Astro uses the shared cookie + * signing key; configure `storage.cookieDomain` for cross-origin handoff. + */ + crossDomain?: ClickTrailClientCrossDomainConfig | false; } function isAbsoluteEndpoint(endpoint: string): boolean { @@ -60,12 +75,47 @@ function isAbsoluteEndpoint(endpoint: string): boolean { } export function clicktrailAstro(options: ClickTrailAstroOptions = {}): AstroIntegration { + if (options.storage?.cookieDomain !== undefined) { + if ( + typeof options.storage.cookieDomain !== 'string' || + options.storage.cookieDomain.trim() === '' || + options.storage.cookieDomain.includes('/') || + options.storage.cookieDomain.includes('@') + ) { + throw new TypeError('clicktrailAstro: storage.cookieDomain must be a host name.'); + } + } + if ( + options.storage?.retentionDays !== undefined && + (!Number.isSafeInteger(options.storage.retentionDays) || + options.storage.retentionDays < 1 || + options.storage.retentionDays > 400) + ) { + throw new RangeError('clicktrailAstro: storage.retentionDays must be an integer from 1 through 400.'); + } + if (options.crossDomain && ( + !Array.isArray(options.crossDomain.domains) || + options.crossDomain.domains.length === 0 || + options.crossDomain.domains.some( + (domain) => typeof domain !== 'string' || domain.trim() === '' || domain.includes('/') || domain.includes('@'), + ) + )) { + throw new TypeError('clicktrailAstro: crossDomain.domains must contain host names.'); + } + if (options.crossDomain && !options.storage?.cookieDomain) { + throw new TypeError( + 'clicktrailAstro: crossDomain requires storage.cookieDomain so approved sibling subdomains share the signing key.', + ); + } + const clientCfg: ClickTrailClientConfig = defaultClientConfig({ endpoint: options.endpoint ?? DEFAULT_ENDPOINT, ...(options.siteId !== undefined ? { siteId: options.siteId } : {}), ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), ...(options.consentRequired !== undefined ? { consentRequired: options.consentRequired } : {}), ...(options.debug !== undefined ? { debug: options.debug } : {}), + ...(options.storage !== undefined ? { storage: options.storage } : {}), + ...(options.crossDomain !== undefined ? { crossDomain: options.crossDomain } : {}), }); const wantsRoute = diff --git a/integrations/astro/src/server.ts b/integrations/astro/src/server.ts index cb2cdfc..a6a0dfb 100644 --- a/integrations/astro/src/server.ts +++ b/integrations/astro/src/server.ts @@ -13,6 +13,7 @@ import { withDeliveryTimeout } from '@vizuh/clicktrail/browser'; import { buildEventPayload, + filterServerAttributionPayload, parseCookieMap, sanitizeServerEventInput, } from '@vizuh/clicktrail/browser'; @@ -25,7 +26,11 @@ import { SESSION_STATE_KEY, VISITOR_ID_FALLBACK_KEY, } from '@vizuh/clicktrail/browser'; -import { isSafeHttpUrl, toCanonicalEventName } from '@vizuh/clicktrail-core'; +import { + deriveStableEventId, + isSafeHttpUrl, + toCanonicalEventName, +} from '@vizuh/clicktrail-core'; export interface ServerIdentity { /** Canonical flat attribution payload from the `attribution` cookie. */ @@ -54,7 +59,7 @@ export function parseIdentityFromCookies(cookieHeader: string | null | undefined const payloadRaw = cookies[ATTRIBUTION_KEY] ?? cookies[LEGACY_ATTRIBUTION_KEY]; const payload = typeof payloadRaw === 'string' && payloadRaw - ? ((safeJsonParse(payloadRaw) as AttributionPayload | null) ?? {}) + ? filterServerAttributionPayload(safeJsonParse(payloadRaw)) : {}; let visitorId: string | undefined; @@ -98,8 +103,10 @@ export interface ClickTrailServerConfig { export interface ConversionInput> { identity: ServerIdentity; data?: D; - /** ISO-8601 event time override. Default: current wall clock. */ + /** ISO-8601 occurrence time override. Defaults to the current wall clock. */ now?: string; + /** Stable caller-owned key reused when the same logical event is retried. */ + eventId?: string; } export interface LeadData extends Record { @@ -114,6 +121,8 @@ export interface BookingData extends Record { } export interface PurchaseData extends Record { transactionId: string; + /** Optional canonical alias; takes precedence over transactionId when set. */ + orderId?: string; value: number; currency: string; } @@ -130,6 +139,61 @@ function requireNonEmptyString(value: unknown, field: string): string { return value; } +function normalizeEventId(value: unknown): string { + const eventId = requireNonEmptyString(value, 'eventId'); + return eventId.startsWith('evt_') ? eventId : `evt_${eventId}`; +} + +function optionalText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +const CONVERSION_RESERVED_KEYS = [ + 'lead_id', + 'booking_id', + 'order_id', + 'form_id', + 'event_time', + 'occurred_at', + 'formId', + 'leadId', + 'bookingId', + 'transactionId', + 'orderId', + 'startDate', +] as const; + +const SAFE_CONVERSION_DATA_KEYS = new Set([ + 'value', + 'currency', + 'landing_url', + 'referrer', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gclid', + 'gbraid', + 'wbraid', + 'fbclid', + 'msclkid', + 'consent', + 'consent_state', + 'consent_source', + 'consent_version', + 'form_provider', + 'properties', +]); + +function sanitizeConversionData(input: Record): Record { + const sanitized = sanitizeServerEventInput(input); + for (const key of CONVERSION_RESERVED_KEYS) delete sanitized[key]; + return Object.fromEntries( + Object.entries(sanitized).filter(([key]) => SAFE_CONVERSION_DATA_KEYS.has(key)), + ); +} + function requireSafeHttpUrl(value: unknown, field: string): string { const endpoint = requireNonEmptyString(value, field); if (!isSafeHttpUrl(endpoint)) { @@ -180,10 +244,33 @@ export class ClickTrailServer { input: ConversionInput>, ): ClickTrailEvent { const payload = sanitizeServerEventInput(input.identity.payload ?? {}); - const data = sanitizeServerEventInput(input.data ?? {}); - return buildEventPayload(payload, toCanonicalEventName(eventName), { + const inputData = input.data ?? {}; + const data = sanitizeConversionData(inputData); + const canonicalName = toCanonicalEventName(eventName); + const occurredAt = input.now === undefined + ? new Date().toISOString() + : requireNonEmptyString(input.now, 'now'); + const formId = optionalText(inputData['formId']); + const leadId = optionalText(inputData['leadId']); + const bookingId = optionalText(inputData['bookingId']); + const orderId = optionalText(inputData['orderId']) ?? optionalText(inputData['transactionId']); + const startDate = optionalText(inputData['startDate']); + const businessKey = orderId ?? bookingId ?? leadId ?? occurredAt; + const eventId = input.eventId === undefined + ? deriveStableEventId( + this.siteId ?? 'unspecified-site', + JSON.stringify([canonicalName, businessKey]), + ) + : normalizeEventId(input.eventId); + return buildEventPayload(payload, canonicalName, { ...data, - ...(input.now !== undefined ? { event_time: input.now } : {}), + ...(formId !== undefined ? { form_id: formId } : {}), + ...(leadId !== undefined ? { lead_id: leadId } : {}), + ...(bookingId !== undefined ? { booking_id: bookingId } : {}), + ...(orderId !== undefined ? { order_id: orderId } : {}), + ...(startDate !== undefined ? { start_date: startDate } : {}), + event_id: eventId, + occurred_at: occurredAt, ...(this.siteId !== undefined ? { site_id: this.siteId } : {}), ...(this.workspaceId !== undefined ? { workspace_id: this.workspaceId } : {}), ...(input.identity.visitorId ? { visitor_id: input.identity.visitorId } : {}), diff --git a/integrations/astro/tests/client-boot.test.ts b/integrations/astro/tests/client-boot.test.ts index 43ed5f2..63fe082 100644 --- a/integrations/astro/tests/client-boot.test.ts +++ b/integrations/astro/tests/client-boot.test.ts @@ -88,6 +88,22 @@ describe('bootClickTrailClient', () => { expect(booted.instance.getField('ft_source')).toBe(''); }); + it('recaptures the initial touch when consent is granted after boot', async () => { + const seams = makeSeams('https://example.com/?utm_source=google&utm_medium=cpc&gclid=G-1'); + const booted = bootClickTrailClient( + defaultClientConfig({ endpoint: '/api/ct', consentRequired: true }), + seams, + ); + + expect(booted.instance.getField('ft_source')).toBe(''); + setConsent(true, seams); + await booted.whenStarted(); + + expect(booted.instance.getField('ft_source')).toBe('google'); + expect(booted.instance.getField('ft_medium')).toBe('cpc'); + expect(booted.instance.getField('gclid')).toBe('G-1'); + }); + it('a denied flag never starts tracking', async () => { const seams = makeSeams('https://example.com/'); bootClickTrailClient( diff --git a/integrations/astro/tests/integration-factory.test.ts b/integrations/astro/tests/integration-factory.test.ts index 48fbfe9..f62af5b 100644 --- a/integrations/astro/tests/integration-factory.test.ts +++ b/integrations/astro/tests/integration-factory.test.ts @@ -39,6 +39,21 @@ describe('clicktrailAstro', () => { expect(typeof define[PROXY_CONFIG_GLOBAL]).toBe('string'); }); + it('serializes shared cookie and approved-domain handoff settings', () => { + const { updateConfig } = runSetup( + clicktrailAstro({ + storage: { cookieDomain: '.example.com', retentionDays: 30 }, + crossDomain: { domains: ['example.com'], tokenParam: 'ct' }, + proxy: false, + }), + ); + const define = updateConfig.mock.calls[0]![0].vite!.define!; + const clientCfg = JSON.parse(define[CLIENT_CONFIG_GLOBAL]!); + + expect(clientCfg.storage).toEqual({ cookieDomain: '.example.com', retentionDays: 30 }); + expect(clientCfg.crossDomain).toEqual({ domains: ['example.com'], tokenParam: 'ct' }); + }); + it('injects the default proxy route when upstream is given', () => { const { injectRoute } = runSetup( clicktrailAstro({ proxy: { upstream: 'https://collector.example.com/v1/events' } }), @@ -69,6 +84,13 @@ describe('clicktrailAstro', () => { expect(injectRoute).not.toHaveBeenCalled(); }); + it('requires a shared cookie domain for cross-domain handoff', () => { + expect(() => clicktrailAstro({ + crossDomain: { domains: ['example.com'] }, + proxy: false, + })).toThrow(/storage\.cookieDomain/); + }); + it('throws a TypeError when the default proxy lacks an upstream', () => { expect(() => clicktrailAstro()).toThrow(TypeError); expect(() => clicktrailAstro()).toThrow(/proxy\.upstream/); diff --git a/integrations/astro/tests/server-helpers.test.ts b/integrations/astro/tests/server-helpers.test.ts index a90d634..111460e 100644 --- a/integrations/astro/tests/server-helpers.test.ts +++ b/integrations/astro/tests/server-helpers.test.ts @@ -25,6 +25,18 @@ describe('parseIdentityFromCookies', () => { expect(id.sessionNumber).toBe(2); }); + it('filters arbitrary and identity fields from attribution cookies', () => { + const raw = encodeURIComponent(JSON.stringify({ + ft_source: 'google', + email: 'victim@example.com', + visitor_id: 'forged-visitor', + arbitrary: 'secret', + })); + const id = parseIdentityFromCookies(`attribution=${raw}`); + expect(id.payload).toEqual({ ft_source: 'google' }); + expect(id.visitorId).toBeUndefined(); + }); + it('falls back to lightweight visitor/session cookies', () => { const id = parseIdentityFromCookies('ct_visitor_id=v-f; ct_session_id=s-f'); expect(id.visitorId).toBe('v-f'); @@ -57,7 +69,8 @@ describe('ClickTrailServer', () => { const server = makeServer(fetchMock); const result = await server.trackLead({ identity: parseIdentityFromCookies(`${ATTRIBUTION_COOKIE}; ${SESSION_COOKIE}`), - data: { formId: 'contact' }, + eventId: 'lead-provider-1', + data: { formId: 'contact', leadId: 'lead_42', email: 'must-not-be-forwarded' }, now: '2026-08-24T10:00:00.000Z', }); expect(result).toEqual({ ok: true, status: 204 }); @@ -70,13 +83,45 @@ describe('ClickTrailServer', () => { expect(event['schema_version']).toBeTypeOf('string'); expect(event['classifier_version']).toBeTypeOf('string'); expect(event['ft_source']).toBe('google'); - expect(event['formId']).toBe('contact'); + expect(event['form_id']).toBe('contact'); + expect(event['formId']).toBeUndefined(); + expect(event['email']).toBeUndefined(); + expect(event['lead_id']).toBe('lead_42'); + expect(event['event_id']).toBe('evt_lead-provider-1'); + expect(event['occurred_at']).toBe('2026-08-24T10:00:00.000Z'); + expect(event['event_time']).toBeUndefined(); expect(event['visitor_id']).toBe('v-1'); expect(event['session_id']).toBe('s-9'); expect(event['session_number']).toBe('2'); expect(event['site_id']).toBe('s1'); expect(event['workspace_id']).toBe('w1'); - expect(event['marketing_trail']).toBeTruthy(); + expect(event['marketing_trail']).toMatchObject({ + event_id: 'evt_lead-provider-1', + lead_id: 'lead_42', + occurred_at: '2026-08-24T10:00:00.000Z', + }); + }); + + it('maps purchase transactionId to canonical order_id', async () => { + const fetchMock = okFetch(); + const server = makeServer(fetchMock); + await server.trackPurchase({ + identity: { payload: {} }, + eventId: 'pix-provider-1', + data: { transactionId: 'invoice-42', value: 49.9, currency: 'EUR' }, + now: '2026-08-24T10:05:00.000Z', + }); + + const [, init] = fetchMock.mock.calls[0]! as unknown as [string, RequestInit]; + const event = (JSON.parse(String(init.body)) as { events: Array> }).events[0]!; + expect(event['event_name']).toBe('sale'); + expect(event['order_id']).toBe('invoice-42'); + expect(event['transactionId']).toBeUndefined(); + expect(event['occurred_at']).toBe('2026-08-24T10:05:00.000Z'); + expect(event['marketing_trail']).toMatchObject({ + event_id: 'evt_pix-provider-1', + occurred_at: '2026-08-24T10:05:00.000Z', + }); }); it('trackPurchase validates transaction fields before sending', async () => { diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index d0d921d..745e81e 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -39,6 +39,7 @@ export { export { createClickTrail } from './create-clicktrail.js'; export type { ClickTrailConfig, + ClickTrailCrossDomainConfig, ClickTrailInstance, ClickTrailStorageConfig, DiagnosticsLevel, @@ -84,4 +85,22 @@ export type { StorageAdapter, } from './storage.js'; +export { + buildReferralTouch, + decodeContinuationToken, + encodeContinuationToken, + isApprovedHost, + DEFAULT_TOKEN_PARAM, + MAX_TOKEN_LENGTH, + TOKEN_TTL_MS, +} from './link-decoration.js'; +export type { + ContinuationPayload, + ConsumeResult, + LandingConsumeOutcome, + LocationHistorySeam, + SignFn, + VerifyFn, +} from './link-decoration.js'; + export { withDeliveryTimeout } from './delivery-timeout.js'; diff --git a/packages/browser/src/browser/serialize.ts b/packages/browser/src/browser/serialize.ts index bf0dd94..2ff579d 100644 --- a/packages/browser/src/browser/serialize.ts +++ b/packages/browser/src/browser/serialize.ts @@ -142,7 +142,13 @@ export function buildMarketingTrailEnvelope( // The top-level event identity and name are authoritative. Nested values // are legacy/additive input and must not fork canonical envelope fields. const eventId = prefixed(firstText(data['event_id']), 'evt_'); - const leadEvent = ['lead', 'lead.submitted', 'lead_submitted', 'form_submission'].includes(eventName); + const leadEvent = [ + 'lead', + 'lead.submitted', + 'lead_submitted', + 'lead_created', + 'form_submission', + ].includes(eventName); const leadId = prefixed( firstText(data['lead_id'], leadEvent ? eventId.replace(/^evt_/, '') : ''), 'lead_', diff --git a/packages/browser/tests/browser-serialize.test.ts b/packages/browser/tests/browser-serialize.test.ts index 01187fb..b150e5b 100644 --- a/packages/browser/tests/browser-serialize.test.ts +++ b/packages/browser/tests/browser-serialize.test.ts @@ -84,6 +84,17 @@ describe('buildEventPayload', () => { expect(event.marketing_trail.workspace_id).toBe('trusted-workspace'); }); + it('derives a lead ID for the canonical lead_created event', () => { + const event = buildEventPayload( + emptyAttribution(), + 'lead_created', + { event_id: 'evt_lead-1' }, + ); + + expect(event.marketing_trail.event_name).toBe('lead_created'); + expect(event.marketing_trail.lead_id).toBe('lead_lead-1'); + }); + it('builds the normalized marketing trail envelope', () => { const event = buildEventPayload( { diff --git a/packages/server/README.md b/packages/server/README.md index 3f01537..6e92a80 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -29,7 +29,9 @@ const clickTrail = new ClickTrailServer({ const result = await clickTrail.trackLead({ identity, - data: { formId: 'contact' }, + // Use a stable CRM/provider key when the host may retry this event. + eventId: 'crm-lead-123', + data: { formId: 'contact', leadId: 'lead_123' }, }); if (!result.ok) { @@ -69,8 +71,14 @@ deduplicate repeated `event_id` values; the adapter does not hide a second browser, WordPress, or webhook capture path. The host owns authentication, retry policy, consent policy, and endpoint -availability. Validate conversion fields before calling the helper and never -send customer content as attribution metadata. +availability. `formId`, `leadId`, `bookingId`, and `transactionId` are input +aliases; the wire builder maps them to `form_id`, `lead_id`, `booking_id`, and +`order_id` and emits `occurred_at`. Unknown top-level adapter data is dropped; +put provider-specific non-PII values under `properties`. Validate conversion +fields before calling the helper and never send customer content as attribution +metadata. For retryable webhooks, always pass the stable provider key as +`eventId`; when it is omitted, the helper derives an ID from the conversion key +or occurrence timestamp, which is not a retry guarantee if the timestamp changes. ## License diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index bbed33a..e97d22a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -89,7 +89,9 @@ export interface ClickTrailServerConfig { export interface ConversionInput> { identity: ServerIdentity; data?: D; + /** ISO-8601 occurrence time override. Defaults to the current wall clock. */ now?: string; + /** Stable caller-owned key reused when the same logical event is retried. */ eventId?: string; } @@ -105,6 +107,8 @@ export interface BookingData extends Record { } export interface PurchaseData extends Record { transactionId: string; + /** Optional canonical alias; takes precedence over transactionId when set. */ + orderId?: string; value: number; currency: string; } @@ -161,6 +165,56 @@ function resolveEventId( return deriveStableEventId(siteId ?? 'clicktrail', stableConversionKey(eventName, input)); } +function optionalText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +const CONVERSION_RESERVED_KEYS = [ + 'lead_id', + 'booking_id', + 'order_id', + 'form_id', + 'event_time', + 'occurred_at', + 'formId', + 'leadId', + 'bookingId', + 'transactionId', + 'orderId', + 'startDate', +] as const; + +const SAFE_CONVERSION_DATA_KEYS = new Set([ + 'value', + 'currency', + 'landing_url', + 'referrer', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gclid', + 'gbraid', + 'wbraid', + 'fbclid', + 'msclkid', + 'consent', + 'consent_state', + 'consent_source', + 'consent_version', + 'form_provider', + 'properties', +]); + +function sanitizeConversionData(input: Record): Record { + const sanitized = sanitizeServerEventInput(input); + for (const key of CONVERSION_RESERVED_KEYS) delete sanitized[key]; + return Object.fromEntries( + Object.entries(sanitized).filter(([key]) => SAFE_CONVERSION_DATA_KEYS.has(key)), + ); +} + function requireSafeHttpUrl(value: unknown, field: string): string { const endpoint = requireNonEmptyString(value, field); if (!isSafeHttpUrl(endpoint)) { @@ -207,12 +261,27 @@ export class ClickTrailServer { private build(eventName: string, input: ConversionInput>): ClickTrailEvent { const payload = filterServerAttributionPayload(input.identity.payload ?? {}); - const data = sanitizeServerEventInput(input.data ?? {}); + const inputData = input.data ?? {}; + const data = sanitizeConversionData(inputData); + const canonicalName = toCanonicalEventName(eventName); + const occurredAt = input.now === undefined + ? new Date().toISOString() + : requireNonEmptyString(input.now, 'now'); + const formId = optionalText(inputData['formId']); + const leadId = optionalText(inputData['leadId']); + const bookingId = optionalText(inputData['bookingId']); + const orderId = optionalText(inputData['orderId']) ?? optionalText(inputData['transactionId']); + const startDate = optionalText(inputData['startDate']); const eventId = resolveEventId(eventName, input, this.siteId); - return buildEventPayload(payload, toCanonicalEventName(eventName), { + return buildEventPayload(payload, canonicalName, { ...data, + ...(formId !== undefined ? { form_id: formId } : {}), + ...(leadId !== undefined ? { lead_id: leadId } : {}), + ...(bookingId !== undefined ? { booking_id: bookingId } : {}), + ...(orderId !== undefined ? { order_id: orderId } : {}), + ...(startDate !== undefined ? { start_date: startDate } : {}), event_id: eventId, - ...(input.now !== undefined ? { event_time: input.now } : {}), + occurred_at: occurredAt, ...(this.siteId !== undefined ? { site_id: this.siteId } : {}), ...(this.workspaceId !== undefined ? { workspace_id: this.workspaceId } : {}), ...(input.identity.visitorId ? { visitor_id: input.identity.visitorId } : {}), diff --git a/packages/server/src/tenant-adapter.ts b/packages/server/src/tenant-adapter.ts index 09b074f..01c6ac4 100644 --- a/packages/server/src/tenant-adapter.ts +++ b/packages/server/src/tenant-adapter.ts @@ -27,6 +27,12 @@ export interface TenantAdapterEventInput { identity: ServerIdentity; eventName: string; externalEventId: string; + /** Trusted host-owned conversion ID mapped to canonical `lead_id`. */ + leadId?: string; + /** Trusted host-owned conversion ID mapped to canonical `booking_id`. */ + bookingId?: string; + /** Trusted host-owned conversion ID mapped to canonical `order_id`. */ + orderId?: string; data?: Record; now?: string; } @@ -47,6 +53,42 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function optionalTenantText(value: unknown, field: string): string | undefined { + return value === undefined ? undefined : requireTenantText(value, field); +} + +/** + * Safe top-level event fields accepted from adapter input. Provider-specific + * values belong under `properties`; dropping unknown top-level keys keeps + * email/phone/content from crossing the metadata boundary by accident. + */ +const SAFE_EVENT_DATA_KEYS = new Set([ + 'value', + 'currency', + 'landing_url', + 'referrer', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gclid', + 'gbraid', + 'wbraid', + 'fbclid', + 'msclkid', + 'consent_state', + 'consent_source', + 'consent_version', + 'refund_of', +]); + +function filterSafeEventData(data: Record): Record { + return Object.fromEntries( + Object.entries(data).filter(([key]) => SAFE_EVENT_DATA_KEYS.has(key) || key === 'properties'), + ); +} + export function validateTenantAdapterConfig(config: TenantAdapterConfig): TenantAdapterConfig { const endpoint = requireTenantText(config.endpoint, 'endpoint'); if (!isSafeHttpUrl(endpoint)) { @@ -78,17 +120,26 @@ export class TenantAdapter { this.config.siteId, JSON.stringify([this.config.tenantId, this.config.adapterName, eventName, externalEventId]), ); + const occurredAt = input.now === undefined + ? new Date().toISOString() + : requireTenantText(input.now, 'now'); const inputProperties = isRecord(input.data?.['properties']) ? input.data['properties'] : {}; - const data = sanitizeServerEventInput({ ...(input.data ?? {}) }); + const data = filterSafeEventData( + sanitizeServerEventInput({ ...(input.data ?? {}) }), + ); + const leadId = optionalTenantText(input.leadId, 'leadId'); + const bookingId = optionalTenantText(input.bookingId, 'bookingId'); + const orderId = optionalTenantText(input.orderId, 'orderId'); return buildEventPayload(filterServerAttributionPayload(input.identity.payload ?? {}), eventName, { ...data, + ...(leadId !== undefined ? { lead_id: leadId } : {}), + ...(bookingId !== undefined ? { booking_id: bookingId } : {}), + ...(orderId !== undefined ? { order_id: orderId } : {}), site_id: this.config.siteId, ...(this.config.workspaceId !== undefined ? { workspace_id: this.config.workspaceId } : {}), event_id: eventId, - ...(input.now !== undefined - ? { event_time: requireTenantText(input.now, 'now'), occurred_at: input.now } - : {}), + occurred_at: occurredAt, ...(input.identity.visitorId ? { visitor_id: input.identity.visitorId } : {}), ...(input.identity.sessionId ? { session_id: input.identity.sessionId } : {}), ...(input.identity.sessionNumber !== undefined diff --git a/packages/server/tests/fixtures/supletivo-handoff.json b/packages/server/tests/fixtures/supletivo-handoff.json new file mode 100644 index 0000000..15392ef --- /dev/null +++ b/packages/server/tests/fixtures/supletivo-handoff.json @@ -0,0 +1,47 @@ +{ + "name": "supletivo-astro-django-pix", + "site_id": "supletivo.test", + "workspace_id": "supletivo-workspace", + "landing_url": "https://landing.supletivo.test/?utm_source=google&utm_medium=cpc&gclid=G-opaque-42&ref=partner_opaque_42", + "app_url": "https://app.supletivo.test/checkout", + "rejected_url": "https://outside.test/checkout", + "partner_ref": "partner_opaque_42", + "host_owned": { + "attribution_id": "attr_opaque_42", + "lead_id": "lead_opaque_42", + "customer_id": "customer_opaque_42", + "invoice_id": "invoice_opaque_42" + }, + "otp": { + "identity_key": "otp_subject_opaque_42" + }, + "checkout": { + "value": 49.9, + "currency": "BRL" + }, + "pix": { + "provider_event_id": "pix_event_opaque_42", + "status": "PAID", + "amount": 49.9, + "currency": "BRL", + "invoice_id": "invoice_opaque_42" + }, + "events": { + "lead": { + "external_id": "crm_lead_event_opaque_42", + "occurred_at": "2026-09-17T12:00:00.000Z" + }, + "sale": { + "occurred_at": "2026-09-18T12:00:00.000Z" + } + }, + "expected": { + "first_source": "google", + "first_medium": "cpc", + "last_source": "email", + "order_id": "invoice_opaque_42", + "approved_host": "app.supletivo.test", + "rejected_host": "outside.test", + "duplicate_sale_count": 1 + } +} diff --git a/packages/server/tests/server.test.ts b/packages/server/tests/server.test.ts index ea818eb..5945f85 100644 --- a/packages/server/tests/server.test.ts +++ b/packages/server/tests/server.test.ts @@ -57,7 +57,13 @@ describe('ClickTrailServer', () => { const server = makeServer(fetchMock); const result = await server.trackLead({ identity: parseIdentityFromCookies(`${ATTRIBUTION_COOKIE}; ${SESSION_COOKIE}`), - data: { formId: 'contact', marketing_trail: { site_id: 'attacker', workspace_id: 'other' } }, + eventId: 'lead-provider-1', + data: { + formId: 'contact', + leadId: 'lead_42', + lead_id: 'attacker-lead', + marketing_trail: { site_id: 'attacker', workspace_id: 'other' }, + } as never, now: '2026-08-24T10:00:00.000Z', }); expect(result).toEqual({ ok: true, status: 204 }); @@ -69,16 +75,89 @@ describe('ClickTrailServer', () => { expect(event['schema_version']).toBeTypeOf('string'); expect(event['classifier_version']).toBeTypeOf('string'); expect(event['ft_source']).toBe('google'); - expect(event['formId']).toBe('contact'); + expect(event['form_id']).toBe('contact'); + expect(event['formId']).toBeUndefined(); + expect(event['lead_id']).toBe('lead_42'); + expect(event['event_id']).toBe('evt_lead-provider-1'); + expect(event['occurred_at']).toBe('2026-08-24T10:00:00.000Z'); + expect(event['event_time']).toBeUndefined(); expect(event['visitor_id']).toBe('v-1'); expect(event['session_id']).toBe('s-9'); expect(event['session_number']).toBe('2'); expect(event['site_id']).toBe('s1'); expect(event['workspace_id']).toBe('w1'); - expect(event['marketing_trail']).toMatchObject({ site_id: 's1', workspace_id: 'w1' }); + expect(event['marketing_trail']).toMatchObject({ + site_id: 's1', + workspace_id: 'w1', + event_id: 'evt_lead-provider-1', + lead_id: 'lead_42', + occurred_at: '2026-08-24T10:00:00.000Z', + }); expect(init.redirect).toBe('error'); }); + it('maps purchase transactionId to canonical order_id', async () => { + const fetchMock = okFetch(); + const server = makeServer(fetchMock); + await server.trackPurchase({ + identity: { payload: {} }, + eventId: 'pix-provider-1', + data: { transactionId: 'invoice-42', value: 49.9, currency: 'EUR' }, + now: '2026-08-24T10:05:00.000Z', + }); + + const [, init] = fetchMock.mock.calls[0]! as unknown as [string, RequestInit]; + const event = (JSON.parse(String(init.body)) as { events: Array> }).events[0]!; + expect(event['event_name']).toBe('sale'); + expect(event['order_id']).toBe('invoice-42'); + expect(event['transactionId']).toBeUndefined(); + expect(event['occurred_at']).toBe('2026-08-24T10:05:00.000Z'); + expect(event['marketing_trail']).toMatchObject({ + event_id: 'evt_pix-provider-1', + occurred_at: '2026-08-24T10:05:00.000Z', + }); + }); + + it('maps bookingId to canonical booking_id and preserves occurred_at', async () => { + const fetchMock = okFetch(); + const server = makeServer(fetchMock); + await server.trackBooking({ + identity: { payload: {} }, + eventId: 'booking-provider-1', + data: { bookingId: 'booking_42', startDate: '2026-09-19T10:00:00.000Z', value: 20, currency: 'BRL' }, + now: '2026-09-19T09:00:00.000Z', + }); + + const [, init] = fetchMock.mock.calls[0]! as unknown as [string, RequestInit]; + const event = (JSON.parse(String(init.body)) as { events: Array> }).events[0]!; + expect(event['event_name']).toBe('booking_created'); + expect(event['booking_id']).toBe('booking_42'); + expect(event['bookingId']).toBeUndefined(); + expect(event['start_date']).toBe('2026-09-19T10:00:00.000Z'); + expect(event['occurred_at']).toBe('2026-09-19T09:00:00.000Z'); + }); + + it('derives the same ID for a repeated conversion key when eventId is omitted', async () => { + const fetchMock = okFetch(); + const server = makeServer(fetchMock); + const input = { + identity: { payload: {} }, + data: { leadId: 'lead_42' }, + now: '2026-09-20T09:00:00.000Z', + }; + await server.trackLead(input); + await server.trackLead(input); + await server.trackLead({ ...input, data: { leadId: 'lead_43' } }); + + const ids = fetchMock.mock.calls.map((call) => { + const [, init] = call as unknown as [string, RequestInit]; + const event = (JSON.parse(String(init.body)) as { events: Array> }).events[0]!; + return event['event_id']; + }); + expect(ids[0]).toBe(ids[1]); + expect(ids[2]).not.toBe(ids[0]); + }); + it('does not promote canonical fields from untrusted conversion data', async () => { const fetchMock = okFetch(); const server = makeServer(fetchMock); @@ -90,6 +169,7 @@ describe('ClickTrailServer', () => { session_number: '999', trail_id: 'attacker-trail', anonymous_id: 'attacker-anonymous', + email: 'must-not-be-forwarded', marketing_trail: { site_id: 'attacker-site', workspace_id: 'attacker-workspace' }, }, }); @@ -99,6 +179,7 @@ describe('ClickTrailServer', () => { expect(event['visitor_id']).toBeUndefined(); expect(event['session_id']).toBeUndefined(); expect(event['session_number']).toBeUndefined(); + expect(event['email']).toBeUndefined(); expect(event['marketing_trail']).toMatchObject({ site_id: 's1', workspace_id: 'w1', diff --git a/packages/server/tests/supletivo-handoff.test.ts b/packages/server/tests/supletivo-handoff.test.ts new file mode 100644 index 0000000..d5a4953 --- /dev/null +++ b/packages/server/tests/supletivo-handoff.test.ts @@ -0,0 +1,314 @@ +import { createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + buildReferralTouch, + decodeContinuationToken, + encodeContinuationToken, + isApprovedHost, +} from '@vizuh/clicktrail-browser'; +import type { ClickTrailEvent } from '@vizuh/clicktrail-browser'; +import { + emptyAttribution, + mergeAttributionTouch, + parseAttributionUrl, +} from '@vizuh/clicktrail-core'; +import { createTenantAdapter } from '../src/tenant-adapter.js'; + +interface HandoffFixture { + name: string; + site_id: string; + workspace_id: string; + landing_url: string; + app_url: string; + rejected_url: string; + partner_ref: string; + host_owned: { + attribution_id: string; + lead_id: string; + customer_id: string; + invoice_id: string; + }; + otp: { identity_key: string }; + checkout: { value: number; currency: string }; + pix: { + provider_event_id: string; + status: string; + amount: number; + currency: string; + invoice_id: string; + }; + events: { + lead: { external_id: string; occurred_at: string }; + sale: { occurred_at: string }; + }; + expected: { + first_source: string; + first_medium: string; + last_source: string; + order_id: string; + approved_host: string; + rejected_host: string; + duplicate_sale_count: number; + }; +} + +const fixture = JSON.parse( + readFileSync(new URL('./fixtures/supletivo-handoff.json', import.meta.url), 'utf8'), +) as HandoffFixture; + +function partnerRefFromUrl(url: string): string | null { + try { + const value = new URL(url).searchParams.get('ref'); + return value && /^[a-z0-9_-]{1,64}$/i.test(value) ? value : null; + } catch { + return null; + } +} + +type AttributionSnapshot = Readonly<{ + attribution_id: string; + partner_ref: string; + first_touch: Readonly<{ + source: string; + medium: string; + campaign: string; + click_id: string; + }>; +}>; + +function makeSnapshot(payload: Record, url: string): AttributionSnapshot { + const partnerRef = partnerRefFromUrl(url); + if (!partnerRef) throw new Error('host: valid partner ref is required'); + return Object.freeze({ + attribution_id: fixture.host_owned.attribution_id, + partner_ref: partnerRef, + first_touch: Object.freeze({ + source: payload['ft_source'] ?? '', + medium: payload['ft_medium'] ?? '', + campaign: payload['ft_campaign'] ?? '', + click_id: payload['gclid'] ?? '', + }), + }); +} + +function parseTouch(url: string, now: string) { + const parsed = parseAttributionUrl({ + url, + currentHost: new URL(url).host, + now, + }); + if (parsed.kind !== 'touch') throw new Error(`expected touch: ${parsed.reason}`); + return parsed.touch; +} + +function hmacPair() { + const key = 'synthetic-shared-signing-key'; + const sign = async (body: string): Promise => + createHmac('sha256', key).update(body).digest('base64url'); + const verify = async (body: string, signature: string): Promise => + sign(body).then((expected) => expected === signature); + return { sign, verify }; +} + +function hostId(input: string): string { + if (!input) throw new Error('host: correlation key is required'); + if (!/^attr_[a-z0-9_-]+$/i.test(input)) throw new Error('host: invalid correlation key'); + return input; +} + +describe('Supletivo Astro -> Django/OTP/invoice/Pix reference fixture', () => { + it('covers consent, first-touch preservation, approved handoff, host state, and Pix idempotency', async () => { + const noConsentPayload = emptyAttribution(); + expect(noConsentPayload['ft_source']).toBe(''); + + const firstTouch = parseTouch( + fixture.landing_url, + fixture.events.lead.occurred_at, + ); + const initialPayload = mergeAttributionTouch(emptyAttribution(), firstTouch); + expect(initialPayload['ft_source']).toBe(fixture.expected.first_source); + expect(initialPayload['ft_medium']).toBe(fixture.expected.first_medium); + // `ref` is deliberately host-owned; the ClickTrail parser does not emit it. + expect(initialPayload['ref']).toBeUndefined(); + expect(initialPayload['attribution_id']).toBeUndefined(); + + const snapshot = makeSnapshot(initialPayload, fixture.landing_url); + const laterPayload = mergeAttributionTouch( + initialPayload, + parseTouch( + 'https://landing.supletivo.test/pricing?utm_source=email&utm_medium=email', + fixture.events.sale.occurred_at, + ), + ); + expect(laterPayload['ft_source']).toBe(snapshot.first_touch.source); + expect(laterPayload['lt_source']).toBe(fixture.expected.last_source); + expect(snapshot.first_touch.source).toBe(fixture.expected.first_source); + + const approvedHost = new URL(fixture.app_url).host; + const rejectedHost = new URL(fixture.rejected_url).host; + expect(isApprovedHost(approvedHost, ['supletivo.test'])).toBe(true); + expect(isApprovedHost(rejectedHost, ['supletivo.test'])).toBe(false); + + const { sign, verify } = hmacPair(); + const token = await encodeContinuationToken({ + visitorId: 'visitor_opaque_42', + sessionId: 'session_opaque_42', + attribution: { + lt_source: laterPayload['lt_source'] ?? '', + lt_medium: laterPayload['lt_medium'] ?? '', + gclid: laterPayload['gclid'] ?? '', + }, + nowMs: Date.parse(fixture.events.lead.occurred_at), + sign, + }); + const decoded = await decodeContinuationToken( + token, + verify, + Date.parse(fixture.events.sale.occurred_at), + ); + expect(decoded.kind).toBe('valid'); + if (decoded.kind !== 'valid') throw new Error('expected a valid handoff token'); + // Shared first-party storage carries the immutable first touch; the + // continuation token contributes the current hop as a last touch. + const appPayload = mergeAttributionTouch( + initialPayload, + buildReferralTouch({ + payload: decoded.payload, + landingUrl: fixture.app_url, + nowIso: fixture.events.sale.occurred_at, + }), + ); + expect(appPayload['ft_source']).toBe(fixture.expected.first_source); + expect(appPayload['lt_medium']).toBe('referral'); + + expect(partnerRefFromUrl(fixture.landing_url)).toBe(fixture.partner_ref); + expect(partnerRefFromUrl(fixture.landing_url.replace('&ref=', '&ref=bad%2F'))).toBeNull(); + expect(partnerRefFromUrl(fixture.landing_url.replace(/&ref=[^&]+/, ''))).toBeNull(); + + const snapshots = new Map(); + snapshots.set(snapshot.attribution_id, snapshot); + const otpIdentityKey = fixture.otp.identity_key; + const leadRecord = { + lead_id: fixture.host_owned.lead_id, + attribution_id: hostId(snapshot.attribution_id), + otp_identity_key: otpIdentityKey, + }; + expect(leadRecord.otp_identity_key).toBe(otpIdentityKey); + expect(snapshots.get(leadRecord.attribution_id)?.first_touch.source).toBe('google'); + + const invoices = new Map(); + const createInvoice = (attributionId: string, clientValue: number) => { + const record = snapshots.get(attributionId); + if (!record) throw new Error('host: unknown attribution_id'); + const invoice = { + invoice_id: fixture.host_owned.invoice_id, + lead_id: leadRecord.lead_id, + attribution_id: record.attribution_id, + value: fixture.checkout.value, + currency: fixture.checkout.currency, + }; + invoices.set(invoice.invoice_id, invoice); + // Client price is not authoritative; the host's pricing table is. + expect(clientValue).not.toBe(invoice.value); + return invoice; + }; + expect(() => createInvoice('attr_tampered', 0.01)).toThrow(/unknown attribution_id/); + const invoice = createInvoice(snapshot.attribution_id, 0.01); + expect(invoice.value).toBe(fixture.checkout.value); + + const adapter = createTenantAdapter({ + endpoint: 'https://collector.supletivo.test/v1/events', + tenantId: 'supletivo-tenant', + siteId: fixture.site_id, + workspaceId: fixture.workspace_id, + adapterName: 'supletivo-host', + adapterVersion: '0.1.0', + }); + const leadEvent = adapter.build({ + identity: { payload: initialPayload, visitorId: 'visitor_opaque_42', sessionId: 'session_opaque_42' }, + eventName: 'lead_created', + externalEventId: fixture.events.lead.external_id, + leadId: leadRecord.lead_id, + now: fixture.events.lead.occurred_at, + data: { + properties: { attribution_id: leadRecord.attribution_id }, + email: 'must-not-be-forwarded', + }, + }); + expect(leadEvent.event_name).toBe('lead_created'); + expect(leadEvent.lead_id).toBe(leadRecord.lead_id); + expect(leadEvent.marketing_trail.lead_id).toBe(leadRecord.lead_id); + expect(leadEvent.properties).toMatchObject({ attribution_id: leadRecord.attribution_id }); + expect(leadEvent.attribution_id).toBeUndefined(); + expect(leadEvent.email).toBeUndefined(); + + const seenProviderEvents = new Set(); + const saleEvents: ClickTrailEvent[] = []; + type SignedPixWebhook = typeof fixture.pix & { signature: string }; + const pixBody = (webhook: SignedPixWebhook): string => JSON.stringify({ + provider_event_id: webhook.provider_event_id, + status: webhook.status, + amount: webhook.amount, + currency: webhook.currency, + invoice_id: webhook.invoice_id, + }); + const signedPix: SignedPixWebhook = { + ...fixture.pix, + signature: await sign(JSON.stringify(fixture.pix)), + }; + const processPix = async ( + webhook: SignedPixWebhook, + ): Promise<'emitted' | 'duplicate' | 'rejected'> => { + if (!(await verify(pixBody(webhook), webhook.signature))) return 'rejected'; + const stored = invoices.get(webhook.invoice_id); + if ( + webhook.status !== 'PAID' || + !stored || + webhook.amount !== stored.value || + webhook.currency !== stored.currency + ) return 'rejected'; + if (seenProviderEvents.has(webhook.provider_event_id)) return 'duplicate'; + seenProviderEvents.add(webhook.provider_event_id); + saleEvents.push(adapter.build({ + identity: { payload: initialPayload }, + eventName: 'sale', + externalEventId: webhook.provider_event_id, + orderId: stored.invoice_id, + now: fixture.events.sale.occurred_at, + data: { + value: stored.value, + currency: stored.currency, + properties: { attribution_id: stored.attribution_id }, + }, + })); + return 'emitted'; + }; + + // A changed body with the old signature is rejected before invoice lookup. + expect(await processPix({ ...signedPix, amount: 0.01 })).toBe('rejected'); + expect(saleEvents).toHaveLength(0); + expect(await processPix(signedPix)).toBe('emitted'); + expect(await processPix(signedPix)).toBe('duplicate'); + expect(saleEvents).toHaveLength(fixture.expected.duplicate_sale_count); + expect(saleEvents[0]?.order_id).toBe(fixture.expected.order_id); + expect(saleEvents[0]?.value).toBe(fixture.checkout.value); + expect(saleEvents[0]?.properties).toMatchObject({ attribution_id: snapshot.attribution_id }); + expect(saleEvents[0]?.event_id).toBe( + adapter.build({ + identity: { payload: initialPayload }, + eventName: 'sale', + externalEventId: signedPix.provider_event_id, + orderId: invoice.invoice_id, + now: fixture.events.sale.occurred_at, + data: { value: invoice.value, currency: invoice.currency }, + }).event_id, + ); + }); +}); diff --git a/packages/server/tests/tenant-adapter.test.ts b/packages/server/tests/tenant-adapter.test.ts index 81515e0..3f03b58 100644 --- a/packages/server/tests/tenant-adapter.test.ts +++ b/packages/server/tests/tenant-adapter.test.ts @@ -46,6 +46,7 @@ describe('TenantAdapter', () => { expect(event.event_name).toBe('lead_created'); expect(event.event_id).toMatch(/^evt_s-/); expect(event.occurred_at).toBe('2026-08-25T10:00:00.000Z'); + expect(event.event_time).toBeUndefined(); expect(event.properties).toEqual({ source_status: 'new', tenant_id: 'med10x-tenant-1', @@ -155,6 +156,34 @@ describe('TenantAdapter', () => { ); }); + it('maps trusted conversion IDs while ignoring reserved input aliases', () => { + const adapter = createTenantAdapter(baseConfig()); + const lead = adapter.build({ + ...input('lead-provider-1'), + eventName: 'lead_created', + leadId: 'lead_42', + data: { lead_id: 'attacker-lead', properties: { attribution_id: 'attr_42' } }, + }); + expect(lead.lead_id).toBe('lead_42'); + expect(lead.marketing_trail.lead_id).toBe('lead_42'); + expect(lead.properties).toEqual({ + attribution_id: 'attr_42', + tenant_id: 'med10x-tenant-1', + adapter_name: 'med10x', + adapter_version: '0.1.0', + }); + + const sale = adapter.build({ + ...input('pix-provider-1'), + eventName: 'sale', + orderId: 'invoice_42', + data: { order_id: 'attacker-order', transactionId: 'attacker-transaction', value: 49.9, currency: 'EUR' }, + }); + expect(sale.order_id).toBe('invoice_42'); + expect(sale.order_id).not.toBe('attacker-order'); + expect(sale.transactionId).toBeUndefined(); + }); + it('sends one stable event through the existing server client', async () => { const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); const adapter = createTenantAdapter({ ...baseConfig(), fetch: fetchImpl as unknown as typeof fetch }); From 8abedd9226838ca6803f8d66bcf7aa7cc423ec9b Mon Sep 17 00:00:00 2001 From: atroci Date: Wed, 16 Sep 2026 23:37:27 +0100 Subject: [PATCH 2/2] test: inject Astro randomness seam for Node 18 --- integrations/astro/src/client.ts | 24 ++++++++++++++++---- integrations/astro/tests/client-boot.test.ts | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/integrations/astro/src/client.ts b/integrations/astro/src/client.ts index 78eee3b..717ee3f 100644 --- a/integrations/astro/src/client.ts +++ b/integrations/astro/src/client.ts @@ -14,7 +14,11 @@ import { createClickTrail, httpDestination, } from '@vizuh/clicktrail/browser'; -import type { ClickTrailConfig, ClickTrailInstance } from '@vizuh/clicktrail/browser'; +import type { + ClickTrailConfig, + ClickTrailInstance, + RandomBytesFn, +} from '@vizuh/clicktrail/browser'; import type { NavigationSeam } from './page-views.js'; import { attachPageViewTracking } from './page-views.js'; import { CLIENT_CONFIG_GLOBAL } from './config.js'; @@ -36,9 +40,18 @@ export interface ClientDomSeams { dispatchEvent?: (event: { type: string }) => void; }; navigationSeam?: NavigationSeam; + /** Injectable randomness seam for non-browser test runtimes. */ + randomBytes?: RandomBytesFn; } -function defaultSeams(): Required & { navigationSeam: NavigationSeam } { +type ResolvedClientDomSeams = { + storageLike: NonNullable; + eventTarget: NonNullable; + navigationSeam: NonNullable; + randomBytes?: RandomBytesFn; +}; + +function defaultSeams(): ResolvedClientDomSeams { const w = globalThis as unknown as { localStorage?: Pick; document?: { @@ -90,11 +103,11 @@ export interface BootedClient { detachPageViews(): void; } -function resolveSeams(seams: ClientDomSeams): Required { +function resolveSeams(seams: ClientDomSeams): ResolvedClientDomSeams { if (seams.storageLike && seams.eventTarget && seams.navigationSeam) { - return seams as Required; + return seams as ResolvedClientDomSeams; } - return { ...defaultSeams(), ...seams } as Required; + return { ...defaultSeams(), ...seams } as ResolvedClientDomSeams; } export function bootClickTrailClient( @@ -113,6 +126,7 @@ export function bootClickTrailClient( ...(config.storage?.retentionDays !== undefined ? { retentionDays: config.storage.retentionDays } : {}), + ...(resolved.randomBytes !== undefined ? { randomBytes: resolved.randomBytes } : {}), }; const crossDomain: ClickTrailConfig['crossDomain'] = config.crossDomain && { domains: config.crossDomain.domains, diff --git a/integrations/astro/tests/client-boot.test.ts b/integrations/astro/tests/client-boot.test.ts index 63fe082..59798fe 100644 --- a/integrations/astro/tests/client-boot.test.ts +++ b/integrations/astro/tests/client-boot.test.ts @@ -53,6 +53,7 @@ function makeSeams(href: string) { referrer: () => '', host: () => 'example.com', }, + randomBytes: (length: number) => new Uint8Array(length).fill(7), }; }