@openpartner/sdk 0.1.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/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as EventResponse, I as IdentifyResponse } from './shared-CLfBjPeb.cjs';
2
- export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.cjs';
1
+ import { E as EventResponse, C as ConversionResponse, I as IdentifyResponse } from './shared-dh1vlldf.cjs';
2
+ export { a as EventAttribution, O as OpenPartnerError } from './shared-dh1vlldf.cjs';
3
3
 
4
4
  /**
5
5
  * Server SDK — runs inside a merchant's backend. Used for the
@@ -19,17 +19,20 @@ export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.
19
19
  * // On signup:
20
20
  * await op.trackEvent({ userId, type: 'signup' });
21
21
  *
22
- * // On invoice paid:
23
- * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });
22
+ * // On invoice paid — externalEventId makes retries safe:
23
+ * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });
24
+ *
25
+ * // Holding only the click id (from the link's {click_id} macro)? One call:
26
+ * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });
24
27
  */
25
28
 
26
29
  interface OpenPartnerServerConfig {
27
30
  /** Base URL of the OpenPartner API. */
28
31
  apiUrl: string;
29
32
  /**
30
- * Admin or scoped API key. For event ingest you need admin or a key
31
- * with the right scope (the core API currently uses admin-only for
32
- * /attribution/events). Never expose this to the browser.
33
+ * Admin or scoped API key. Event ingest (/attribution/events) accepts
34
+ * an admin key or a scoped key with `events:write`. Never expose this
35
+ * to the browser.
33
36
  */
34
37
  apiKey: string;
35
38
  /** Optional fetch override for tests or custom transports. */
@@ -48,6 +51,43 @@ interface TrackEventParams {
48
51
  metadata?: Record<string, unknown>;
49
52
  /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */
50
53
  ts?: string;
54
+ /**
55
+ * Idempotency key: your system's stable id for this one event (order id,
56
+ * invoice id, CRM deal-stage change id), max 120 chars. Strongly
57
+ * recommended. A retried call with the same key returns the original
58
+ * event and attribution (`replayed: true`) instead of recording and
59
+ * commissioning it twice. Unique per event, not per order: an order's
60
+ * `subscription_created` and its `invoice_paid` need different keys.
61
+ * Reusing a key for a different userId / type / value / currency throws
62
+ * an `OpenPartnerError` with code `external_event_id_conflict` (409).
63
+ */
64
+ externalEventId?: string;
65
+ }
66
+ interface TrackConversionParams {
67
+ /** The click id the partner link handed you: the `{click_id}` macro in the
68
+ * destination URL, or the `cref` query parameter. */
69
+ clickId: string;
70
+ /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */
71
+ type: string;
72
+ /** Value of the conversion, for percent-commission rules. */
73
+ value?: number;
74
+ /** ISO 4217 currency code. Defaults to USD on the server if omitted. */
75
+ currency?: string;
76
+ /**
77
+ * Required idempotency key: your stable id for this one conversion (loan
78
+ * id plus stage, order id), max 120 chars. A retry returns the original
79
+ * result (`replayed: true`); reusing it for a different click or
80
+ * conversion throws `external_event_id_conflict` (409).
81
+ */
82
+ externalEventId: string;
83
+ /**
84
+ * Your own id for the user, if you have one. Omit it and the conversion is
85
+ * recorded under the click's own user, credited to that click alone.
86
+ */
87
+ userId?: string;
88
+ metadata?: Record<string, unknown>;
89
+ /** Event time. Defaults to now on the server. ISO 8601. */
90
+ ts?: string;
51
91
  }
52
92
  declare class OpenPartnerServer {
53
93
  private readonly apiUrl;
@@ -60,6 +100,14 @@ declare class OpenPartnerServer {
60
100
  * resulting attribution + commission rows.
61
101
  */
62
102
  trackEvent(params: TrackEventParams): Promise<EventResponse>;
103
+ /**
104
+ * Record a conversion against a click in one call, for when you have the
105
+ * click id but no identify()'d user: a lender reporting a funded loan, a
106
+ * CRM closing a deal. Stitches the click and records the event together.
107
+ * Throws `click_not_found` (404) for a click outside your program and
108
+ * `click_flagged` (422) for one flagged as fraud.
109
+ */
110
+ trackConversion(params: TrackConversionParams): Promise<ConversionResponse>;
63
111
  /**
64
112
  * Stitch an attribution token (cref) to a known userId. Normally the
65
113
  * browser SDK does this; use this overload if your backend receives
@@ -73,5 +121,61 @@ declare class OpenPartnerServer {
73
121
  }): Promise<IdentifyResponse>;
74
122
  private request;
75
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;
76
179
 
77
- export { EventResponse, IdentifyResponse, OpenPartnerServer, type OpenPartnerServerConfig, type TrackEventParams };
180
+ export { ConversionResponse, EventResponse, IdentifyResponse, OpenPartnerServer, readReferralCookie, referralCookieHeader, referralCookieMiddleware };
181
+ export type { OpenPartnerServerConfig, ReferralCookieOptions, TrackConversionParams, TrackEventParams };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as EventResponse, I as IdentifyResponse } from './shared-CLfBjPeb.js';
2
- export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.js';
1
+ import { E as EventResponse, C as ConversionResponse, I as IdentifyResponse } from './shared-dh1vlldf.js';
2
+ export { a as EventAttribution, O as OpenPartnerError } from './shared-dh1vlldf.js';
3
3
 
4
4
  /**
5
5
  * Server SDK — runs inside a merchant's backend. Used for the
@@ -19,17 +19,20 @@ export { a as EventAttribution, O as OpenPartnerError } from './shared-CLfBjPeb.
19
19
  * // On signup:
20
20
  * await op.trackEvent({ userId, type: 'signup' });
21
21
  *
22
- * // On invoice paid:
23
- * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });
22
+ * // On invoice paid — externalEventId makes retries safe:
23
+ * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD', externalEventId: invoice.id });
24
+ *
25
+ * // Holding only the click id (from the link's {click_id} macro)? One call:
26
+ * await op.trackConversion({ clickId, type: 'loan_funded', value: 5000, externalEventId: `${loan.id}:funded` });
24
27
  */
25
28
 
26
29
  interface OpenPartnerServerConfig {
27
30
  /** Base URL of the OpenPartner API. */
28
31
  apiUrl: string;
29
32
  /**
30
- * Admin or scoped API key. For event ingest you need admin or a key
31
- * with the right scope (the core API currently uses admin-only for
32
- * /attribution/events). Never expose this to the browser.
33
+ * Admin or scoped API key. Event ingest (/attribution/events) accepts
34
+ * an admin key or a scoped key with `events:write`. Never expose this
35
+ * to the browser.
33
36
  */
34
37
  apiKey: string;
35
38
  /** Optional fetch override for tests or custom transports. */
@@ -48,6 +51,43 @@ interface TrackEventParams {
48
51
  metadata?: Record<string, unknown>;
49
52
  /** Event time. Defaults to now on the server. Pass ISO 8601 to override. */
50
53
  ts?: string;
54
+ /**
55
+ * Idempotency key: your system's stable id for this one event (order id,
56
+ * invoice id, CRM deal-stage change id), max 120 chars. Strongly
57
+ * recommended. A retried call with the same key returns the original
58
+ * event and attribution (`replayed: true`) instead of recording and
59
+ * commissioning it twice. Unique per event, not per order: an order's
60
+ * `subscription_created` and its `invoice_paid` need different keys.
61
+ * Reusing a key for a different userId / type / value / currency throws
62
+ * an `OpenPartnerError` with code `external_event_id_conflict` (409).
63
+ */
64
+ externalEventId?: string;
65
+ }
66
+ interface TrackConversionParams {
67
+ /** The click id the partner link handed you: the `{click_id}` macro in the
68
+ * destination URL, or the `cref` query parameter. */
69
+ clickId: string;
70
+ /** Event type, e.g. 'loan_funded', 'opportunity_won', 'invoice_paid'. */
71
+ type: string;
72
+ /** Value of the conversion, for percent-commission rules. */
73
+ value?: number;
74
+ /** ISO 4217 currency code. Defaults to USD on the server if omitted. */
75
+ currency?: string;
76
+ /**
77
+ * Required idempotency key: your stable id for this one conversion (loan
78
+ * id plus stage, order id), max 120 chars. A retry returns the original
79
+ * result (`replayed: true`); reusing it for a different click or
80
+ * conversion throws `external_event_id_conflict` (409).
81
+ */
82
+ externalEventId: string;
83
+ /**
84
+ * Your own id for the user, if you have one. Omit it and the conversion is
85
+ * recorded under the click's own user, credited to that click alone.
86
+ */
87
+ userId?: string;
88
+ metadata?: Record<string, unknown>;
89
+ /** Event time. Defaults to now on the server. ISO 8601. */
90
+ ts?: string;
51
91
  }
52
92
  declare class OpenPartnerServer {
53
93
  private readonly apiUrl;
@@ -60,6 +100,14 @@ declare class OpenPartnerServer {
60
100
  * resulting attribution + commission rows.
61
101
  */
62
102
  trackEvent(params: TrackEventParams): Promise<EventResponse>;
103
+ /**
104
+ * Record a conversion against a click in one call, for when you have the
105
+ * click id but no identify()'d user: a lender reporting a funded loan, a
106
+ * CRM closing a deal. Stitches the click and records the event together.
107
+ * Throws `click_not_found` (404) for a click outside your program and
108
+ * `click_flagged` (422) for one flagged as fraud.
109
+ */
110
+ trackConversion(params: TrackConversionParams): Promise<ConversionResponse>;
63
111
  /**
64
112
  * Stitch an attribution token (cref) to a known userId. Normally the
65
113
  * browser SDK does this; use this overload if your backend receives
@@ -73,5 +121,61 @@ declare class OpenPartnerServer {
73
121
  }): Promise<IdentifyResponse>;
74
122
  private request;
75
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;
76
179
 
77
- export { EventResponse, IdentifyResponse, OpenPartnerServer, type OpenPartnerServerConfig, type TrackEventParams };
180
+ export { ConversionResponse, EventResponse, IdentifyResponse, OpenPartnerServer, readReferralCookie, referralCookieHeader, referralCookieMiddleware };
181
+ export type { OpenPartnerServerConfig, ReferralCookieOptions, TrackConversionParams, TrackEventParams };
package/dist/server.js CHANGED
@@ -63,6 +63,16 @@ var OpenPartnerServer = class {
63
63
  async trackEvent(params) {
64
64
  return this.request("/attribution/events", { method: "POST", body: params });
65
65
  }
66
+ /**
67
+ * Record a conversion against a click in one call, for when you have the
68
+ * click id but no identify()'d user: a lender reporting a funded loan, a
69
+ * CRM closing a deal. Stitches the click and records the event together.
70
+ * Throws `click_not_found` (404) for a click outside your program and
71
+ * `click_flagged` (422) for one flagged as fraud.
72
+ */
73
+ async trackConversion(params) {
74
+ return this.request("/attribution/conversions", { method: "POST", body: params });
75
+ }
66
76
  /**
67
77
  * Stitch an attribution token (cref) to a known userId. Normally the
68
78
  * browser SDK does this; use this overload if your backend receives
@@ -87,8 +97,53 @@ var OpenPartnerServer = class {
87
97
  );
88
98
  }
89
99
  };
100
+ var REFERRAL_RE = /^[A-Za-z0-9_-]{1,200}$/;
101
+ function referralCookieHeader(url, options = {}) {
102
+ let referral;
103
+ try {
104
+ referral = new URL(url, "http://localhost").searchParams.get(options.queryParam ?? "cref");
105
+ } catch {
106
+ return null;
107
+ }
108
+ if (!referral || !REFERRAL_RE.test(referral)) return null;
109
+ const name = options.name ?? "op_ref";
110
+ const maxAge = Math.round((options.days ?? 90) * 86400);
111
+ const parts = [`${name}=${referral}`, `Max-Age=${maxAge}`, "Path=/", "SameSite=Lax"];
112
+ if (options.domain) parts.push(`Domain=${options.domain}`);
113
+ if (options.secure !== false) parts.push("Secure");
114
+ return parts.join("; ");
115
+ }
116
+ function referralCookieMiddleware(options = {}) {
117
+ return (req, res, next) => {
118
+ const header = referralCookieHeader(req.originalUrl ?? req.url ?? "", options);
119
+ if (header) {
120
+ const existing = res.getHeader("set-cookie");
121
+ const list = existing == null ? [] : Array.isArray(existing) ? existing : [String(existing)];
122
+ res.setHeader("set-cookie", [...list, header]);
123
+ }
124
+ next();
125
+ };
126
+ }
127
+ function readReferralCookie(cookieHeader, name = "op_ref") {
128
+ if (!cookieHeader) return null;
129
+ for (const part of cookieHeader.split(";")) {
130
+ const eq = part.indexOf("=");
131
+ if (eq === -1 || part.slice(0, eq).trim() !== name) continue;
132
+ let value;
133
+ try {
134
+ value = decodeURIComponent(part.slice(eq + 1).trim());
135
+ } catch {
136
+ return null;
137
+ }
138
+ return REFERRAL_RE.test(value) ? value : null;
139
+ }
140
+ return null;
141
+ }
90
142
  export {
91
143
  OpenPartnerError,
92
- OpenPartnerServer
144
+ OpenPartnerServer,
145
+ readReferralCookie,
146
+ referralCookieHeader,
147
+ referralCookieMiddleware
93
148
  };
94
149
  //# sourceMappingURL=server.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/shared.ts","../src/server.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 status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed';\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}\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 * 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:\n * await op.trackEvent({ userId, type: 'invoice_paid', value: 249, currency: 'USD' });\n */\n\nimport { OpenPartnerError, requestJson, type EventResponse, type IdentifyResponse, type JsonRequestInit } from './shared.js';\n\nexport { OpenPartnerError };\nexport type { 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. For event ingest you need admin or a key\n * with the right scope (the core API currently uses admin-only for\n * /attribution/events). Never expose this 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\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 * 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"],"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;AAkCA,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;;;ACtCO,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,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/shared.ts","../src/server.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 * 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"],"mappings":"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","names":[]}
@@ -9,8 +9,8 @@
9
9
  declare class OpenPartnerError extends Error {
10
10
  readonly status: number;
11
11
  readonly code?: string | undefined;
12
- readonly detail?: unknown;
13
- constructor(message: string, status: number, code?: string | undefined, detail?: unknown);
12
+ readonly detail?: unknown | undefined;
13
+ constructor(message: string, status: number, code?: string | undefined, detail?: unknown | undefined);
14
14
  }
15
15
  interface IdentifyResponse {
16
16
  ok: boolean;
@@ -19,7 +19,13 @@ interface IdentifyResponse {
19
19
  backfilledAttributions?: number;
20
20
  }
21
21
  interface EventAttribution {
22
- status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed';
22
+ /**
23
+ * 'not_attributed' only appears on a replay (`replayed: true`) of an event
24
+ * that didn't attribute the first time; which miss it was isn't stored.
25
+ * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —
26
+ * recorded, never attributed.
27
+ */
28
+ status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed' | 'not_attributed' | 'corrective';
23
29
  model?: 'last_click' | 'first_click' | 'linear' | 'position';
24
30
  touches?: Array<{
25
31
  clickId: string;
@@ -33,6 +39,15 @@ interface EventResponse {
33
39
  ok: boolean;
34
40
  eventId: string;
35
41
  attribution: EventAttribution;
42
+ /** True when an earlier call with the same externalEventId already
43
+ * recorded this event; eventId and attribution are that call's. */
44
+ replayed?: boolean;
45
+ }
46
+ interface ConversionResponse extends EventResponse {
47
+ /** The user the conversion was recorded under: the userId you passed, or
48
+ * the click's own `click:<clickId>` user. */
49
+ userId: string;
36
50
  }
37
51
 
38
- export { type EventResponse as E, type IdentifyResponse as I, OpenPartnerError as O, type EventAttribution as a };
52
+ export { OpenPartnerError as O };
53
+ export type { ConversionResponse as C, EventResponse as E, IdentifyResponse as I, EventAttribution as a };
@@ -9,8 +9,8 @@
9
9
  declare class OpenPartnerError extends Error {
10
10
  readonly status: number;
11
11
  readonly code?: string | undefined;
12
- readonly detail?: unknown;
13
- constructor(message: string, status: number, code?: string | undefined, detail?: unknown);
12
+ readonly detail?: unknown | undefined;
13
+ constructor(message: string, status: number, code?: string | undefined, detail?: unknown | undefined);
14
14
  }
15
15
  interface IdentifyResponse {
16
16
  ok: boolean;
@@ -19,7 +19,13 @@ interface IdentifyResponse {
19
19
  backfilledAttributions?: number;
20
20
  }
21
21
  interface EventAttribution {
22
- status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed';
22
+ /**
23
+ * 'not_attributed' only appears on a replay (`replayed: true`) of an event
24
+ * that didn't attribute the first time; which miss it was isn't stored.
25
+ * 'corrective': refund / chargeback / dispute_created / invoice_payment_failed —
26
+ * recorded, never attributed.
27
+ */
28
+ status: 'attributed' | 'no_identity' | 'no_click' | 'outside_window' | 'already_attributed' | 'not_attributed' | 'corrective';
23
29
  model?: 'last_click' | 'first_click' | 'linear' | 'position';
24
30
  touches?: Array<{
25
31
  clickId: string;
@@ -33,6 +39,15 @@ interface EventResponse {
33
39
  ok: boolean;
34
40
  eventId: string;
35
41
  attribution: EventAttribution;
42
+ /** True when an earlier call with the same externalEventId already
43
+ * recorded this event; eventId and attribution are that call's. */
44
+ replayed?: boolean;
45
+ }
46
+ interface ConversionResponse extends EventResponse {
47
+ /** The user the conversion was recorded under: the userId you passed, or
48
+ * the click's own `click:<clickId>` user. */
49
+ userId: string;
36
50
  }
37
51
 
38
- export { type EventResponse as E, type IdentifyResponse as I, OpenPartnerError as O, type EventAttribution as a };
52
+ export { OpenPartnerError as O };
53
+ export type { ConversionResponse as C, EventResponse as E, IdentifyResponse as I, EventAttribution as a };
@@ -0,0 +1 @@
1
+ "use strict";(()=>{var C=Object.defineProperty,S=Object.defineProperties;var R=Object.getOwnPropertyDescriptors;var y=Object.getOwnPropertySymbols;var E=Object.prototype.hasOwnProperty,L=Object.prototype.propertyIsEnumerable;var g=(n,e,t)=>e in n?C(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t,l=(n,e)=>{for(var t in e||(e={}))E.call(e,t)&&g(n,t,e[t]);if(y)for(var t of y(e))L.call(e,t)&&g(n,t,e[t]);return n},b=(n,e)=>S(n,R(e));var c=(n,e,t)=>g(n,typeof e!="symbol"?e+"":e,t);var u=class extends Error{constructor(t,r,i,a){super(t);c(this,"status",r);c(this,"code",i);c(this,"detail",a);this.name="OpenPartnerError"}};async function h(n,e={},t=globalThis.fetch){var s,f;let r=new Headers(e.headers);e.body!==void 0&&!r.has("content-type")&&r.set("content-type","application/json");let i=await t(n,b(l({},e),{headers:r,body:e.body===void 0?void 0:typeof e.body=="string"?e.body:JSON.stringify(e.body)})),a=await i.text(),o;if(a)try{o=JSON.parse(a)}catch(d){o=a}if(!i.ok){let d=o!=null?o:{};throw new u((s=d.error)!=null?s:`${i.status} ${i.statusText}`,i.status,d.error,(f=d.detail)!=null?f:o)}return o}var P={cookieName:"_cref",queryParam:"cref",storageKey:"openpartner:cref",stripe:!0,referralCookie:!1,consent:"granted",reportLanding:!0,onError:n=>{console.warn("[openpartner] request failed",n)}},m=/^[A-Za-z0-9_-]{1,200}$/,k="stripe-pricing-table, stripe-buy-button",A="op_ref",p=class n{constructor(e){c(this,"config");c(this,"observer",null);c(this,"consent");c(this,"held",null);c(this,"current",null);c(this,"tagged",[]);this.config=l(l({},P),e),this.consent=this.config.consent,this.consent==="granted"?this.start():this.consent==="pending"?this.held=this.queryCref():this.clear()}static init(e){return new n(e)}getCref(){var e,t;return this.consent!=="granted"?null:(t=(e=this.storageGet())!=null?e:this.current)!=null?t:this.readCookie(this.referralCookieName())}getReferral(){return this.getCref()}clear(){if(this.held=null,this.current=null,this.untagStripe(),this.storageRemove(),typeof document=="undefined")return;let e=this.referralCookieName();if(!this.readCookie(e))return;let t=this.config.referralCookie;document.cookie=`${e}=; Max-Age=0; Path=/`,t&&t.domain&&(document.cookie=`${e}=; Max-Age=0; Path=/; Domain=${t.domain}`)}setConsent(e){if(e==="granted"){if(this.consent==="granted")return;this.consent=e,this.start();return}this.consent=e,this.stopLinkingStripe(),this.clear()}linkStripe(e=document){let t=this.getReferral();if(!t||!m.test(t)||typeof document=="undefined")return 0;let r=new Set(["buy.stripe.com",...this.stripeLinkHosts()]),i=0,a=[];e instanceof HTMLAnchorElement&&a.push(e),a.push(...Array.from(e.querySelectorAll("a[href]")));for(let s of a){let f;try{f=new URL(s.href)}catch(d){continue}!r.has(f.hostname.toLowerCase())||f.searchParams.has("client_reference_id")||(f.searchParams.set("client_reference_id",t),s.href=f.toString(),this.remember(s,t),i+=1)}let o=[];e instanceof Element&&e.matches(k)&&o.push(e),o.push(...Array.from(e.querySelectorAll(k)));for(let s of o)s.hasAttribute("client-reference-id")||(s.setAttribute("client-reference-id",t),this.remember(s,t),i+=1);return i}remember(e,t){this.tagged=this.tagged.filter(r=>r.el.isConnected),this.tagged.push({el:e,value:t})}untagStripe(){for(let{el:e,value:t}of this.tagged)if(e instanceof HTMLAnchorElement){let r;try{r=new URL(e.href)}catch(i){continue}if(r.searchParams.get("client_reference_id")!==t)continue;r.searchParams.delete("client_reference_id"),e.href=r.toString()}else e.getAttribute("client-reference-id")===t&&e.removeAttribute("client-reference-id");this.tagged=[]}stopLinkingStripe(){var e;(e=this.observer)==null||e.disconnect(),this.observer=null}stripeLinkHosts(){let e=this.config.stripe;return typeof e=="object"&&e.linkHosts?e.linkHosts.map(t=>t.trim().toLowerCase()).filter(Boolean):[]}start(){let e=this.captureCref(this.held);this.held=null,this.writeReferralCookie(),this.config.stripe!==!1&&this.watchStripe(),e&&this.config.reportLanding!==!1&&this.reportLanding(e)}watchStripe(){typeof document=="undefined"||this.observer||!this.getReferral()||(this.linkStripe(document),typeof MutationObserver!="undefined"&&(this.observer=new MutationObserver(e=>{for(let t of e){if(t.type==="attributes"){t.target instanceof HTMLAnchorElement&&this.linkStripe(t.target);continue}for(let r of Array.from(t.addedNodes))r instanceof Element&&this.linkStripe(r)}}),this.observer.observe(document.documentElement,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["href"]})))}queryCref(){return typeof window=="undefined"?null:new URL(window.location.href).searchParams.get(this.config.queryParam)}captureCref(e){var i;if(typeof window=="undefined")return null;let t=e!=null?e:this.queryCref();if(t){let a=this.storageGet()!==t;return this.current=t,this.storageSet(t),a?t:null}if(this.storageGet())return null;let r=(i=this.readCookie(this.config.cookieName))!=null?i:this.readCookie(this.referralCookieName());return r&&(this.current=r,this.storageSet(r)),null}referralCookieName(){let e=this.config.referralCookie;return e&&e.name||A}writeReferralCookie(){var s;let e=this.config.referralCookie;if(!e||typeof document=="undefined")return;let t=this.getReferral();if(!t||!m.test(t))return;let r=this.referralCookieName();if(this.readCookie(r)===t)return;let i=Math.round(((s=e.days)!=null?s:90)*86400),a=typeof location!="undefined"&&location.protocol==="https:"?"; Secure":"",o=e.domain?`; Domain=${e.domain}`:"";document.cookie=`${r}=${encodeURIComponent(t)}; Max-Age=${i}; Path=/; SameSite=Lax${o}${a}`}readCookie(e){if(typeof document=="undefined")return null;let t=document.cookie.match(new RegExp(`(^|; )${e}=([^;]+)`));if(!t)return null;try{return decodeURIComponent(t[2])}catch(r){return null}}storageGet(){try{return typeof localStorage=="undefined"?null:localStorage.getItem(this.config.storageKey)}catch(e){return null}}storageSet(e){try{typeof localStorage!="undefined"&&localStorage.setItem(this.config.storageKey,e)}catch(t){}}storageRemove(){try{typeof localStorage!="undefined"&&localStorage.removeItem(this.config.storageKey)}catch(e){}}async reportLanding(e){if(!(!m.test(e)||typeof fetch=="undefined"))try{await h(`${this.config.apiUrl}/attribution/landing`,{method:"POST",body:{cref:e},keepalive:!0})}catch(t){t instanceof u&&t.code==="click_not_found"&&this.config.onError(t)}}async identify(e){let t=this.getCref();if(!t)return null;try{return await h(`${this.config.apiUrl}/attribution/identify`,{method:"POST",body:{cref:t,userId:e,ts:Date.now()},keepalive:!0})}catch(r){return this.config.onError(r),null}}};function x(n){var a,o;let e=n.dataset,t=((a=e.apiUrl)!=null?a:"").trim();if(!t)try{t=`${new URL(n.src).origin}/api`}catch(s){t="/api"}let r=((o=e.stripeHosts)!=null?o:"").split(",").map(s=>s.trim()).filter(Boolean),i=e.consent==="pending"||e.consent==="denied"?e.consent:"granted";return{apiUrl:t.replace(/\/$/,""),stripe:e.stripe==="off"?!1:{linkHosts:r},referralCookie:e.cookie==="off"?!1:l({name:"op_ref"},e.cookieDomain?{domain:e.cookieDomain}:{}),consent:i,reportLanding:e.landing!=="off"}}var v,w;if(typeof window!="undefined"&&typeof document!="undefined"&&!window.openpartner){let n=document.currentScript;window.OpenPartner=p,window.openpartner=p.init(x({src:(v=n==null?void 0:n.src)!=null?v:window.location.href,dataset:l({},(w=n==null?void 0:n.dataset)!=null?w:{})}))}})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpartner/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenPartner SDK. Browser client for click → identity stitching + Node server SDK for conversion events and attribution reads.",
5
5
  "type": "module",
6
6
  "main": "./dist/browser.cjs",
@@ -8,19 +8,38 @@
8
8
  "types": "./dist/browser.d.ts",
9
9
  "exports": {
10
10
  ".": {
11
- "types": "./dist/browser.d.ts",
12
- "import": "./dist/browser.js",
13
- "require": "./dist/browser.cjs"
11
+ "import": {
12
+ "types": "./dist/browser.d.ts",
13
+ "default": "./dist/browser.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/browser.d.cts",
17
+ "default": "./dist/browser.cjs"
18
+ }
14
19
  },
15
20
  "./server": {
16
- "types": "./dist/server.d.ts",
17
- "import": "./dist/server.js",
18
- "require": "./dist/server.cjs"
21
+ "import": {
22
+ "types": "./dist/server.d.ts",
23
+ "default": "./dist/server.js"
24
+ },
25
+ "require": {
26
+ "types": "./dist/server.d.cts",
27
+ "default": "./dist/server.cjs"
28
+ }
29
+ }
30
+ },
31
+ "typesVersions": {
32
+ "*": {
33
+ "server": [
34
+ "./dist/server.d.ts"
35
+ ]
19
36
  }
20
37
  },
21
38
  "files": [
22
39
  "dist",
23
- "README.md"
40
+ "dist-global",
41
+ "README.md",
42
+ "CHANGELOG.md"
24
43
  ],
25
44
  "publishConfig": {
26
45
  "access": "public"
@@ -41,16 +60,19 @@
41
60
  },
42
61
  "license": "MIT",
43
62
  "sideEffects": false,
44
- "devDependencies": {
45
- "jsdom": "^29.0.2",
46
- "tsup": "^8.5.1",
47
- "typescript": "5.4.5",
48
- "vitest": "^1.6.0"
49
- },
50
63
  "scripts": {
51
64
  "build": "tsup",
65
+ "build:global": "tsup --config tsup.global.config.ts",
52
66
  "dev": "tsup --watch",
53
67
  "lint": "tsc --noEmit",
54
68
  "test": "vitest run --passWithNoTests"
55
- }
56
- }
69
+ },
70
+ "devDependencies": {
71
+ "jsdom": "^29.0.2",
72
+ "tsup": "^9.0.0",
73
+ "typescript": "6.0.3",
74
+ "vitest": "^4.1.12"
75
+ },
76
+ "unpkg": "./dist-global/openpartner.js",
77
+ "jsdelivr": "./dist-global/openpartner.js"
78
+ }