Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/TUTORIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion examples/astro-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 });
};
Expand Down
37 changes: 35 additions & 2 deletions integrations/astro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
```

Expand All @@ -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:
Expand All @@ -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

Expand Down
70 changes: 60 additions & 10 deletions integrations/astro/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<ClientDomSeams> & { navigationSeam: NavigationSeam } {
type ResolvedClientDomSeams = {
storageLike: NonNullable<ClientDomSeams['storageLike']>;
eventTarget: NonNullable<ClientDomSeams['eventTarget']>;
navigationSeam: NonNullable<ClientDomSeams['navigationSeam']>;
randomBytes?: RandomBytesFn;
};

function defaultSeams(): ResolvedClientDomSeams {
const w = globalThis as unknown as {
localStorage?: Pick<Storage, 'getItem' | 'setItem'>;
document?: {
Expand Down Expand Up @@ -90,11 +103,11 @@ export interface BootedClient {
detachPageViews(): void;
}

function resolveSeams(seams: ClientDomSeams): Required<ClientDomSeams> {
function resolveSeams(seams: ClientDomSeams): ResolvedClientDomSeams {
if (seams.storageLike && seams.eventTarget && seams.navigationSeam) {
return seams as Required<ClientDomSeams>;
return seams as ResolvedClientDomSeams;
}
return { ...defaultSeams(), ...seams } as Required<ClientDomSeams>;
return { ...defaultSeams(), ...seams } as ResolvedClientDomSeams;
}

export function bootClickTrailClient(
Expand All @@ -106,16 +119,37 @@ 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 }
: {}),
...(resolved.randomBytes !== undefined ? { randomBytes: resolved.randomBytes } : {}),
};
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;
Expand All @@ -127,7 +161,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();
};

Expand All @@ -136,6 +178,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();
}
Expand All @@ -150,7 +194,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. */
Expand Down
20 changes: 20 additions & 0 deletions integrations/astro/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,33 @@ 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;
siteId?: string;
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 {
Expand Down
52 changes: 51 additions & 1 deletion integrations/astro/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -53,19 +58,64 @@ 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 {
return /^https?:\/\//i.test(endpoint);
}

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 =
Expand Down
Loading
Loading