@scaletrail/connector 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,143 @@
1
+ // HTTP client for the Connector API. One request per call; the shop keeps its own queue and decides
2
+ // when to retry (see retryDelaySeconds). The target host is fixed: credentials never go anywhere else.
3
+
4
+ import { signRequest, toBytes } from './signature.js';
5
+
6
+ export const API_BASE = 'https://api.scaletrail.io/connect/v1';
7
+ export const MAX_BODY_BYTES = 256 * 1024;
8
+ export const DEFAULT_TIMEOUT_MS = 15_000;
9
+
10
+ /** Retry schedule from the API description: 1 min, 5 min, 30 min, then hourly for up to 72 h. */
11
+ export const RETRY_SCHEDULE_SECONDS = Object.freeze([60, 300, 1800, 3600]);
12
+ export const RETRY_GIVE_UP_AFTER_SECONDS = 72 * 3600;
13
+
14
+ const ROUTES = Object.freeze({ orders: 'orders', events: 'events', redact: 'customers/redact' });
15
+ const REQUEST_ID_RE = /^[A-Za-z0-9_.:-]{1,100}$/;
16
+ const PATH_RE = /^[A-Za-z0-9_.$[\]-]{1,200}$/;
17
+ const MAX_RESPONSE_CHARS = 64 * 1024;
18
+ const MAX_PATHS = 50;
19
+ const MAX_RETRY_AFTER_SECONDS = 24 * 3600;
20
+
21
+ /**
22
+ * @typedef {'accepted'|'valid'|'rejected'|'retry'} Outcome
23
+ * @typedef {{
24
+ * outcome: Outcome,
25
+ * status: number|null,
26
+ * requestId?: string,
27
+ * paths?: string[],
28
+ * retryAfterSeconds?: number|null,
29
+ * reason?: string,
30
+ * }} SendResult
31
+ */
32
+
33
+ function parseRetryAfter(value) {
34
+ if (typeof value !== 'string' || !/^\d{1,6}$/.test(value)) return null;
35
+ return Math.min(Number(value), MAX_RETRY_AFTER_SECONDS);
36
+ }
37
+
38
+ async function readJson(res) {
39
+ try {
40
+ const text = await res.text();
41
+ if (text.length > MAX_RESPONSE_CHARS) return null;
42
+ return JSON.parse(text);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function cleanPaths(paths) {
49
+ if (!Array.isArray(paths)) return [];
50
+ return paths.filter((p) => typeof p === 'string' && PATH_RE.test(p)).slice(0, MAX_PATHS);
51
+ }
52
+
53
+ /**
54
+ * Maps an HTTP response to what the shop should do. Exported for tests and custom transports.
55
+ * @param {Response} res
56
+ * @param {{dryRun?: boolean}} [opts]
57
+ * @returns {Promise<SendResult>}
58
+ */
59
+ export async function classifyResponse(res, { dryRun = false } = {}) {
60
+ const status = res.status;
61
+ if (status === 202 && !dryRun) {
62
+ const json = await readJson(res);
63
+ const requestId = json && typeof json.request_id === 'string' && REQUEST_ID_RE.test(json.request_id)
64
+ ? json.request_id : null;
65
+ // Accepted only with confirmation in the body; anything else is sent again (safe, idempotent).
66
+ return requestId
67
+ ? { outcome: 'accepted', status, requestId }
68
+ : { outcome: 'retry', status, retryAfterSeconds: null, reason: 'unconfirmed' };
69
+ }
70
+ if (status === 200 && dryRun) {
71
+ const json = await readJson(res);
72
+ return json && json.valid === true
73
+ ? { outcome: 'valid', status }
74
+ : { outcome: 'retry', status, retryAfterSeconds: null, reason: 'unconfirmed' };
75
+ }
76
+ if (status === 400) {
77
+ const json = await readJson(res);
78
+ return { outcome: 'rejected', status, paths: cleanPaths(json?.paths), reason: 'invalid_payload' };
79
+ }
80
+ if (status === 401) return { outcome: 'rejected', status, reason: 'unauthorized' };
81
+ if (status === 404) return { outcome: 'rejected', status, reason: 'not_found' };
82
+ if (status === 413) return { outcome: 'rejected', status, reason: 'too_large' };
83
+ if (status === 429 || status === 503) {
84
+ return { outcome: 'retry', status, retryAfterSeconds: parseRetryAfter(res.headers.get('Retry-After')) };
85
+ }
86
+ if (status >= 300 && status < 400) return { outcome: 'rejected', status, reason: 'redirect' };
87
+ if (status >= 500) return { outcome: 'retry', status, retryAfterSeconds: null };
88
+ return { outcome: 'rejected', status, reason: 'unexpected_status' };
89
+ }
90
+
91
+ /**
92
+ * Seconds to wait before the next attempt, or null when the shop should stop and raise an alarm.
93
+ * @param {number} attempt number of attempts made so far (1 after the first failure)
94
+ * @param {{retryAfterSeconds?: number|null, firstAttemptAt?: number, nowMs?: number}} [opts]
95
+ * @returns {number|null}
96
+ */
97
+ export function retryDelaySeconds(attempt, { retryAfterSeconds = null, firstAttemptAt, nowMs = Date.now() } = {}) {
98
+ if (!Number.isInteger(attempt) || attempt < 1) throw new TypeError('attempt must be an integer >= 1');
99
+ if (Number.isFinite(firstAttemptAt) && (nowMs - firstAttemptAt) / 1000 >= RETRY_GIVE_UP_AFTER_SECONDS) return null;
100
+ const scheduled = RETRY_SCHEDULE_SECONDS[Math.min(attempt, RETRY_SCHEDULE_SECONDS.length) - 1];
101
+ return Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0 ? retryAfterSeconds : scheduled;
102
+ }
103
+
104
+ /**
105
+ * @param {{
106
+ * keyId: string,
107
+ * secret: string,
108
+ * connector?: string,
109
+ * fetch?: typeof globalThis.fetch,
110
+ * timeoutMs?: number,
111
+ * }} config
112
+ */
113
+ export function createClient({ keyId, secret, connector, fetch: fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
114
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetch is not available, pass one in the config');
115
+
116
+ async function send(route, message, { dryRun = false } = {}) {
117
+ const body = JSON.stringify(message);
118
+ if (toBytes(body).length > MAX_BODY_BYTES) return { outcome: 'rejected', status: null, reason: 'too_large' };
119
+ const headers = await signRequest({ keyId, secret, body, connector });
120
+ const url = `${API_BASE}/${ROUTES[route]}${dryRun ? '?dry_run=1' : ''}`;
121
+ const controller = new AbortController();
122
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
123
+ let res;
124
+ try {
125
+ res = await fetchImpl(url, { method: 'POST', headers, body, redirect: 'manual', signal: controller.signal });
126
+ } catch (err) {
127
+ const reason = err && err.name === 'AbortError' ? 'timeout' : 'network';
128
+ return { outcome: 'retry', status: null, retryAfterSeconds: null, reason };
129
+ } finally {
130
+ clearTimeout(timer);
131
+ }
132
+ return classifyResponse(res, { dryRun });
133
+ }
134
+
135
+ return {
136
+ /** @param {object} order snapshot of the order, see OrderMessage */
137
+ sendOrder: (order, opts) => send('orders', { order }, opts),
138
+ /** @param {object} event see EventMessage */
139
+ sendEvent: (event, opts) => send('events', { event }, opts),
140
+ /** @param {object} redact see RedactMessage */
141
+ sendRedact: (redact, opts) => send('redact', { redact }, opts),
142
+ };
143
+ }
package/src/cookies.js ADDED
@@ -0,0 +1,251 @@
1
+ // Edge cookies for the shop's own server or gateway worker.
2
+ //
3
+ // On every response the shop sets the visitor ID mirror `_sa_uid_shop`. On a landing with click IDs
4
+ // or UTM parameters it also sets one cookie per parameter. All cookies are first-party, set by the
5
+ // shop's own response, Secure, SameSite=Lax, 400 days, on the shop's registrable domain.
6
+ //
7
+ // Cached pages: call edgeCookies() in the stage that runs on every request (before the cache), and
8
+ // apply the cookies to the response the browser gets, never to the stored cache entry. Responses
9
+ // with these cookies are private.
10
+
11
+ import { normalizeVisitorId, uuidV7 } from './uuid.js';
12
+
13
+ export const VISITOR_COOKIE = '_sa_uid';
14
+ export const MIRROR_COOKIE = '_sa_uid_shop';
15
+ export const COOKIE_PREFIX = '__sa_';
16
+ export const COOKIE_MAX_AGE_SECONDS = 400 * 24 * 3600;
17
+
18
+ export const CLICK_ID_PARAMS = Object.freeze(['gclid', 'gbraid', 'wbraid', 'fbclid', 'ttclid', 'msclkid', 'oppref', 'epik', 'srsltid', 'trailiq']);
19
+ export const UTM_PARAMS = Object.freeze(['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'utm_id']);
20
+
21
+ // Read by the ScaleTrail pixel in the browser, so it cannot be HttpOnly. All others are.
22
+ const SCRIPT_READABLE = new Set([`${COOKIE_PREFIX}oppref`]);
23
+
24
+ const CLICK_ID_RE = /^[A-Za-z0-9_-]{20,200}$/;
25
+ const TRAILIQ_RE = /^[A-Za-z0-9_|:.~-]{1,256}$/;
26
+ const UTM_MAX = 200;
27
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
28
+ const MAX_COOKIE_HEADER = 16 * 1024;
29
+ const MAX_URL = 8 * 1024;
30
+
31
+ const BROWSER_ID_COOKIES = Object.freeze({ ga: ['_ga', 64], fbp: ['_fbp', 128], fbc: ['_fbc', 512], ttp: ['_ttp', 128] });
32
+ const BROWSER_ID_RE = /^[A-Za-z0-9_.|:~-]+$/;
33
+
34
+ // Public suffixes with two labels that matter in practice. A host under one of them keeps three labels.
35
+ const MULTI_PART_TLDS = new Set([
36
+ 'co.uk', 'org.uk', 'me.uk', 'net.uk',
37
+ 'com.au', 'net.au', 'org.au',
38
+ 'co.nz', 'net.nz', 'org.nz',
39
+ 'co.jp', 'or.jp', 'ne.jp',
40
+ 'co.za',
41
+ 'co.kr',
42
+ 'com.br', 'net.br', 'org.br',
43
+ 'com.mx', 'com.tr', 'com.sg', 'com.hk',
44
+ ]);
45
+
46
+ const DOMAIN_RE = /^\.?[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)+$/;
47
+
48
+ /**
49
+ * Registrable domain of a host with a leading dot, e.g. "shop.example.ch" -> ".example.ch".
50
+ * @param {string} host
51
+ * @returns {string|null}
52
+ */
53
+ export function deriveCookieDomain(host) {
54
+ if (!host || typeof host !== 'string') return null;
55
+ const clean = host.toLowerCase().trim().split(':')[0];
56
+ if (!clean || !clean.includes('.')) return null;
57
+ const parts = clean.split('.').filter(Boolean);
58
+ if (parts.length < 2) return null;
59
+ const lastTwo = parts.slice(-2).join('.');
60
+ if (MULTI_PART_TLDS.has(lastTwo) && parts.length >= 3) return `.${parts.slice(-3).join('.')}`;
61
+ return `.${parts.slice(-2).join('.')}`;
62
+ }
63
+
64
+ /**
65
+ * Cookie domain for a host. Without an override this is the registrable domain. An override (for a
66
+ * staging host, e.g. "staging.example.ch") applies only if it is the host or one of its parents and
67
+ * not wider than the registrable domain; otherwise the registrable domain is used.
68
+ * @param {string} host
69
+ * @param {string} [override]
70
+ * @returns {string|null}
71
+ */
72
+ export function cookieDomainFor(host, override) {
73
+ const derived = deriveCookieDomain(host);
74
+ if (!derived || typeof override !== 'string' || override.length > 253 || !DOMAIN_RE.test(override)) return derived;
75
+ const dotted = override.startsWith('.') ? override : `.${override}`;
76
+ const hostDotted = `.${host.toLowerCase().trim().split(':')[0]}`;
77
+ if (!hostDotted.endsWith(dotted)) return derived;
78
+ if (!dotted.endsWith(derived)) return derived;
79
+ return dotted;
80
+ }
81
+
82
+ /** Click ID value if valid, else null. Length is checked before the pattern. */
83
+ export function cleanClickId(param, value) {
84
+ if (typeof value !== 'string') return null;
85
+ if (param === 'trailiq') return value.length <= 256 && TRAILIQ_RE.test(value) ? value : null;
86
+ return value.length <= 200 && CLICK_ID_RE.test(value) ? value : null;
87
+ }
88
+
89
+ /** UTM value if valid (1 to 200 characters, no control characters), else null. */
90
+ export function cleanUtm(value) {
91
+ if (typeof value !== 'string') return null;
92
+ const v = value.trim();
93
+ return v.length > 0 && v.length <= UTM_MAX && !CONTROL_RE.test(v) ? v : null;
94
+ }
95
+
96
+ /**
97
+ * Parses a Cookie header. First occurrence of a name wins (the browser sends the most specific first).
98
+ * @param {string|null|undefined} header
99
+ * @returns {Map<string, string>}
100
+ */
101
+ export function parseCookieHeader(header) {
102
+ const out = new Map();
103
+ if (typeof header !== 'string' || header.length === 0 || header.length > MAX_COOKIE_HEADER) return out;
104
+ for (const part of header.split(';')) {
105
+ const i = part.indexOf('=');
106
+ if (i <= 0) continue;
107
+ const name = part.slice(0, i).trim();
108
+ if (!name || out.has(name)) continue;
109
+ out.set(name, part.slice(i + 1).trim());
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function decodeUtmCookie(raw) {
115
+ try {
116
+ return cleanUtm(decodeURIComponent(raw));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function serialize(name, value, domain, maxAge) {
123
+ const attrs = [`${name}=${value}`];
124
+ if (domain) attrs.push(`Domain=${domain}`);
125
+ attrs.push('Path=/', `Max-Age=${maxAge}`, 'Secure', 'SameSite=Lax');
126
+ if (!SCRIPT_READABLE.has(name)) attrs.push('HttpOnly');
127
+ return attrs.join('; ');
128
+ }
129
+
130
+ function readRequest(input) {
131
+ if (input && typeof input.url === 'string' && input.headers && typeof input.headers.get === 'function') {
132
+ return { url: input.url, cookie: input.headers.get('Cookie') };
133
+ }
134
+ if (input && typeof input.url === 'string') return { url: input.url, cookie: input.cookie ?? null };
135
+ throw new TypeError('edgeCookies needs a Request or {url, cookie}');
136
+ }
137
+
138
+ /**
139
+ * Cookies to set on the response for one request.
140
+ *
141
+ * @param {Request|{url: string, cookie?: string|null}} input
142
+ * @param {{
143
+ * cookieDomain?: string,
144
+ * mode?: 'always'|'after_consent',
145
+ * marketingConsent?: boolean|null,
146
+ * nowMs?: number,
147
+ * }} [opts]
148
+ * cookieDomain override for staging hosts, see cookieDomainFor
149
+ * mode 'always' (default): click ID and UTM cookies on every landing.
150
+ * 'after_consent': only with marketingConsent === true.
151
+ * @returns {{setCookie: string[], visitorId: string, newVisitor: boolean, landing: boolean}}
152
+ */
153
+ export function edgeCookies(input, { cookieDomain, mode = 'always', marketingConsent = null, nowMs } = {}) {
154
+ if (mode !== 'always' && mode !== 'after_consent') throw new TypeError("mode must be 'always' or 'after_consent'");
155
+ const { url, cookie } = readRequest(input);
156
+ const parsed = new URL(url);
157
+ const domain = cookieDomainFor(parsed.hostname, cookieDomain);
158
+ // An oversized address never breaks the page; it just does not count as a landing.
159
+ const params = url.length <= MAX_URL ? parsed.searchParams : new URLSearchParams();
160
+ const jar = parseCookieHeader(cookie);
161
+ const setCookie = [];
162
+
163
+ const existing = normalizeVisitorId(jar.get(VISITOR_COOKIE)) || normalizeVisitorId(jar.get(MIRROR_COOKIE));
164
+ const visitorId = existing || uuidV7(nowMs);
165
+ setCookie.push(serialize(MIRROR_COOKIE, visitorId, domain, COOKIE_MAX_AGE_SECONDS));
166
+
167
+ const clickIds = {};
168
+ for (const p of CLICK_ID_PARAMS) {
169
+ const v = cleanClickId(p, params.get(p));
170
+ if (v) clickIds[p] = v;
171
+ }
172
+ // Some ad setups pass the Google click ID as ggclid.
173
+ if (!clickIds.gclid) {
174
+ const g = cleanClickId('gclid', params.get('ggclid'));
175
+ if (g) clickIds.gclid = g;
176
+ }
177
+ const utm = {};
178
+ for (const p of UTM_PARAMS) {
179
+ const v = cleanUtm(params.get(p));
180
+ if (v) utm[p] = v;
181
+ }
182
+ const hasUtm = Object.keys(utm).length > 0;
183
+ const landing = Object.keys(clickIds).length > 0 || hasUtm;
184
+ const allowed = mode === 'always' || marketingConsent === true;
185
+
186
+ if (landing && allowed) {
187
+ for (const [p, v] of Object.entries(clickIds)) setCookie.push(serialize(`${COOKIE_PREFIX}${p}`, v, domain, COOKIE_MAX_AGE_SECONDS));
188
+ // UTM parameters belong together: a new campaign replaces all of them, absent ones are removed.
189
+ if (hasUtm) {
190
+ for (const p of UTM_PARAMS) {
191
+ const name = `${COOKIE_PREFIX}${p}`;
192
+ if (utm[p]) setCookie.push(serialize(name, encodeURIComponent(utm[p]), domain, COOKIE_MAX_AGE_SECONDS));
193
+ else if (jar.has(name)) setCookie.push(serialize(name, '', domain, 0));
194
+ }
195
+ }
196
+ }
197
+ return { setCookie, visitorId, newVisitor: !existing, landing };
198
+ }
199
+
200
+ /**
201
+ * Copy of a response with the cookies appended and Cache-Control set to private, so no shared cache
202
+ * stores it. Use on the response that goes to the browser, after any cache lookup.
203
+ * @param {Response} response
204
+ * @param {{setCookie: string[]}} cookies
205
+ * @returns {Response}
206
+ */
207
+ export function applyEdgeCookies(response, { setCookie }) {
208
+ const headers = new Headers(response.headers);
209
+ for (const c of setCookie) headers.append('Set-Cookie', c);
210
+ const kept = (headers.get('Cache-Control') || '')
211
+ .split(',').map((d) => d.trim()).filter((d) => d && !/^(public|private|s-maxage=)/i.test(d));
212
+ headers.set('Cache-Control', ['private', ...kept].join(', '));
213
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
214
+ }
215
+
216
+ /**
217
+ * Attribution values from the request cookies, in the shape of `attribution` in an order snapshot.
218
+ * Every value is checked again: cookies are client input. Store the result with the cart and copy
219
+ * it into the order before payment. Add landing_url, referrer and captured_at yourself.
220
+ * @param {string|null|undefined} cookieHeader
221
+ * @returns {{sa_uid?: string, click_ids?: Record<string, string>, utm?: Record<string, string>, browser_ids?: Record<string, string>}}
222
+ */
223
+ export function readAttribution(cookieHeader) {
224
+ const jar = parseCookieHeader(cookieHeader);
225
+ const out = {};
226
+ const uid = normalizeVisitorId(jar.get(VISITOR_COOKIE)) || normalizeVisitorId(jar.get(MIRROR_COOKIE));
227
+ if (uid) out.sa_uid = uid;
228
+
229
+ const clickIds = {};
230
+ for (const p of CLICK_ID_PARAMS) {
231
+ const v = cleanClickId(p, jar.get(`${COOKIE_PREFIX}${p}`));
232
+ if (v) clickIds[p] = v;
233
+ }
234
+ if (Object.keys(clickIds).length) out.click_ids = clickIds;
235
+
236
+ const utm = {};
237
+ for (const p of UTM_PARAMS) {
238
+ const raw = jar.get(`${COOKIE_PREFIX}${p}`);
239
+ const v = raw ? decodeUtmCookie(raw) : null;
240
+ if (v) utm[p.slice(4)] = v;
241
+ }
242
+ if (Object.keys(utm).length) out.utm = utm;
243
+
244
+ const browserIds = {};
245
+ for (const [key, [name, max]] of Object.entries(BROWSER_ID_COOKIES)) {
246
+ const v = jar.get(name);
247
+ if (typeof v === 'string' && v.length > 0 && v.length <= max && BROWSER_ID_RE.test(v)) browserIds[key] = v;
248
+ }
249
+ if (Object.keys(browserIds).length) out.browser_ids = browserIds;
250
+ return out;
251
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { Order, EventAddToCart, EventLoginIdentity, Redact, Attribution } from './types.js';
2
+
3
+ export * from './types.js';
4
+
5
+ export const VERSION: string;
6
+ export const SCHEMA_VERSION: string;
7
+ export const SCHEMA: Readonly<Record<string, unknown>>;
8
+
9
+ // Visitor IDs
10
+ export function uuidV7(nowMs?: number): string;
11
+ export function isVisitorId(value: unknown): value is string;
12
+ export function normalizeVisitorId(value: unknown): string | null;
13
+
14
+ // Signing
15
+ export const TIMESTAMP_TOLERANCE_SECONDS: number;
16
+ export type Body = string | Uint8Array | ArrayBuffer;
17
+ export function computeSignature(secret: string, timestamp: number, body: Body): Promise<string>;
18
+ export function connectorHeader(name?: string): string;
19
+ export function signRequest(opts: {
20
+ keyId: string;
21
+ secret: string;
22
+ body: Body;
23
+ timestamp?: number;
24
+ connector?: string;
25
+ }): Promise<Record<string, string>>;
26
+ export function verifySignature(opts: {
27
+ secret: string;
28
+ timestamp: number | string;
29
+ body: Body;
30
+ signature: string;
31
+ nowMs?: number;
32
+ }): Promise<boolean>;
33
+
34
+ // Client
35
+ export const API_BASE: string;
36
+ export const MAX_BODY_BYTES: number;
37
+ export const DEFAULT_TIMEOUT_MS: number;
38
+ export const RETRY_SCHEDULE_SECONDS: readonly number[];
39
+ export const RETRY_GIVE_UP_AFTER_SECONDS: number;
40
+
41
+ export type Outcome = 'accepted' | 'valid' | 'rejected' | 'retry';
42
+ export interface SendResult {
43
+ outcome: Outcome;
44
+ status: number | null;
45
+ requestId?: string;
46
+ paths?: string[];
47
+ retryAfterSeconds?: number | null;
48
+ reason?: 'unconfirmed' | 'invalid_payload' | 'unauthorized' | 'not_found' | 'too_large' | 'redirect'
49
+ | 'unexpected_status' | 'timeout' | 'network';
50
+ }
51
+ export interface SendOptions {
52
+ dryRun?: boolean;
53
+ }
54
+ export interface ConnectorClient {
55
+ sendOrder(order: Order, opts?: SendOptions): Promise<SendResult>;
56
+ sendEvent(event: EventAddToCart | EventLoginIdentity, opts?: SendOptions): Promise<SendResult>;
57
+ sendRedact(redact: Redact, opts?: SendOptions): Promise<SendResult>;
58
+ }
59
+ export function createClient(config: {
60
+ keyId: string;
61
+ secret: string;
62
+ connector?: string;
63
+ fetch?: typeof globalThis.fetch;
64
+ timeoutMs?: number;
65
+ }): ConnectorClient;
66
+ export function classifyResponse(res: Response, opts?: SendOptions): Promise<SendResult>;
67
+ export function retryDelaySeconds(attempt: number, opts?: {
68
+ retryAfterSeconds?: number | null;
69
+ firstAttemptAt?: number;
70
+ nowMs?: number;
71
+ }): number | null;
72
+
73
+ // Edge cookies
74
+ export const VISITOR_COOKIE: string;
75
+ export const MIRROR_COOKIE: string;
76
+ export const COOKIE_PREFIX: string;
77
+ export const COOKIE_MAX_AGE_SECONDS: number;
78
+ export const CLICK_ID_PARAMS: readonly string[];
79
+ export const UTM_PARAMS: readonly string[];
80
+ export interface EdgeCookies {
81
+ setCookie: string[];
82
+ visitorId: string;
83
+ newVisitor: boolean;
84
+ landing: boolean;
85
+ }
86
+ export function edgeCookies(
87
+ input: Request | { url: string; cookie?: string | null },
88
+ opts?: {
89
+ cookieDomain?: string;
90
+ mode?: 'always' | 'after_consent';
91
+ marketingConsent?: boolean | null;
92
+ nowMs?: number;
93
+ },
94
+ ): EdgeCookies;
95
+ export function applyEdgeCookies(response: Response, cookies: { setCookie: string[] }): Response;
96
+ export function readAttribution(cookieHeader: string | null | undefined):
97
+ Pick<Attribution, 'sa_uid' | 'click_ids' | 'utm' | 'browser_ids'>;
98
+ export function deriveCookieDomain(host: string): string | null;
99
+ export function cookieDomainFor(host: string, override?: string): string | null;
100
+ export function parseCookieHeader(header: string | null | undefined): Map<string, string>;
101
+ export function cleanClickId(param: string, value: unknown): string | null;
102
+ export function cleanUtm(value: unknown): string | null;
103
+
104
+ // Validation
105
+ export type Route = 'orders' | 'events' | 'redact';
106
+ export function validateMessage(route: Route, message: unknown, opts?: { strict?: boolean }): string[];
107
+ export function validateOrderMessage(message: unknown, opts?: { strict?: boolean }): string[];
108
+ export function validateEventMessage(message: unknown, opts?: { strict?: boolean }): string[];
109
+ export function validateRedactMessage(message: unknown, opts?: { strict?: boolean }): string[];
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { VERSION } from './version.js';
2
+ export { uuidV7, isVisitorId, normalizeVisitorId } from './uuid.js';
3
+ export {
4
+ computeSignature, signRequest, verifySignature, connectorHeader, TIMESTAMP_TOLERANCE_SECONDS,
5
+ } from './signature.js';
6
+ export {
7
+ createClient, classifyResponse, retryDelaySeconds,
8
+ API_BASE, MAX_BODY_BYTES, DEFAULT_TIMEOUT_MS, RETRY_SCHEDULE_SECONDS, RETRY_GIVE_UP_AFTER_SECONDS,
9
+ } from './client.js';
10
+ export {
11
+ edgeCookies, applyEdgeCookies, readAttribution, deriveCookieDomain, cookieDomainFor,
12
+ parseCookieHeader, cleanClickId, cleanUtm,
13
+ VISITOR_COOKIE, MIRROR_COOKIE, COOKIE_PREFIX, COOKIE_MAX_AGE_SECONDS, CLICK_ID_PARAMS, UTM_PARAMS,
14
+ } from './cookies.js';
15
+ export {
16
+ validateMessage, validateOrderMessage, validateEventMessage, validateRedactMessage, SCHEMA_VERSION,
17
+ } from './validate.js';
18
+ export { SCHEMA } from './schema.js';