@openpartner/sdk 0.2.0 → 0.3.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/dist/browser.js CHANGED
@@ -50,28 +50,42 @@ var DEFAULTS = {
50
50
  storageKey: "openpartner:cref",
51
51
  stripe: true,
52
52
  referralCookie: false,
53
+ consent: "granted",
54
+ reportLanding: true,
53
55
  onError: (err) => {
54
- console.warn("[openpartner] identify failed", err);
56
+ console.warn("[openpartner] request failed", err);
55
57
  }
56
58
  };
57
59
  var STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;
58
60
  var STRIPE_EMBED_SELECTOR = "stripe-pricing-table, stripe-buy-button";
61
+ var REFERRAL_COOKIE_DEFAULT = "op_ref";
59
62
  var OpenPartner = class _OpenPartner {
60
63
  constructor(config) {
61
64
  __publicField(this, "config");
62
65
  __publicField(this, "observer", null);
66
+ __publicField(this, "consent");
67
+ /** The URL's referral while consent is pending, held in memory only. */
68
+ __publicField(this, "held", null);
69
+ /** This page's referral, kept here too: where localStorage throws it is
70
+ * the only copy until the cookie is written. */
71
+ __publicField(this, "current", null);
72
+ /** The Stripe links and embeds this SDK tagged, and with what, so a
73
+ * withdrawn consent can take the tags back off. */
74
+ __publicField(this, "tagged", []);
63
75
  this.config = { ...DEFAULTS, ...config };
64
- this.captureCref();
65
- this.writeReferralCookie();
66
- if (this.config.stripe !== false) this.watchStripe();
76
+ this.consent = this.config.consent;
77
+ if (this.consent === "granted") this.start();
78
+ else if (this.consent === "pending") this.held = this.queryCref();
79
+ else this.clear();
67
80
  }
68
81
  static init(config) {
69
82
  return new _OpenPartner(config);
70
83
  }
71
- /** The cref we're holding onto, if any. Useful for debugging. */
84
+ /** The cref we're holding onto, if any. Useful for debugging. Null until
85
+ * consent is granted. */
72
86
  getCref() {
73
- if (typeof localStorage === "undefined") return null;
74
- return localStorage.getItem(this.config.storageKey);
87
+ if (this.consent !== "granted") return null;
88
+ return this.storageGet() ?? this.current ?? this.readCookie(this.referralCookieName());
75
89
  }
76
90
  /**
77
91
  * Captured referral token for this visitor. Pass to Stripe Checkout
@@ -83,15 +97,44 @@ var OpenPartner = class _OpenPartner {
83
97
  * // ...
84
98
  * });
85
99
  *
86
- * Returns null if the visitor didn't arrive via a partner link.
100
+ * Returns null if the visitor didn't arrive via a partner link, or
101
+ * hasn't agreed to tracking yet.
87
102
  */
88
103
  getReferral() {
89
104
  return this.getCref();
90
105
  }
91
- /** Explicitly discard the captured cref. */
106
+ /** Explicitly discard the captured cref, the referral cookie, and the
107
+ * references this SDK added to Stripe links and embeds. */
92
108
  clear() {
93
- if (typeof localStorage === "undefined") return;
94
- localStorage.removeItem(this.config.storageKey);
109
+ this.held = null;
110
+ this.current = null;
111
+ this.untagStripe();
112
+ this.storageRemove();
113
+ if (typeof document === "undefined") return;
114
+ const name = this.referralCookieName();
115
+ if (!this.readCookie(name)) return;
116
+ const cfg = this.config.referralCookie;
117
+ document.cookie = `${name}=; Max-Age=0; Path=/`;
118
+ if (cfg && cfg.domain) document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${cfg.domain}`;
119
+ }
120
+ /**
121
+ * Record the visitor's answer to your consent banner. `'granted'` stores
122
+ * the referral (the one held since the page loaded, or one already
123
+ * stored), starts tagging Stripe links and sends the landing report.
124
+ * `'denied'` stops all of it, takes the references it added off Stripe
125
+ * links and embeds, and deletes what was stored. Calling it again is
126
+ * safe.
127
+ */
128
+ setConsent(state) {
129
+ if (state === "granted") {
130
+ if (this.consent === "granted") return;
131
+ this.consent = state;
132
+ this.start();
133
+ return;
134
+ }
135
+ this.consent = state;
136
+ this.stopLinkingStripe();
137
+ this.clear();
95
138
  }
96
139
  /**
97
140
  * Add the referral to every Stripe Payment Link and Pricing Table / Buy
@@ -118,6 +161,7 @@ var OpenPartner = class _OpenPartner {
118
161
  if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has("client_reference_id")) continue;
119
162
  url.searchParams.set("client_reference_id", referral);
120
163
  a.href = url.toString();
164
+ this.remember(a, referral);
121
165
  changed += 1;
122
166
  }
123
167
  const embeds = [];
@@ -126,10 +170,35 @@ var OpenPartner = class _OpenPartner {
126
170
  for (const el of embeds) {
127
171
  if (el.hasAttribute("client-reference-id")) continue;
128
172
  el.setAttribute("client-reference-id", referral);
173
+ this.remember(el, referral);
129
174
  changed += 1;
130
175
  }
131
176
  return changed;
132
177
  }
178
+ remember(el, value) {
179
+ this.tagged = this.tagged.filter((t) => t.el.isConnected);
180
+ this.tagged.push({ el, value });
181
+ }
182
+ /** Take back the references this SDK added. One the brand set, or one
183
+ * changed since, stays. */
184
+ untagStripe() {
185
+ for (const { el, value } of this.tagged) {
186
+ if (el instanceof HTMLAnchorElement) {
187
+ let url;
188
+ try {
189
+ url = new URL(el.href);
190
+ } catch {
191
+ continue;
192
+ }
193
+ if (url.searchParams.get("client_reference_id") !== value) continue;
194
+ url.searchParams.delete("client_reference_id");
195
+ el.href = url.toString();
196
+ } else if (el.getAttribute("client-reference-id") === value) {
197
+ el.removeAttribute("client-reference-id");
198
+ }
199
+ }
200
+ this.tagged = [];
201
+ }
133
202
  /** Stop watching the page for new Stripe links and embeds. */
134
203
  stopLinkingStripe() {
135
204
  this.observer?.disconnect();
@@ -139,6 +208,14 @@ var OpenPartner = class _OpenPartner {
139
208
  const cfg = this.config.stripe;
140
209
  return typeof cfg === "object" && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];
141
210
  }
211
+ /** Everything the SDK does once it may: store, mirror, link, report. */
212
+ start() {
213
+ const landed = this.captureCref(this.held);
214
+ this.held = null;
215
+ this.writeReferralCookie();
216
+ if (this.config.stripe !== false) this.watchStripe();
217
+ if (landed && this.config.reportLanding !== false) void this.reportLanding(landed);
218
+ }
142
219
  /**
143
220
  * Link what's on the page now, then anything added or re-pointed later
144
221
  * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
@@ -150,7 +227,7 @@ var OpenPartner = class _OpenPartner {
150
227
  * element is parsed, before Stripe upgrades it.
151
228
  */
152
229
  watchStripe() {
153
- if (typeof document === "undefined" || !this.getReferral()) return;
230
+ if (typeof document === "undefined" || this.observer || !this.getReferral()) return;
154
231
  this.linkStripe(document);
155
232
  if (typeof MutationObserver === "undefined") return;
156
233
  this.observer = new MutationObserver((records) => {
@@ -171,32 +248,52 @@ var OpenPartner = class _OpenPartner {
171
248
  attributeFilter: ["href"]
172
249
  });
173
250
  }
251
+ /** The cref on the landing URL, if any. */
252
+ queryCref() {
253
+ if (typeof window === "undefined") return null;
254
+ return new URL(window.location.href).searchParams.get(this.config.queryParam);
255
+ }
174
256
  /**
175
- * Capture the cref from query string or cookie and stash in
176
- * localStorage. Runs on init so the value survives SPA navigations,
177
- * ITP cookie expiry, and subsequent page loads.
257
+ * Capture the cref from the URL (or the one held while consent was
258
+ * pending), else the router's cookie, else the referral cookie, and
259
+ * stash it in localStorage so it survives SPA navigations and later
260
+ * page loads. Returns the cref when it's a click arriving now, one this
261
+ * browser didn't have yet, for the landing report.
178
262
  */
179
- captureCref() {
180
- if (typeof window === "undefined" || typeof localStorage === "undefined") return;
181
- const url = new URL(window.location.href);
182
- const queryCref = url.searchParams.get(this.config.queryParam);
183
- if (queryCref) {
184
- localStorage.setItem(this.config.storageKey, queryCref);
185
- return;
263
+ captureCref(held) {
264
+ if (typeof window === "undefined") return null;
265
+ const incoming = held ?? this.queryCref();
266
+ if (incoming) {
267
+ const isNew = this.storageGet() !== incoming;
268
+ this.current = incoming;
269
+ this.storageSet(incoming);
270
+ return isNew ? incoming : null;
186
271
  }
187
- const cookieCref = this.readCookie(this.config.cookieName);
188
- if (cookieCref && !localStorage.getItem(this.config.storageKey)) {
189
- localStorage.setItem(this.config.storageKey, cookieCref);
272
+ if (this.storageGet()) return null;
273
+ const restored = this.readCookie(this.config.cookieName) ?? this.readCookie(this.referralCookieName());
274
+ if (restored) {
275
+ this.current = restored;
276
+ this.storageSet(restored);
190
277
  }
278
+ return null;
279
+ }
280
+ referralCookieName() {
281
+ const cfg = this.config.referralCookie;
282
+ return cfg && cfg.name || REFERRAL_COOKIE_DEFAULT;
191
283
  }
192
- /** Mirror the referral into the brand's own first-party cookie (when
193
- * configured), refreshed on every page load that has one. */
284
+ /**
285
+ * Mirror the referral into the brand's own first-party cookie (when
286
+ * configured). Left alone when it already holds this referral: in Safari
287
+ * rewriting it from script would trade a server-set cookie's full
288
+ * lifetime for the 7-day cap. A new click overwrites it.
289
+ */
194
290
  writeReferralCookie() {
195
291
  const cfg = this.config.referralCookie;
196
292
  if (!cfg || typeof document === "undefined") return;
197
293
  const referral = this.getReferral();
198
294
  if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;
199
- const name = cfg.name ?? "op_ref";
295
+ const name = this.referralCookieName();
296
+ if (this.readCookie(name) === referral) return;
200
297
  const maxAge = Math.round((cfg.days ?? 90) * 86400);
201
298
  const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
202
299
  const domain = cfg.domain ? `; Domain=${cfg.domain}` : "";
@@ -205,16 +302,55 @@ var OpenPartner = class _OpenPartner {
205
302
  readCookie(name) {
206
303
  if (typeof document === "undefined") return null;
207
304
  const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
208
- return match ? decodeURIComponent(match[2]) : null;
305
+ if (!match) return null;
306
+ try {
307
+ return decodeURIComponent(match[2]);
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+ // localStorage throws in some sandboxed iframes and with site data
313
+ // blocked; the referral then lives in the cookie alone.
314
+ storageGet() {
315
+ try {
316
+ return typeof localStorage === "undefined" ? null : localStorage.getItem(this.config.storageKey);
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+ storageSet(value) {
322
+ try {
323
+ if (typeof localStorage !== "undefined") localStorage.setItem(this.config.storageKey, value);
324
+ } catch {
325
+ }
326
+ }
327
+ storageRemove() {
328
+ try {
329
+ if (typeof localStorage !== "undefined") localStorage.removeItem(this.config.storageKey);
330
+ } catch {
331
+ }
332
+ }
333
+ /** Tell OpenPartner the click arrived. Best-effort, once per click. */
334
+ async reportLanding(cref) {
335
+ if (!STRIPE_REFERENCE_RE.test(cref) || typeof fetch === "undefined") return;
336
+ try {
337
+ await requestJson(`${this.config.apiUrl}/attribution/landing`, {
338
+ method: "POST",
339
+ body: { cref },
340
+ keepalive: true
341
+ });
342
+ } catch (err) {
343
+ if (err instanceof OpenPartnerError && err.code === "click_not_found") this.config.onError(err);
344
+ }
209
345
  }
210
346
  /**
211
347
  * Stitch the current captured click to an authenticated user. Call
212
348
  * this on login, signup, or anywhere the user becomes known.
213
349
  *
214
- * No-op if no cref was captured. Errors are reported through
215
- * `config.onError` by default — attribution is best-effort from the
216
- * browser — but you can await this and try/catch if you want to
217
- * surface failures explicitly.
350
+ * No-op if no cref was captured, or consent isn't granted. Errors are
351
+ * reported through `config.onError` by default — attribution is
352
+ * best-effort from the browser — but you can await this and try/catch
353
+ * if you want to surface failures explicitly.
218
354
  */
219
355
  async identify(userId) {
220
356
  const cref = this.getCref();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/shared.ts","../src/browser.ts"],"sourcesContent":["/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n","/**\n * Browser SDK — runs inside a merchant's web app. Captures the `cref`\n * attribution token from landing-page URLs and the first-party cookie\n * the click router sets, stashes it in localStorage so it survives\n * Safari / ITP cookie expiry and SPA navigations, and stitches to the\n * authenticated user on signup or login.\n *\n * Typical integration:\n *\n * import { OpenPartner } from '@openpartner/sdk';\n *\n * const op = OpenPartner.init({ apiUrl: 'https://openpartner.example.com' });\n *\n * // After login/signup, once you know who the user is:\n * await op.identify(user.id);\n *\n * With a captured referral it also hands the click to Stripe on its own:\n * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe\n * Pricing Table / Buy Button embeds get `client-reference-id`, including\n * ones added to the page later. A brand whose Stripe account is connected\n * to OpenPartner then needs no code for those checkouts. `stripe: false`\n * turns it off.\n *\n * No bundler? The same SDK ships as a script tag that starts itself\n * (auto.ts), served at /op.js by an OpenPartner portal.\n */\n\nimport { OpenPartnerError, requestJson, type IdentifyResponse } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { IdentifyResponse };\n\nexport interface StripeLinkingConfig {\n /** Extra Payment Link hosts — a custom domain set up for Payment Links\n * in Stripe (e.g. \"pay.yourbrand.com\"). buy.stripe.com is always on. */\n linkHosts?: string[];\n}\n\nexport interface OpenPartnerConfig {\n /** Base URL of your OpenPartner API, e.g. \"https://openpartner.example.com\". */\n apiUrl: string;\n /** First-party cookie name set by the click router. Default `_cref`. */\n cookieName?: string;\n /** Query-string parameter that carries the cref on the landing URL. Default `cref`. */\n queryParam?: string;\n /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */\n storageKey?: string;\n /**\n * Hand the captured referral to Stripe Payment Links and Pricing Table /\n * Buy Button embeds on the page. Default on; `false` turns it off.\n */\n stripe?: boolean | StripeLinkingConfig;\n /**\n * Also keep the referral in a first-party cookie, so your server can read\n * it straight off the request when it creates a Stripe Checkout Session\n * (`client_reference_id: req.cookies.op_ref`). Off by default; the\n * script-tag build turns it on. `domain` shares it across subdomains\n * (\".yourbrand.com\") when visitors land on one and check out on another.\n */\n referralCookie?: false | { name?: string; domain?: string; days?: number };\n /**\n * Called when identify() fails. Default: log to console. Pass a noop\n * to suppress entirely or hook into your observability stack.\n */\n onError?: (err: unknown) => void;\n}\n\nconst DEFAULTS = {\n cookieName: '_cref',\n queryParam: 'cref',\n storageKey: 'openpartner:cref',\n stripe: true,\n referralCookie: false,\n onError: (err: unknown) => {\n console.warn('[openpartner] identify failed', err);\n },\n};\n\n/** Stripe accepts a client_reference_id of letters, digits, \"-\" and \"_\",\n * up to 200, and silently drops anything else. A click id always fits; a\n * value that doesn't came from somewhere else and isn't sent. */\nconst STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;\nconst STRIPE_EMBED_SELECTOR = 'stripe-pricing-table, stripe-buy-button';\n\nexport class OpenPartner {\n private config: Required<OpenPartnerConfig>;\n private observer: MutationObserver | null = null;\n\n private constructor(config: OpenPartnerConfig) {\n this.config = { ...DEFAULTS, ...config } as Required<OpenPartnerConfig>;\n this.captureCref();\n this.writeReferralCookie();\n if (this.config.stripe !== false) this.watchStripe();\n }\n\n static init(config: OpenPartnerConfig): OpenPartner {\n return new OpenPartner(config);\n }\n\n /** The cref we're holding onto, if any. Useful for debugging. */\n getCref(): string | null {\n if (typeof localStorage === 'undefined') return null;\n return localStorage.getItem(this.config.storageKey);\n }\n\n /**\n * Captured referral token for this visitor. Pass to Stripe Checkout\n * as `client_reference_id` to enable webhook-based attribution\n * without an explicit identify() call:\n *\n * stripe.checkout.sessions.create({\n * client_reference_id: op.getReferral() ?? undefined,\n * // ...\n * });\n *\n * Returns null if the visitor didn't arrive via a partner link.\n */\n getReferral(): string | null {\n return this.getCref();\n }\n\n /** Explicitly discard the captured cref. */\n clear(): void {\n if (typeof localStorage === 'undefined') return;\n localStorage.removeItem(this.config.storageKey);\n }\n\n /**\n * Add the referral to every Stripe Payment Link and Pricing Table / Buy\n * Button embed under `root` that doesn't carry a reference already.\n * Runs on its own at init and for anything added later; call it yourself\n * only if you build Stripe elements inside a shadow root. Returns how\n * many it changed.\n */\n linkStripe(root: ParentNode = document): number {\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === 'undefined') return 0;\n const hosts = new Set(['buy.stripe.com', ...this.stripeLinkHosts()]);\n let changed = 0;\n\n const anchors: HTMLAnchorElement[] = [];\n if (root instanceof HTMLAnchorElement) anchors.push(root);\n anchors.push(...Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]')));\n for (const a of anchors) {\n let url: URL;\n try {\n url = new URL(a.href);\n } catch {\n continue;\n }\n // One the brand set itself (or an earlier pass) stays as it is.\n if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has('client_reference_id')) continue;\n url.searchParams.set('client_reference_id', referral);\n a.href = url.toString();\n changed += 1;\n }\n\n const embeds: Element[] = [];\n if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);\n embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));\n for (const el of embeds) {\n if (el.hasAttribute('client-reference-id')) continue;\n el.setAttribute('client-reference-id', referral);\n changed += 1;\n }\n return changed;\n }\n\n /** Stop watching the page for new Stripe links and embeds. */\n stopLinkingStripe(): void {\n this.observer?.disconnect();\n this.observer = null;\n }\n\n private stripeLinkHosts(): string[] {\n const cfg = this.config.stripe;\n return typeof cfg === 'object' && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];\n }\n\n /**\n * Link what's on the page now, then anything added or re-pointed later\n * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor\n * who didn't come from a partner link costs nothing.\n *\n * Pricing Table and Buy Button embeds read client-reference-id when\n * Stripe's script renders them, so the script tag belongs in <head>,\n * ahead of the embed: the observer then sets the attribute as the\n * element is parsed, before Stripe upgrades it.\n */\n private watchStripe(): void {\n if (typeof document === 'undefined' || !this.getReferral()) return;\n this.linkStripe(document);\n if (typeof MutationObserver === 'undefined') return;\n this.observer = new MutationObserver((records) => {\n for (const record of records) {\n if (record.type === 'attributes') {\n // An href re-pointed at a Payment Link.\n if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);\n continue;\n }\n for (const node of Array.from(record.addedNodes)) {\n if (node instanceof Element) this.linkStripe(node);\n }\n }\n });\n // Our own edits only ever add a missing reference, so the second pass\n // they trigger finds nothing to change.\n this.observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['href'],\n });\n }\n\n /**\n * Capture the cref from query string or cookie and stash in\n * localStorage. Runs on init so the value survives SPA navigations,\n * ITP cookie expiry, and subsequent page loads.\n */\n private captureCref(): void {\n if (typeof window === 'undefined' || typeof localStorage === 'undefined') return;\n\n const url = new URL(window.location.href);\n const queryCref = url.searchParams.get(this.config.queryParam);\n\n if (queryCref) {\n localStorage.setItem(this.config.storageKey, queryCref);\n return;\n }\n\n const cookieCref = this.readCookie(this.config.cookieName);\n if (cookieCref && !localStorage.getItem(this.config.storageKey)) {\n localStorage.setItem(this.config.storageKey, cookieCref);\n }\n }\n\n /** Mirror the referral into the brand's own first-party cookie (when\n * configured), refreshed on every page load that has one. */\n private writeReferralCookie(): void {\n const cfg = this.config.referralCookie;\n if (!cfg || typeof document === 'undefined') return;\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;\n const name = cfg.name ?? 'op_ref';\n const maxAge = Math.round((cfg.days ?? 90) * 86400);\n const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';\n const domain = cfg.domain ? `; Domain=${cfg.domain}` : '';\n document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;\n }\n\n private readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null;\n const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));\n return match ? decodeURIComponent(match[2]!) : null;\n }\n\n /**\n * Stitch the current captured click to an authenticated user. Call\n * this on login, signup, or anywhere the user becomes known.\n *\n * No-op if no cref was captured. Errors are reported through\n * `config.onError` by default — attribution is best-effort from the\n * browser — but you can await this and try/catch if you want to\n * surface failures explicitly.\n */\n async identify(userId: string): Promise<IdentifyResponse | null> {\n const cref = this.getCref();\n if (!cref) return null;\n\n try {\n return await requestJson<IdentifyResponse>(`${this.config.apiUrl}/attribution/identify`, {\n method: 'POST',\n body: { cref, userId, ts: Date.now() },\n keepalive: true,\n });\n } catch (err) {\n this.config.onError(err);\n return null;\n }\n }\n}\n"],"mappings":";;;;;AASO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YACE,SACgB,QACA,MACA,QAChB;AACA,UAAM,OAAO;AAJG;AACA;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAwDA,eAAsB,YACpB,OACA,OAAwB,CAAC,GACzB,YAA0B,WAAW,OACzB;AACZ,QAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,MAAI,KAAK,SAAS,UAAa,CAAC,QAAQ,IAAI,cAAc,GAAG;AAC3D,YAAQ,IAAI,gBAAgB,kBAAkB;AAAA,EAChD;AACA,QAAM,MAAM,MAAM,UAAU,OAAO;AAAA,IACjC,GAAG;AAAA,IACH;AAAA,IACA,MACE,KAAK,SAAS,SACV,SACA,OAAO,KAAK,SAAS,WACnB,KAAK,OACL,KAAK,UAAU,KAAK,IAAI;AAAA,EAClC,CAAC;AAED,QAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,MAAI,OAAgB;AACpB,MAAI,MAAM;AACR,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,UAAW,QAAQ,CAAC;AAC1B,UAAM,IAAI;AAAA,MACR,QAAQ,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AAAA,MAChD,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,QAAQ,UAAU;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ACjDA,IAAM,WAAW;AAAA,EACf,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,gBAAgB;AAAA,EAChB,SAAS,CAAC,QAAiB;AACzB,YAAQ,KAAK,iCAAiC,GAAG;AAAA,EACnD;AACF;AAKA,IAAM,sBAAsB;AAC5B,IAAM,wBAAwB;AAEvB,IAAM,cAAN,MAAM,aAAY;AAAA,EAIf,YAAY,QAA2B;AAH/C,wBAAQ;AACR,wBAAQ,YAAoC;AAG1C,SAAK,SAAS,EAAE,GAAG,UAAU,GAAG,OAAO;AACvC,SAAK,YAAY;AACjB,SAAK,oBAAoB;AACzB,QAAI,KAAK,OAAO,WAAW,MAAO,MAAK,YAAY;AAAA,EACrD;AAAA,EAEA,OAAO,KAAK,QAAwC;AAClD,WAAO,IAAI,aAAY,MAAM;AAAA,EAC/B;AAAA;AAAA,EAGA,UAAyB;AACvB,QAAI,OAAO,iBAAiB,YAAa,QAAO;AAChD,WAAO,aAAa,QAAQ,KAAK,OAAO,UAAU;AAAA,EACpD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,cAA6B;AAC3B,WAAO,KAAK,QAAQ;AAAA,EACtB;AAAA;AAAA,EAGA,QAAc;AACZ,QAAI,OAAO,iBAAiB,YAAa;AACzC,iBAAa,WAAW,KAAK,OAAO,UAAU;AAAA,EAChD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,WAAW,OAAmB,UAAkB;AAC9C,UAAM,WAAW,KAAK,YAAY;AAClC,QAAI,CAAC,YAAY,CAAC,oBAAoB,KAAK,QAAQ,KAAK,OAAO,aAAa,YAAa,QAAO;AAChG,UAAM,QAAQ,oBAAI,IAAI,CAAC,kBAAkB,GAAG,KAAK,gBAAgB,CAAC,CAAC;AACnE,QAAI,UAAU;AAEd,UAAM,UAA+B,CAAC;AACtC,QAAI,gBAAgB,kBAAmB,SAAQ,KAAK,IAAI;AACxD,YAAQ,KAAK,GAAG,MAAM,KAAK,KAAK,iBAAoC,SAAS,CAAC,CAAC;AAC/E,eAAW,KAAK,SAAS;AACvB,UAAI;AACJ,UAAI;AACF,cAAM,IAAI,IAAI,EAAE,IAAI;AAAA,MACtB,QAAQ;AACN;AAAA,MACF;AAEA,UAAI,CAAC,MAAM,IAAI,IAAI,SAAS,YAAY,CAAC,KAAK,IAAI,aAAa,IAAI,qBAAqB,EAAG;AAC3F,UAAI,aAAa,IAAI,uBAAuB,QAAQ;AACpD,QAAE,OAAO,IAAI,SAAS;AACtB,iBAAW;AAAA,IACb;AAEA,UAAM,SAAoB,CAAC;AAC3B,QAAI,gBAAgB,WAAW,KAAK,QAAQ,qBAAqB,EAAG,QAAO,KAAK,IAAI;AACpF,WAAO,KAAK,GAAG,MAAM,KAAK,KAAK,iBAAiB,qBAAqB,CAAC,CAAC;AACvE,eAAW,MAAM,QAAQ;AACvB,UAAI,GAAG,aAAa,qBAAqB,EAAG;AAC5C,SAAG,aAAa,uBAAuB,QAAQ;AAC/C,iBAAW;AAAA,IACb;AACA,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,oBAA0B;AACxB,SAAK,UAAU,WAAW;AAC1B,SAAK,WAAW;AAAA,EAClB;AAAA,EAEQ,kBAA4B;AAClC,UAAM,MAAM,KAAK,OAAO;AACxB,WAAO,OAAO,QAAQ,YAAY,IAAI,YAAY,IAAI,UAAU,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC,EAAE,OAAO,OAAO,IAAI,CAAC;AAAA,EACxH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYQ,cAAoB;AAC1B,QAAI,OAAO,aAAa,eAAe,CAAC,KAAK,YAAY,EAAG;AAC5D,SAAK,WAAW,QAAQ;AACxB,QAAI,OAAO,qBAAqB,YAAa;AAC7C,SAAK,WAAW,IAAI,iBAAiB,CAAC,YAAY;AAChD,iBAAW,UAAU,SAAS;AAC5B,YAAI,OAAO,SAAS,cAAc;AAEhC,cAAI,OAAO,kBAAkB,kBAAmB,MAAK,WAAW,OAAO,MAAM;AAC7E;AAAA,QACF;AACA,mBAAW,QAAQ,MAAM,KAAK,OAAO,UAAU,GAAG;AAChD,cAAI,gBAAgB,QAAS,MAAK,WAAW,IAAI;AAAA,QACnD;AAAA,MACF;AAAA,IACF,CAAC;AAGD,SAAK,SAAS,QAAQ,SAAS,iBAAiB;AAAA,MAC9C,WAAW;AAAA,MACX,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,iBAAiB,CAAC,MAAM;AAAA,IAC1B,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOQ,cAAoB;AAC1B,QAAI,OAAO,WAAW,eAAe,OAAO,iBAAiB,YAAa;AAE1E,UAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AACxC,UAAM,YAAY,IAAI,aAAa,IAAI,KAAK,OAAO,UAAU;AAE7D,QAAI,WAAW;AACb,mBAAa,QAAQ,KAAK,OAAO,YAAY,SAAS;AACtD;AAAA,IACF;AAEA,UAAM,aAAa,KAAK,WAAW,KAAK,OAAO,UAAU;AACzD,QAAI,cAAc,CAAC,aAAa,QAAQ,KAAK,OAAO,UAAU,GAAG;AAC/D,mBAAa,QAAQ,KAAK,OAAO,YAAY,UAAU;AAAA,IACzD;AAAA,EACF;AAAA;AAAA;AAAA,EAIQ,sBAA4B;AAClC,UAAM,MAAM,KAAK,OAAO;AACxB,QAAI,CAAC,OAAO,OAAO,aAAa,YAAa;AAC7C,UAAM,WAAW,KAAK,YAAY;AAClC,QAAI,CAAC,YAAY,CAAC,oBAAoB,KAAK,QAAQ,EAAG;AACtD,UAAM,OAAO,IAAI,QAAQ;AACzB,UAAM,SAAS,KAAK,OAAO,IAAI,QAAQ,MAAM,KAAK;AAClD,UAAM,SAAS,OAAO,aAAa,eAAe,SAAS,aAAa,WAAW,aAAa;AAChG,UAAM,SAAS,IAAI,SAAS,YAAY,IAAI,MAAM,KAAK;AACvD,aAAS,SAAS,GAAG,IAAI,IAAI,mBAAmB,QAAQ,CAAC,aAAa,MAAM,yBAAyB,MAAM,GAAG,MAAM;AAAA,EACtH;AAAA,EAEQ,WAAW,MAA6B;AAC9C,QAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,UAAM,QAAQ,SAAS,OAAO,MAAM,IAAI,OAAO,SAAS,IAAI,UAAU,CAAC;AACvE,WAAO,QAAQ,mBAAmB,MAAM,CAAC,CAAE,IAAI;AAAA,EACjD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,SAAS,QAAkD;AAC/D,UAAM,OAAO,KAAK,QAAQ;AAC1B,QAAI,CAAC,KAAM,QAAO;AAElB,QAAI;AACF,aAAO,MAAM,YAA8B,GAAG,KAAK,OAAO,MAAM,yBAAyB;AAAA,QACvF,QAAQ;AAAA,QACR,MAAM,EAAE,MAAM,QAAQ,IAAI,KAAK,IAAI,EAAE;AAAA,QACrC,WAAW;AAAA,MACb,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,WAAK,OAAO,QAAQ,GAAG;AACvB,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/shared.ts","../src/browser.ts"],"sourcesContent":["/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n","/**\n * Browser SDK — runs inside a merchant's web app. Captures the `cref`\n * attribution token from landing-page URLs and the first-party cookie\n * the click router sets, stashes it in localStorage so it survives SPA\n * navigations and later visits, and stitches to the authenticated user on\n * signup or login.\n *\n * Typical integration:\n *\n * import { OpenPartner } from '@openpartner/sdk';\n *\n * const op = OpenPartner.init({ apiUrl: 'https://openpartner.example.com' });\n *\n * // After login/signup, once you know who the user is:\n * await op.identify(user.id);\n *\n * With a captured referral it also hands the click to Stripe on its own:\n * Payment Links (buy.stripe.com) get `client_reference_id`, and Stripe\n * Pricing Table / Buy Button embeds get `client-reference-id`, including\n * ones added to the page later. A brand whose Stripe account is connected\n * to OpenPartner then needs no code for those checkouts. `stripe: false`\n * turns it off.\n *\n * How long a referral lasts. Safari deletes storage that a page's own\n * scripts wrote (localStorage, and cookies set through document.cookie)\n * after 7 days without a visit, so a visitor who clicks, leaves for a\n * fortnight and comes back to buy without signing up first arrives with\n * nothing. A cookie the brand's own server sets on the landing response\n * isn't capped that way: `referralCookieHeader` in `@openpartner/sdk/server`\n * writes one, and this SDK reads it back (`op_ref`), on every subdomain it\n * covers. Once identify() has run, the stitch lives server-side and no\n * browser storage matters.\n *\n * Consent. `consent: 'pending'` holds the referral in memory until\n * setConsent('granted'): nothing is read from or written to storage or\n * cookies, no Stripe link is tagged and nothing is sent until then.\n *\n * Landing report. When a click first arrives from the URL the SDK tells\n * OpenPartner (POST /attribution/landing), so Admin → Tracking health can\n * count the clicks that never reached a page running it. `reportLanding:\n * false` turns it off.\n *\n * No bundler? The same SDK ships as a script tag that starts itself\n * (auto.ts), served at /op.js by an OpenPartner portal.\n */\n\nimport { OpenPartnerError, requestJson, type IdentifyResponse } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { IdentifyResponse };\n\nexport interface StripeLinkingConfig {\n /** Extra Payment Link hosts — a custom domain set up for Payment Links\n * in Stripe (e.g. \"pay.yourbrand.com\"). buy.stripe.com is always on. */\n linkHosts?: string[];\n}\n\n/** Whether this visitor agreed to referral tracking. */\nexport type ConsentState = 'granted' | 'pending' | 'denied';\n\nexport interface OpenPartnerConfig {\n /** Base URL of your OpenPartner API, e.g. \"https://openpartner.example.com\". */\n apiUrl: string;\n /** First-party cookie name set by the click router. Default `_cref`. */\n cookieName?: string;\n /** Query-string parameter that carries the cref on the landing URL. Default `cref`. */\n queryParam?: string;\n /** localStorage key the captured cref is stashed under. Default `openpartner:cref`. */\n storageKey?: string;\n /**\n * Hand the captured referral to Stripe Payment Links and Pricing Table /\n * Buy Button embeds on the page. Default on; `false` turns it off.\n */\n stripe?: boolean | StripeLinkingConfig;\n /**\n * Also keep the referral in a first-party cookie, so your server can read\n * it straight off the request when it creates a Stripe Checkout Session\n * (`client_reference_id: req.cookies.op_ref`). Off by default; the\n * script-tag build turns it on. `domain` shares it across subdomains\n * (\".yourbrand.com\") when visitors land on one and check out on another.\n *\n * The SDK reads this cookie back whether or not it writes it (`op_ref`\n * unless `name` says otherwise): that's how a page on another subdomain,\n * or a visit after Safari cleared localStorage, finds the referral, and\n * how one your server set (`referralCookieHeader`) reaches identify().\n */\n referralCookie?: false | { name?: string; domain?: string; days?: number };\n /**\n * Whether the visitor has agreed to referral tracking. Default\n * `'granted'`: the SDK stores the referral as soon as it loads. Behind a\n * consent banner, start at `'pending'` and call `setConsent()` from its\n * callback. Until then the referral from the URL is held in memory only,\n * so a visitor who agrees on the landing page is still credited.\n * `'denied'` uses nothing.\n */\n consent?: ConsentState;\n /**\n * Tell OpenPartner when a partner click arrives on your site, so Admin →\n * Tracking health can show clicks lost between the partner link and your\n * page. Sends the click id once per click, nothing about the visitor.\n * Default on; `false` turns it off.\n */\n reportLanding?: boolean;\n /**\n * Called when a call to OpenPartner fails: identify(), or a landing\n * report OpenPartner answered with `click_not_found` (which usually means\n * `apiUrl` names another program; other landing failures are dropped).\n * Default: log to console. Pass a noop to suppress entirely or\n * hook into your observability stack.\n */\n onError?: (err: unknown) => void;\n}\n\nconst DEFAULTS = {\n cookieName: '_cref',\n queryParam: 'cref',\n storageKey: 'openpartner:cref',\n stripe: true,\n referralCookie: false,\n consent: 'granted',\n reportLanding: true,\n onError: (err: unknown) => {\n console.warn('[openpartner] request failed', err);\n },\n};\n\n/** Stripe accepts a client_reference_id of letters, digits, \"-\" and \"_\",\n * up to 200, and silently drops anything else. A click id always fits; a\n * value that doesn't came from somewhere else and isn't sent. */\nconst STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;\nconst STRIPE_EMBED_SELECTOR = 'stripe-pricing-table, stripe-buy-button';\nconst REFERRAL_COOKIE_DEFAULT = 'op_ref';\n\nexport class OpenPartner {\n private config: Required<OpenPartnerConfig>;\n private observer: MutationObserver | null = null;\n private consent: ConsentState;\n /** The URL's referral while consent is pending, held in memory only. */\n private held: string | null = null;\n /** This page's referral, kept here too: where localStorage throws it is\n * the only copy until the cookie is written. */\n private current: string | null = null;\n /** The Stripe links and embeds this SDK tagged, and with what, so a\n * withdrawn consent can take the tags back off. */\n private tagged: Array<{ el: Element; value: string }> = [];\n\n private constructor(config: OpenPartnerConfig) {\n this.config = { ...DEFAULTS, ...config } as Required<OpenPartnerConfig>;\n this.consent = this.config.consent;\n if (this.consent === 'granted') this.start();\n else if (this.consent === 'pending') this.held = this.queryCref();\n // A returning visitor who has since said no: drop what an earlier\n // visit stored.\n else this.clear();\n }\n\n static init(config: OpenPartnerConfig): OpenPartner {\n return new OpenPartner(config);\n }\n\n /** The cref we're holding onto, if any. Useful for debugging. Null until\n * consent is granted. */\n getCref(): string | null {\n if (this.consent !== 'granted') return null;\n return this.storageGet() ?? this.current ?? this.readCookie(this.referralCookieName());\n }\n\n /**\n * Captured referral token for this visitor. Pass to Stripe Checkout\n * as `client_reference_id` to enable webhook-based attribution\n * without an explicit identify() call:\n *\n * stripe.checkout.sessions.create({\n * client_reference_id: op.getReferral() ?? undefined,\n * // ...\n * });\n *\n * Returns null if the visitor didn't arrive via a partner link, or\n * hasn't agreed to tracking yet.\n */\n getReferral(): string | null {\n return this.getCref();\n }\n\n /** Explicitly discard the captured cref, the referral cookie, and the\n * references this SDK added to Stripe links and embeds. */\n clear(): void {\n this.held = null;\n this.current = null;\n this.untagStripe();\n this.storageRemove();\n if (typeof document === 'undefined') return;\n const name = this.referralCookieName();\n if (!this.readCookie(name)) return;\n const cfg = this.config.referralCookie;\n document.cookie = `${name}=; Max-Age=0; Path=/`;\n if (cfg && cfg.domain) document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${cfg.domain}`;\n }\n\n /**\n * Record the visitor's answer to your consent banner. `'granted'` stores\n * the referral (the one held since the page loaded, or one already\n * stored), starts tagging Stripe links and sends the landing report.\n * `'denied'` stops all of it, takes the references it added off Stripe\n * links and embeds, and deletes what was stored. Calling it again is\n * safe.\n */\n setConsent(state: 'granted' | 'denied'): void {\n if (state === 'granted') {\n if (this.consent === 'granted') return;\n this.consent = state;\n this.start();\n return;\n }\n this.consent = state;\n this.stopLinkingStripe();\n this.clear();\n }\n\n /**\n * Add the referral to every Stripe Payment Link and Pricing Table / Buy\n * Button embed under `root` that doesn't carry a reference already.\n * Runs on its own at init and for anything added later; call it yourself\n * only if you build Stripe elements inside a shadow root. Returns how\n * many it changed.\n */\n linkStripe(root: ParentNode = document): number {\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === 'undefined') return 0;\n const hosts = new Set(['buy.stripe.com', ...this.stripeLinkHosts()]);\n let changed = 0;\n\n const anchors: HTMLAnchorElement[] = [];\n if (root instanceof HTMLAnchorElement) anchors.push(root);\n anchors.push(...Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]')));\n for (const a of anchors) {\n let url: URL;\n try {\n url = new URL(a.href);\n } catch {\n continue;\n }\n // One the brand set itself (or an earlier pass) stays as it is.\n if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has('client_reference_id')) continue;\n url.searchParams.set('client_reference_id', referral);\n a.href = url.toString();\n this.remember(a, referral);\n changed += 1;\n }\n\n const embeds: Element[] = [];\n if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);\n embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));\n for (const el of embeds) {\n if (el.hasAttribute('client-reference-id')) continue;\n el.setAttribute('client-reference-id', referral);\n this.remember(el, referral);\n changed += 1;\n }\n return changed;\n }\n\n private remember(el: Element, value: string): void {\n // Elements an SPA has thrown away go, so the list can't grow forever.\n this.tagged = this.tagged.filter((t) => t.el.isConnected);\n this.tagged.push({ el, value });\n }\n\n /** Take back the references this SDK added. One the brand set, or one\n * changed since, stays. */\n private untagStripe(): void {\n for (const { el, value } of this.tagged) {\n if (el instanceof HTMLAnchorElement) {\n let url: URL;\n try {\n url = new URL(el.href);\n } catch {\n continue;\n }\n if (url.searchParams.get('client_reference_id') !== value) continue;\n url.searchParams.delete('client_reference_id');\n el.href = url.toString();\n } else if (el.getAttribute('client-reference-id') === value) {\n el.removeAttribute('client-reference-id');\n }\n }\n this.tagged = [];\n }\n\n /** Stop watching the page for new Stripe links and embeds. */\n stopLinkingStripe(): void {\n this.observer?.disconnect();\n this.observer = null;\n }\n\n private stripeLinkHosts(): string[] {\n const cfg = this.config.stripe;\n return typeof cfg === 'object' && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];\n }\n\n /** Everything the SDK does once it may: store, mirror, link, report. */\n private start(): void {\n const landed = this.captureCref(this.held);\n this.held = null;\n this.writeReferralCookie();\n if (this.config.stripe !== false) this.watchStripe();\n if (landed && this.config.reportLanding !== false) void this.reportLanding(landed);\n }\n\n /**\n * Link what's on the page now, then anything added or re-pointed later\n * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor\n * who didn't come from a partner link costs nothing.\n *\n * Pricing Table and Buy Button embeds read client-reference-id when\n * Stripe's script renders them, so the script tag belongs in <head>,\n * ahead of the embed: the observer then sets the attribute as the\n * element is parsed, before Stripe upgrades it.\n */\n private watchStripe(): void {\n if (typeof document === 'undefined' || this.observer || !this.getReferral()) return;\n this.linkStripe(document);\n if (typeof MutationObserver === 'undefined') return;\n this.observer = new MutationObserver((records) => {\n for (const record of records) {\n if (record.type === 'attributes') {\n // An href re-pointed at a Payment Link.\n if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);\n continue;\n }\n for (const node of Array.from(record.addedNodes)) {\n if (node instanceof Element) this.linkStripe(node);\n }\n }\n });\n // Our own edits only ever add a missing reference, so the second pass\n // they trigger finds nothing to change.\n this.observer.observe(document.documentElement, {\n childList: true,\n subtree: true,\n attributes: true,\n attributeFilter: ['href'],\n });\n }\n\n /** The cref on the landing URL, if any. */\n private queryCref(): string | null {\n if (typeof window === 'undefined') return null;\n return new URL(window.location.href).searchParams.get(this.config.queryParam);\n }\n\n /**\n * Capture the cref from the URL (or the one held while consent was\n * pending), else the router's cookie, else the referral cookie, and\n * stash it in localStorage so it survives SPA navigations and later\n * page loads. Returns the cref when it's a click arriving now, one this\n * browser didn't have yet, for the landing report.\n */\n private captureCref(held: string | null): string | null {\n if (typeof window === 'undefined') return null;\n\n const incoming = held ?? this.queryCref();\n if (incoming) {\n const isNew = this.storageGet() !== incoming;\n this.current = incoming;\n this.storageSet(incoming);\n return isNew ? incoming : null;\n }\n\n if (this.storageGet()) return null;\n // A page that isn't the landing page: the router's own cookie (a\n // self-host whose router shares the brand's domain), then the referral\n // cookie a sibling subdomain or the brand's server wrote.\n const restored = this.readCookie(this.config.cookieName) ?? this.readCookie(this.referralCookieName());\n if (restored) {\n this.current = restored;\n this.storageSet(restored);\n }\n return null;\n }\n\n private referralCookieName(): string {\n const cfg = this.config.referralCookie;\n return (cfg && cfg.name) || REFERRAL_COOKIE_DEFAULT;\n }\n\n /**\n * Mirror the referral into the brand's own first-party cookie (when\n * configured). Left alone when it already holds this referral: in Safari\n * rewriting it from script would trade a server-set cookie's full\n * lifetime for the 7-day cap. A new click overwrites it.\n */\n private writeReferralCookie(): void {\n const cfg = this.config.referralCookie;\n if (!cfg || typeof document === 'undefined') return;\n const referral = this.getReferral();\n if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;\n const name = this.referralCookieName();\n if (this.readCookie(name) === referral) return;\n const maxAge = Math.round((cfg.days ?? 90) * 86400);\n const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';\n const domain = cfg.domain ? `; Domain=${cfg.domain}` : '';\n document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;\n }\n\n private readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null;\n const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));\n if (!match) return null;\n try {\n return decodeURIComponent(match[2]!);\n } catch {\n return null;\n }\n }\n\n // localStorage throws in some sandboxed iframes and with site data\n // blocked; the referral then lives in the cookie alone.\n private storageGet(): string | null {\n try {\n return typeof localStorage === 'undefined' ? null : localStorage.getItem(this.config.storageKey);\n } catch {\n return null;\n }\n }\n\n private storageSet(value: string): void {\n try {\n if (typeof localStorage !== 'undefined') localStorage.setItem(this.config.storageKey, value);\n } catch {\n // Storage unavailable; see storageGet.\n }\n }\n\n private storageRemove(): void {\n try {\n if (typeof localStorage !== 'undefined') localStorage.removeItem(this.config.storageKey);\n } catch {\n // Storage unavailable; see storageGet.\n }\n }\n\n /** Tell OpenPartner the click arrived. Best-effort, once per click. */\n private async reportLanding(cref: string): Promise<void> {\n if (!STRIPE_REFERENCE_RE.test(cref) || typeof fetch === 'undefined') return;\n try {\n await requestJson(`${this.config.apiUrl}/attribution/landing`, {\n method: 'POST',\n body: { cref },\n keepalive: true,\n });\n } catch (err) {\n // Only a click this program doesn't have is worth reporting: it\n // usually means apiUrl names another program. Anything else (offline,\n // rate limited, an API from before the landing report) is dropped:\n // the report is a diagnostic, never part of attribution.\n if (err instanceof OpenPartnerError && err.code === 'click_not_found') this.config.onError(err);\n }\n }\n\n /**\n * Stitch the current captured click to an authenticated user. Call\n * this on login, signup, or anywhere the user becomes known.\n *\n * No-op if no cref was captured, or consent isn't granted. Errors are\n * reported through `config.onError` by default — attribution is\n * best-effort from the browser — but you can await this and try/catch\n * if you want to surface failures explicitly.\n */\n async identify(userId: string): Promise<IdentifyResponse | null> {\n const cref = this.getCref();\n if (!cref) return null;\n\n try {\n return await requestJson<IdentifyResponse>(`${this.config.apiUrl}/attribution/identify`, {\n method: 'POST',\n body: { cref, userId, ts: Date.now() },\n keepalive: true,\n });\n } catch (err) {\n this.config.onError(err);\n return null;\n }\n }\n}\n"],"mappings":"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","names":[]}
package/dist/server.cjs CHANGED
@@ -23,7 +23,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
23
23
  var server_exports = {};
24
24
  __export(server_exports, {
25
25
  OpenPartnerError: () => OpenPartnerError,
26
- OpenPartnerServer: () => OpenPartnerServer
26
+ OpenPartnerServer: () => OpenPartnerServer,
27
+ readReferralCookie: () => readReferralCookie,
28
+ referralCookieHeader: () => referralCookieHeader,
29
+ referralCookieMiddleware: () => referralCookieMiddleware
27
30
  });
28
31
  module.exports = __toCommonJS(server_exports);
29
32
 
@@ -122,9 +125,54 @@ var OpenPartnerServer = class {
122
125
  );
123
126
  }
124
127
  };
128
+ var REFERRAL_RE = /^[A-Za-z0-9_-]{1,200}$/;
129
+ function referralCookieHeader(url, options = {}) {
130
+ let referral;
131
+ try {
132
+ referral = new URL(url, "http://localhost").searchParams.get(options.queryParam ?? "cref");
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (!referral || !REFERRAL_RE.test(referral)) return null;
137
+ const name = options.name ?? "op_ref";
138
+ const maxAge = Math.round((options.days ?? 90) * 86400);
139
+ const parts = [`${name}=${referral}`, `Max-Age=${maxAge}`, "Path=/", "SameSite=Lax"];
140
+ if (options.domain) parts.push(`Domain=${options.domain}`);
141
+ if (options.secure !== false) parts.push("Secure");
142
+ return parts.join("; ");
143
+ }
144
+ function referralCookieMiddleware(options = {}) {
145
+ return (req, res, next) => {
146
+ const header = referralCookieHeader(req.originalUrl ?? req.url ?? "", options);
147
+ if (header) {
148
+ const existing = res.getHeader("set-cookie");
149
+ const list = existing == null ? [] : Array.isArray(existing) ? existing : [String(existing)];
150
+ res.setHeader("set-cookie", [...list, header]);
151
+ }
152
+ next();
153
+ };
154
+ }
155
+ function readReferralCookie(cookieHeader, name = "op_ref") {
156
+ if (!cookieHeader) return null;
157
+ for (const part of cookieHeader.split(";")) {
158
+ const eq = part.indexOf("=");
159
+ if (eq === -1 || part.slice(0, eq).trim() !== name) continue;
160
+ let value;
161
+ try {
162
+ value = decodeURIComponent(part.slice(eq + 1).trim());
163
+ } catch {
164
+ return null;
165
+ }
166
+ return REFERRAL_RE.test(value) ? value : null;
167
+ }
168
+ return null;
169
+ }
125
170
  // Annotate the CommonJS export names for ESM import in node:
126
171
  0 && (module.exports = {
127
172
  OpenPartnerError,
128
- OpenPartnerServer
173
+ OpenPartnerServer,
174
+ readReferralCookie,
175
+ referralCookieHeader,
176
+ referralCookieMiddleware
129
177
  });
130
178
  //# sourceMappingURL=server.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts","../src/shared.ts"],"sourcesContent":["/**\n * Server SDK — runs inside a merchant's backend. Used for the\n * server-to-server pieces of the attribution pipeline: posting\n * conversion events (signup / invoice_paid / custom), stitching\n * clicks to users from the backend, and reading dashboard snapshots.\n *\n * Typical integration:\n *\n * import { OpenPartnerServer } from '@openpartner/sdk/server';\n *\n * const op = new OpenPartnerServer({\n * apiUrl: process.env.OPENPARTNER_API_URL!,\n * apiKey: process.env.OPENPARTNER_API_KEY!,\n * });\n *\n * // On signup:\n * await op.trackEvent({ userId, type: 'signup' });\n *\n * // On invoice paid — externalEventId makes retries safe:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });\n *\n * // Holding only the click id (from the link's {click_id} macro)? One call:\n * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });\n */\n\nimport {\n OpenPartnerError,\n requestJson,\n type ConversionResponse,\n type EventResponse,\n type IdentifyResponse,\n type JsonRequestInit,\n} from './shared.js';\n\nexport { OpenPartnerError };\nexport type { ConversionResponse, EventResponse, IdentifyResponse };\nexport type { EventAttribution } from './shared.js';\n\nexport interface OpenPartnerServerConfig {\n /** Base URL of the OpenPartner API. */\n apiUrl: string;\n /**\n * Admin or scoped API key. Event ingest (/attribution/events) accepts\n * an admin key or a scoped key with `events:write`. Never expose this\n * to the browser.\n */\n apiKey: string;\n /** Optional fetch override for tests or custom transports. */\n fetch?: typeof fetch;\n}\n\nexport interface TrackEventParams {\n /** The user the event pertains to. Must match how the browser SDK called identify(). */\n userId: string;\n /** Event type — typically 'signup' | 'trial_started' | 'subscription_created' | 'invoice_paid' | custom. */\n type: string;\n /** Dollar (or currency) value of the event, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /** Arbitrary metadata you want to attach (stripeEventId, invoiceId, etc). */\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */\n ts?: string;\n /**\n * Idempotency key: your system's stable id for this one event (order id,\n * invoice id, CRM deal-stage change id), max 120 chars. Strongly\n * recommended. A retried call with the same key returns the original\n * event and attribution (`replayed: true`) instead of recording and\n * commissioning it twice. Unique per event, not per order: an order's\n * `subscription_created` and its `invoice_paid` need different keys.\n * Reusing a key for a different userId / type / value / currency throws\n * an `OpenPartnerError` with code `external_event_id_conflict` (409).\n */\n externalEventId?: string;\n}\n\nexport interface TrackConversionParams {\n /** The click id the partner link handed you: the `{click_id}` macro in the\n * destination URL, or the `cref` query parameter. */\n clickId: string;\n /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */\n type: string;\n /** Value of the conversion, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /**\n * Required idempotency key: your stable id for this one conversion (loan\n * id plus stage, order id), max 120 chars. A retry returns the original\n * result (`replayed: true`); reusing it for a different click or\n * conversion throws `external_event_id_conflict` (409).\n */\n externalEventId: string;\n /**\n * Your own id for the user, if you have one. Omit it and the conversion is\n * recorded under the click's own user, credited to that click alone.\n */\n userId?: string;\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. ISO 8601. */\n ts?: string;\n}\n\nexport class OpenPartnerServer {\n private readonly apiUrl: string;\n private readonly apiKey: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: OpenPartnerServerConfig) {\n if (!config.apiUrl) throw new OpenPartnerError('apiUrl is required', 400, 'config_missing');\n if (!config.apiKey) throw new OpenPartnerError('apiKey is required', 400, 'config_missing');\n this.apiUrl = config.apiUrl.replace(/\\/$/, '');\n this.apiKey = config.apiKey;\n this.fetchImpl = config.fetch ?? globalThis.fetch;\n }\n\n /**\n * Record a conversion event. The server walks the userId back to a\n * click, applies the campaign's attribution model, and writes the\n * resulting attribution + commission rows.\n */\n async trackEvent(params: TrackEventParams): Promise<EventResponse> {\n return this.request<EventResponse>('/attribution/events', { method: 'POST', body: params });\n }\n\n /**\n * Record a conversion against a click in one call, for when you have the\n * click id but no identify()'d user: a lender reporting a funded loan, a\n * CRM closing a deal. Stitches the click and records the event together.\n * Throws `click_not_found` (404) for a click outside your program and\n * `click_flagged` (422) for one flagged as fraud.\n */\n async trackConversion(params: TrackConversionParams): Promise<ConversionResponse> {\n return this.request<ConversionResponse>('/attribution/conversions', { method: 'POST', body: params });\n }\n\n /**\n * Stitch an attribution token (cref) to a known userId. Normally the\n * browser SDK does this; use this overload if your backend receives\n * the cref from the browser (e.g. as part of a signup payload) and\n * wants to own the stitch call.\n */\n async identify(params: { cref: string; userId: string; ts?: number }): Promise<IdentifyResponse> {\n return this.request<IdentifyResponse>('/attribution/identify', {\n method: 'POST',\n body: params,\n });\n }\n\n private async request<T>(path: string, init: JsonRequestInit): Promise<T> {\n const headers = new Headers(init.headers);\n headers.set('authorization', `Bearer ${this.apiKey}`);\n return requestJson<T>(\n `${this.apiUrl}${path}`,\n { ...init, headers },\n // Inject the configured fetch so tests (or alternate transports)\n // can intercept.\n this.fetchImpl,\n );\n }\n}\n","/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSO,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YACE,SACgB,QACA,MACA,QAChB;AACA,UAAM,OAAO;AAJG;AACA;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAwDA,eAAsB,YACpB,OACA,OAAwB,CAAC,GACzB,YAA0B,WAAW,OACzB;AACZ,QAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,MAAI,KAAK,SAAS,UAAa,CAAC,QAAQ,IAAI,cAAc,GAAG;AAC3D,YAAQ,IAAI,gBAAgB,kBAAkB;AAAA,EAChD;AACA,QAAM,MAAM,MAAM,UAAU,OAAO;AAAA,IACjC,GAAG;AAAA,IACH;AAAA,IACA,MACE,KAAK,SAAS,SACV,SACA,OAAO,KAAK,SAAS,WACnB,KAAK,OACL,KAAK,UAAU,KAAK,IAAI;AAAA,EAClC,CAAC;AAED,QAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,MAAI,OAAgB;AACpB,MAAI,MAAM;AACR,QAAI;AACF,aAAO,KAAK,MAAM,IAAI;AAAA,IACxB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,UAAW,QAAQ,CAAC;AAC1B,UAAM,IAAI;AAAA,MACR,QAAQ,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,UAAU;AAAA,MAChD,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,QAAQ,UAAU;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ADZO,IAAM,oBAAN,MAAwB;AAAA,EAK7B,YAAY,QAAiC;AAJ7C,wBAAiB;AACjB,wBAAiB;AACjB,wBAAiB;AAGf,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,QAAI,CAAC,OAAO,OAAQ,OAAM,IAAI,iBAAiB,sBAAsB,KAAK,gBAAgB;AAC1F,SAAK,SAAS,OAAO,OAAO,QAAQ,OAAO,EAAE;AAC7C,SAAK,SAAS,OAAO;AACrB,SAAK,YAAY,OAAO,SAAS,WAAW;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,WAAW,QAAkD;AACjE,WAAO,KAAK,QAAuB,uBAAuB,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EAC5F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,gBAAgB,QAA4D;AAChF,WAAO,KAAK,QAA4B,4BAA4B,EAAE,QAAQ,QAAQ,MAAM,OAAO,CAAC;AAAA,EACtG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,SAAS,QAAkF;AAC/F,WAAO,KAAK,QAA0B,yBAAyB;AAAA,MAC7D,QAAQ;AAAA,MACR,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,QAAW,MAAc,MAAmC;AACxE,UAAM,UAAU,IAAI,QAAQ,KAAK,OAAO;AACxC,YAAQ,IAAI,iBAAiB,UAAU,KAAK,MAAM,EAAE;AACpD,WAAO;AAAA,MACL,GAAG,KAAK,MAAM,GAAG,IAAI;AAAA,MACrB,EAAE,GAAG,MAAM,QAAQ;AAAA;AAAA;AAAA,MAGnB,KAAK;AAAA,IACP;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/server.ts","../src/shared.ts"],"sourcesContent":["/**\n * Server SDK — runs inside a merchant's backend. Used for the\n * server-to-server pieces of the attribution pipeline: posting\n * conversion events (signup / invoice_paid / custom), stitching\n * clicks to users from the backend, and reading dashboard snapshots.\n *\n * Typical integration:\n *\n * import { OpenPartnerServer } from '@openpartner/sdk/server';\n *\n * const op = new OpenPartnerServer({\n * apiUrl: process.env.OPENPARTNER_API_URL!,\n * apiKey: process.env.OPENPARTNER_API_KEY!,\n * });\n *\n * // On signup:\n * await op.trackEvent({ userId, type: 'signup' });\n *\n * // On invoice paid — externalEventId makes retries safe:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });\n *\n * // Holding only the click id (from the link's {click_id} macro)? One call:\n * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });\n */\n\nimport {\n OpenPartnerError,\n requestJson,\n type ConversionResponse,\n type EventResponse,\n type IdentifyResponse,\n type JsonRequestInit,\n} from './shared.js';\n\nexport { OpenPartnerError };\nexport type { ConversionResponse, EventResponse, IdentifyResponse };\nexport type { EventAttribution } from './shared.js';\n\nexport interface OpenPartnerServerConfig {\n /** Base URL of the OpenPartner API. */\n apiUrl: string;\n /**\n * Admin or scoped API key. Event ingest (/attribution/events) accepts\n * an admin key or a scoped key with `events:write`. Never expose this\n * to the browser.\n */\n apiKey: string;\n /** Optional fetch override for tests or custom transports. */\n fetch?: typeof fetch;\n}\n\nexport interface TrackEventParams {\n /** The user the event pertains to. Must match how the browser SDK called identify(). */\n userId: string;\n /** Event type — typically 'signup' | 'trial_started' | 'subscription_created' | 'invoice_paid' | custom. */\n type: string;\n /** Dollar (or currency) value of the event, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /** Arbitrary metadata you want to attach (stripeEventId, invoiceId, etc). */\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */\n ts?: string;\n /**\n * Idempotency key: your system's stable id for this one event (order id,\n * invoice id, CRM deal-stage change id), max 120 chars. Strongly\n * recommended. A retried call with the same key returns the original\n * event and attribution (`replayed: true`) instead of recording and\n * commissioning it twice. Unique per event, not per order: an order's\n * `subscription_created` and its `invoice_paid` need different keys.\n * Reusing a key for a different userId / type / value / currency throws\n * an `OpenPartnerError` with code `external_event_id_conflict` (409).\n */\n externalEventId?: string;\n}\n\nexport interface TrackConversionParams {\n /** The click id the partner link handed you: the `{click_id}` macro in the\n * destination URL, or the `cref` query parameter. */\n clickId: string;\n /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */\n type: string;\n /** Value of the conversion, for percent-commission rules. */\n value?: number;\n /** ISO 4217 currency code. Defaults to USD on the server if omitted. */\n currency?: string;\n /**\n * Required idempotency key: your stable id for this one conversion (loan\n * id plus stage, order id), max 120 chars. A retry returns the original\n * result (`replayed: true`); reusing it for a different click or\n * conversion throws `external_event_id_conflict` (409).\n */\n externalEventId: string;\n /**\n * Your own id for the user, if you have one. Omit it and the conversion is\n * recorded under the click's own user, credited to that click alone.\n */\n userId?: string;\n metadata?: Record<string, unknown>;\n /** Event time. Defaults to now on the server. ISO 8601. */\n ts?: string;\n}\n\nexport class OpenPartnerServer {\n private readonly apiUrl: string;\n private readonly apiKey: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(config: OpenPartnerServerConfig) {\n if (!config.apiUrl) throw new OpenPartnerError('apiUrl is required', 400, 'config_missing');\n if (!config.apiKey) throw new OpenPartnerError('apiKey is required', 400, 'config_missing');\n this.apiUrl = config.apiUrl.replace(/\\/$/, '');\n this.apiKey = config.apiKey;\n this.fetchImpl = config.fetch ?? globalThis.fetch;\n }\n\n /**\n * Record a conversion event. The server walks the userId back to a\n * click, applies the campaign's attribution model, and writes the\n * resulting attribution + commission rows.\n */\n async trackEvent(params: TrackEventParams): Promise<EventResponse> {\n return this.request<EventResponse>('/attribution/events', { method: 'POST', body: params });\n }\n\n /**\n * Record a conversion against a click in one call, for when you have the\n * click id but no identify()'d user: a lender reporting a funded loan, a\n * CRM closing a deal. Stitches the click and records the event together.\n * Throws `click_not_found` (404) for a click outside your program and\n * `click_flagged` (422) for one flagged as fraud.\n */\n async trackConversion(params: TrackConversionParams): Promise<ConversionResponse> {\n return this.request<ConversionResponse>('/attribution/conversions', { method: 'POST', body: params });\n }\n\n /**\n * Stitch an attribution token (cref) to a known userId. Normally the\n * browser SDK does this; use this overload if your backend receives\n * the cref from the browser (e.g. as part of a signup payload) and\n * wants to own the stitch call.\n */\n async identify(params: { cref: string; userId: string; ts?: number }): Promise<IdentifyResponse> {\n return this.request<IdentifyResponse>('/attribution/identify', {\n method: 'POST',\n body: params,\n });\n }\n\n private async request<T>(path: string, init: JsonRequestInit): Promise<T> {\n const headers = new Headers(init.headers);\n headers.set('authorization', `Bearer ${this.apiKey}`);\n return requestJson<T>(\n `${this.apiUrl}${path}`,\n { ...init, headers },\n // Inject the configured fetch so tests (or alternate transports)\n // can intercept.\n this.fetchImpl,\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// The referral cookie, set from your own server\n// ---------------------------------------------------------------------------\n\nexport interface ReferralCookieOptions {\n /** Cookie name. Default `op_ref`, the one the browser SDK reads back. */\n name?: string;\n /** Share it across subdomains, e.g. \".yourbrand.com\" (land on www., buy on app.). */\n domain?: string;\n /** Lifetime in days. Default 90. */\n days?: number;\n /** Send it over HTTPS only. Default true; set false for plain-http local development. */\n secure?: boolean;\n /** Query parameter that carries the click on the landing URL. Default `cref`. */\n queryParam?: string;\n}\n\n/** A click id, or anything Stripe would accept as client_reference_id. */\nconst REFERRAL_RE = /^[A-Za-z0-9_-]{1,200}$/;\n\n/**\n * The `Set-Cookie` header value that keeps a partner click on your own\n * domain, for a request whose URL carries `?cref=`; null otherwise. Send it\n * on the landing page's own response.\n *\n * Why from the server: Safari deletes what a page's scripts store\n * (localStorage, and cookies written through document.cookie) after 7 days\n * without a visit. A cookie your server sets on the page's own response\n * keeps its full lifetime, so a visitor who clicks today and buys in a\n * month, without signing up in between, is still credited. Not HttpOnly:\n * the browser SDK reads it back for identify() and Stripe links. Set it\n * from the server that serves the page itself: Safari caps a cookie from a\n * server on another IP (an API host on a different provider) at 7 days too.\n *\n * const header = referralCookieHeader(req.url, { domain: '.yourbrand.com' });\n * if (header) res.append('Set-Cookie', header);\n */\nexport function referralCookieHeader(url: string, options: ReferralCookieOptions = {}): string | null {\n let referral: string | null;\n try {\n referral = new URL(url, 'http://localhost').searchParams.get(options.queryParam ?? 'cref');\n } catch {\n return null;\n }\n if (!referral || !REFERRAL_RE.test(referral)) return null;\n const name = options.name ?? 'op_ref';\n const maxAge = Math.round((options.days ?? 90) * 86400);\n const parts = [`${name}=${referral}`, `Max-Age=${maxAge}`, 'Path=/', 'SameSite=Lax'];\n if (options.domain) parts.push(`Domain=${options.domain}`);\n if (options.secure !== false) parts.push('Secure');\n return parts.join('; ');\n}\n\n/** The slice of Node's request and response the middleware uses. */\ninterface CookieRequest {\n url?: string;\n originalUrl?: string;\n}\ninterface CookieResponse {\n getHeader(name: string): number | string | string[] | undefined;\n setHeader(name: string, value: string | string[]): unknown;\n}\n\n/**\n * Express / Connect middleware: sets the referral cookie on every request\n * that arrives with `?cref=`, adding to any Set-Cookie already queued.\n *\n * app.use(referralCookieMiddleware({ domain: '.yourbrand.com' }));\n */\nexport function referralCookieMiddleware(options: ReferralCookieOptions = {}) {\n return (req: CookieRequest, res: CookieResponse, next: () => void): void => {\n const header = referralCookieHeader(req.originalUrl ?? req.url ?? '', options);\n if (header) {\n const existing = res.getHeader('set-cookie');\n const list = existing == null ? [] : Array.isArray(existing) ? existing : [String(existing)];\n res.setHeader('set-cookie', [...list, header]);\n }\n next();\n };\n}\n\n/**\n * Read the referral back off a request's `Cookie` header: for identify()\n * from your backend at signup, or `client_reference_id` on a Checkout\n * Session you create. Null when there's none.\n *\n * const cref = readReferralCookie(req.headers.cookie);\n * if (cref) await op.identify({ cref, userId: user.id });\n */\nexport function readReferralCookie(cookieHeader: string | null | undefined, name = 'op_ref'): string | null {\n if (!cookieHeader) return null;\n for (const part of cookieHeader.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1 || part.slice(0, eq).trim() !== name) continue;\n let value: string;\n try {\n value = decodeURIComponent(part.slice(eq + 1).trim());\n } catch {\n return null;\n }\n return REFERRAL_RE.test(value) ? value : null;\n }\n return null;\n}\n","/**\n * Shared types + error class for both the browser and server entry\n * points.\n *\n * Keeping these in their own module means browser imports won't drag in\n * `fetch` polyfills or server-only code, and server imports won't touch\n * anything that references `window` or `document`.\n */\n\nexport class OpenPartnerError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n public readonly detail?: unknown,\n ) {\n super(message);\n this.name = 'OpenPartnerError';\n }\n}\n\nexport interface IdentifyResponse {\n ok: boolean;\n identityId: string | null;\n firstStitch: boolean;\n backfilledAttributions?: number;\n}\n\nexport interface EventAttribution {\n /**\n * 'not_attributed' only appears on a replay (`replayed: true`) of an event\n * that didn't attribute the first time; which miss it was isn't stored.\n * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —\n * recorded, never attributed.\n */\n status:\n | 'attributed'\n | 'no_identity'\n | 'no_click'\n | 'outside_window'\n | 'already_attributed'\n | 'not_attributed'\n | 'corrective';\n model?: 'last_click' | 'first_click' | 'linear' | 'position';\n touches?: Array<{\n clickId: string;\n partnerId: string;\n weight: number;\n attributionId: string;\n commissionId?: string;\n }>;\n}\n\nexport interface EventResponse {\n ok: boolean;\n eventId: string;\n attribution: EventAttribution;\n /** True when an earlier call with the same externalEventId already\n * recorded this event; eventId and attribution are that call's. */\n replayed?: boolean;\n}\n\nexport interface ConversionResponse extends EventResponse {\n /** The user the conversion was recorded under: the userId you passed, or\n * the click's own `click:<clickId>` user. */\n userId: string;\n}\n\n/**\n * Small fetch helper used by both entries. Normalizes non-2xx responses\n * into OpenPartnerError so callers can try/catch with a stable shape\n * instead of poking at Response objects themselves.\n */\nexport type JsonRequestInit = Omit<RequestInit, 'body'> & { body?: unknown };\n\nexport async function requestJson<T>(\n input: string,\n init: JsonRequestInit = {},\n fetchImpl: typeof fetch = globalThis.fetch,\n): Promise<T> {\n const headers = new Headers(init.headers);\n if (init.body !== undefined && !headers.has('content-type')) {\n headers.set('content-type', 'application/json');\n }\n const res = await fetchImpl(input, {\n ...init,\n headers,\n body:\n init.body === undefined\n ? undefined\n : typeof init.body === 'string'\n ? init.body\n : JSON.stringify(init.body),\n });\n\n const text = await res.text();\n let body: unknown = undefined;\n if (text) {\n try {\n body = JSON.parse(text);\n } catch {\n body = text;\n }\n }\n\n if (!res.ok) {\n const errBody = (body ?? {}) as { error?: string; detail?: unknown };\n throw new OpenPartnerError(\n errBody.error ?? `${res.status} ${res.statusText}`,\n res.status,\n errBody.error,\n errBody.detail ?? body,\n );\n }\n\n return body as T;\n}\n"],"mappings":"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","names":[]}
package/dist/server.d.cts CHANGED
@@ -121,6 +121,61 @@ declare class OpenPartnerServer {
121
121
  }): Promise<IdentifyResponse>;
122
122
  private request;
123
123
  }
124
+ interface ReferralCookieOptions {
125
+ /** Cookie name. Default `op_ref`, the one the browser SDK reads back. */
126
+ name?: string;
127
+ /** Share it across subdomains, e.g. ".yourbrand.com" (land on www., buy on app.). */
128
+ domain?: string;
129
+ /** Lifetime in days. Default 90. */
130
+ days?: number;
131
+ /** Send it over HTTPS only. Default true; set false for plain-http local development. */
132
+ secure?: boolean;
133
+ /** Query parameter that carries the click on the landing URL. Default `cref`. */
134
+ queryParam?: string;
135
+ }
136
+ /**
137
+ * The `Set-Cookie` header value that keeps a partner click on your own
138
+ * domain, for a request whose URL carries `?cref=`; null otherwise. Send it
139
+ * on the landing page's own response.
140
+ *
141
+ * Why from the server: Safari deletes what a page's scripts store
142
+ * (localStorage, and cookies written through document.cookie) after 7 days
143
+ * without a visit. A cookie your server sets on the page's own response
144
+ * keeps its full lifetime, so a visitor who clicks today and buys in a
145
+ * month, without signing up in between, is still credited. Not HttpOnly:
146
+ * the browser SDK reads it back for identify() and Stripe links. Set it
147
+ * from the server that serves the page itself: Safari caps a cookie from a
148
+ * server on another IP (an API host on a different provider) at 7 days too.
149
+ *
150
+ * const header = referralCookieHeader(req.url, { domain: '.yourbrand.com' });
151
+ * if (header) res.append('Set-Cookie', header);
152
+ */
153
+ declare function referralCookieHeader(url: string, options?: ReferralCookieOptions): string | null;
154
+ /** The slice of Node's request and response the middleware uses. */
155
+ interface CookieRequest {
156
+ url?: string;
157
+ originalUrl?: string;
158
+ }
159
+ interface CookieResponse {
160
+ getHeader(name: string): number | string | string[] | undefined;
161
+ setHeader(name: string, value: string | string[]): unknown;
162
+ }
163
+ /**
164
+ * Express / Connect middleware: sets the referral cookie on every request
165
+ * that arrives with `?cref=`, adding to any Set-Cookie already queued.
166
+ *
167
+ * app.use(referralCookieMiddleware({ domain: '.yourbrand.com' }));
168
+ */
169
+ declare function referralCookieMiddleware(options?: ReferralCookieOptions): (req: CookieRequest, res: CookieResponse, next: () => void) => void;
170
+ /**
171
+ * Read the referral back off a request's `Cookie` header: for identify()
172
+ * from your backend at signup, or `client_reference_id` on a Checkout
173
+ * Session you create. Null when there's none.
174
+ *
175
+ * const cref = readReferralCookie(req.headers.cookie);
176
+ * if (cref) await op.identify({ cref, userId: user.id });
177
+ */
178
+ declare function readReferralCookie(cookieHeader: string | null | undefined, name?: string): string | null;
124
179
 
125
- export { ConversionResponse, EventResponse, IdentifyResponse, OpenPartnerServer };
126
- export type { OpenPartnerServerConfig, TrackConversionParams, TrackEventParams };
180
+ export { ConversionResponse, EventResponse, IdentifyResponse, OpenPartnerServer, readReferralCookie, referralCookieHeader, referralCookieMiddleware };
181
+ export type { OpenPartnerServerConfig, ReferralCookieOptions, TrackConversionParams, TrackEventParams };