@sagepilot-ai/web-events-addon 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -87,13 +87,15 @@ In Settings → SDK, select a web installation, turn on **First-party visitor co
87
87
 
88
88
  Records retain event ID, occurrence time, workspace/registered installation, original signed session owner and local identity epoch. A separate credential cache retains at most four owner generations across verification/reload so anonymous backlog stays anonymous. Records whose authority is unavailable or explicitly revoked are dropped with diagnostics; they never block newer owners or get relabeled.
89
89
 
90
- ## Popup and verified identity
90
+ ## Popup and visitor identity
91
91
 
92
92
  Settings → SDK publishes a text-only popup template independently from mapping versions. The installed runtime renders the published revision inside a shadow DOM, with exact page paths, delay/scroll conditions, frequency limits, required fields and keyboard dismissal/focus management. Submitting the form sets the existing contact endpoint's `marketing_consent` field to `true`. The renderer and dashboard preview have no consent checkbox or label. Drafts never render. Disabled publication removes an open popup after refresh. Native app registrations do not render website popups.
93
93
 
94
- Email verification happens through the explicit contact endpoint. Contact-only forms save self-reported details and keep anonymous authority. Only a completed server challenge creates an identified session; phone numbers remain unverified. Form values and verification codes never enter ordinary observations. Shown/submitted/dismissed lifecycle observations obey collection permission.
94
+ A contact-only form requires a phone number and can collect an optional email. Platform API resolves a compatible phone match first, then tries a unique email match. It creates a customer when neither matches and saves the form contact as unverified. Earlier events retain their original capture owner and resolve through the browser link. When email verification is enabled, the email field is required and the link is made after the code succeeds.
95
95
 
96
- The popup handles submission/verification itself. Host-owned forms can use `submitContact({submission_id,popup_revision,name?,email?,phone?,marketing_consent})` and `verifyContact(challengeId,code)`. `logout()` immediately pauses capture and cancels pending uploads before revoking the API grant and establishing a fresh anonymous owner. Queued events retain their original ownership. The collector discards any records whose credentials the logout revoked. Existing signed ingress credentials expire within 15 minutes.
96
+ With a compatible Platform API, a successful form submission sends one `popup_submitted` event through Event Handler even when storefront analytics is off. Popup impressions, dismissals and automatic events still follow collection permission. Form values and codes stay off browser observations. FJE adds the selected customer's contact details and verification flags to attributed event history.
97
+
98
+ The popup handles submission/verification itself. Host-owned forms can use `submitContact({submission_id,popup_revision,phone,name?,email?,marketing_consent})` and `verifyContact(challengeId,code)`. `logout()` immediately pauses capture and cancels pending uploads before revoking the API grant and establishing a fresh anonymous owner. Queued events retain their original ownership. The collector discards any records whose credentials the logout revoked. Existing signed ingress credentials expire within 15 minutes.
97
99
 
98
100
  - At most 128 pending route/click/network emissions per collector, and 128 capture writes / 512 KiB of serialized handoff evidence.
99
101
  - The journal holds up to 8,192 records or 8 MiB, whichever comes first. It evicts oldest records at capacity. The 24-hour local expiry fits the server's existing offline renewal window.
package/dist/index.cjs CHANGED
@@ -142,27 +142,40 @@ var SagepilotInstallationClient = class {
142
142
  this.startup = startup;
143
143
  return startup;
144
144
  }
145
- /** Renews the owner or, when allowed for web capture, starts fresh after an expired browser session. */
145
+ /** Renews capture, using separate browser proof to recover a linked web visitor after expiry. */
146
146
  async refresh(recoverAnonymous = false) {
147
147
  if (this.pending) return this.pending;
148
148
  if (!this.anonymousId) throw new Error("Start the installation client before renewing it.");
149
149
  const generation = this.generation, prior = this.current;
150
150
  const pending = (async () => {
151
151
  let renewing = prior;
152
+ let resumed = false;
152
153
  let value;
153
154
  try {
154
155
  value = await this.requestSession(prior?.session_token);
155
156
  } catch (error) {
156
157
  if (!recoverAnonymous || !prior || this.options.platform !== "web" || generation !== this.generation || !(error instanceof SagepilotInstallationError) || error.status !== 401) throw error;
157
158
  renewing = null;
158
- value = await this.requestSession();
159
+ if (prior.browser_resume_token) {
160
+ try {
161
+ value = await this.requestSession(prior.browser_resume_token, true);
162
+ resumed = true;
163
+ } catch (resumeError) {
164
+ if (generation !== this.generation || !(resumeError instanceof SagepilotInstallationError) || ![401, 403, 422].includes(resumeError.status ?? 0)) throw resumeError;
165
+ }
166
+ }
167
+ if (!resumed) value = await this.requestSession();
159
168
  }
160
169
  const next = this.validateSession(value);
161
170
  if (generation !== this.generation) throw new Error("SDK session startup was cancelled.");
162
- if (renewing && (next.session_id !== renewing.session_id || next.customer_id !== renewing.customer_id) || !renewing && (next.customer_id !== null || next.session_id === prior?.session_id)) {
171
+ if (renewing && (next.session_id !== renewing.session_id || next.customer_id !== renewing.customer_id) || !renewing && (next.customer_id !== null || !resumed && next.session_id === prior?.session_id)) {
163
172
  throw new Error("SDK session renewal changed capture ownership.");
164
173
  }
165
- if (prior && !renewing) this.owners.delete(prior.session_id);
174
+ if (resumed && next.browser_resume_token !== prior?.browser_resume_token) {
175
+ throw new Error("SDK browser recovery returned invalid proof.");
176
+ }
177
+ if (renewing && !next.browser_resume_token) next.browser_resume_token = renewing.browser_resume_token;
178
+ if (prior && next.session_id !== prior.session_id) this.owners.delete(prior.session_id);
166
179
  this.current = next;
167
180
  this.remember(next);
168
181
  try {
@@ -176,8 +189,8 @@ var SagepilotInstallationClient = class {
176
189
  this.pending = pending;
177
190
  return pending;
178
191
  }
179
- /** Requests policy for this browser or device with the original token when renewing. */
180
- requestSession(token) {
192
+ /** Sends only the capture token for renewal or the distinct credential for browser recovery. */
193
+ requestSession(token, resumeBrowser = false) {
181
194
  return this.request(`${this.apiHost}/platform-api/v1/sdk/sessions`, {
182
195
  method: "POST",
183
196
  headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -185,7 +198,8 @@ var SagepilotInstallationClient = class {
185
198
  key: `${this.workspaceId}:${this.installationId}`,
186
199
  anonymous_id: this.anonymousId,
187
200
  platform: this.options.platform,
188
- ...this.options.appId ? { app_id: this.options.appId } : {}
201
+ ...this.options.appId ? { app_id: this.options.appId } : {},
202
+ ...resumeBrowser ? { resume_browser: true } : {}
189
203
  })
190
204
  });
191
205
  }
@@ -223,12 +237,25 @@ var SagepilotInstallationClient = class {
223
237
  if (new TextEncoder().encode(body).byteLength > 128 * 1024) throw new Error("SDK batch exceeds the upload limit.");
224
238
  return validateSagepilotInstallationReceipt(await this.request(auth.url, { method: "POST", headers: auth.headers, body }), observations.map((item) => item.event_id));
225
239
  }
226
- /** Submits explicit popup contacts independently from ordinary observation payloads. */
240
+ /** Submits contact and adopts only a scoped customer session returned by the server. */
227
241
  async submitContact(input) {
228
242
  if (!UUID.test(input.submission_id) || !Number.isSafeInteger(input.popup_revision) || input.popup_revision < 1 || typeof input.marketing_consent !== "boolean") throw new Error("Invalid popup submission.");
229
- const result = await this.authenticated("/contact", input);
230
- if (!result || !["recorded", "verification_required"].includes(result.status) || result.status === "verification_required" && (!result.challenge_id || !UUID.test(result.challenge_id) || !Number.isFinite(Date.parse(result.expires_at ?? "")))) throw new Error("Invalid popup submission response.");
231
- return result;
243
+ if (this.transitioning) throw new Error("An SDK identity operation is already running.");
244
+ this.transitioning = true;
245
+ const generation = this.generation, prior = this.current;
246
+ try {
247
+ const body = prior?.popup_submission_events === true ? { ...input, server_submission_event: true } : input;
248
+ const result = await this.authenticated("/contact", body);
249
+ if (!result || !["recorded", "verification_required"].includes(result.status) || result.status === "verification_required" && (!result.challenge_id || !UUID.test(result.challenge_id) || !Number.isFinite(Date.parse(result.expires_at ?? "")))) throw new Error("Invalid popup submission response.");
250
+ const { session, ...acknowledgement } = result;
251
+ if (session != null) {
252
+ if (result.status !== "recorded" || prior?.customer_id !== null) throw new Error("Invalid popup customer session.");
253
+ await this.adoptTransition(session, prior, generation, true);
254
+ }
255
+ return acknowledgement;
256
+ } finally {
257
+ this.transitioning = false;
258
+ }
232
259
  }
233
260
  /** Adopts customer authority only from the server's completed verification operation. */
234
261
  async verifyContact(challengeId, code) {
@@ -241,7 +268,7 @@ var SagepilotInstallationClient = class {
241
268
  }
242
269
  /** Keeps a bounded credential cache separate from observations so old anonymous batches retain their owner. */
243
270
  remember(session) {
244
- this.owners.set(session.session_id, { ...session });
271
+ this.owners.set(session.session_id, { ...session, browser_resume_token: void 0 });
245
272
  while (this.owners.size > 4) this.owners.delete(this.owners.keys().next().value);
246
273
  try {
247
274
  void Promise.resolve(this.options.storage?.setItem(this.storageKey + ":owners", JSON.stringify([...this.owners.values()]))).catch(() => {
@@ -263,24 +290,28 @@ var SagepilotInstallationClient = class {
263
290
  this.transitioning = true;
264
291
  const generation = this.generation, prior = this.current;
265
292
  try {
266
- const next = this.validateSession(await this.authenticated(path, body));
267
- if (generation !== this.generation || !prior || next.session_id === prior.session_id || (identified ? next.customer_id === null : next.customer_id !== null)) throw new Error("Invalid SDK identity transition.");
268
- this.generation++;
269
- this.cancelPending();
270
- this.pending = null;
271
- if (path === "/logout") this.owners.delete(prior.session_id);
272
- else this.remember(prior);
273
- this.current = next;
274
- this.remember(next);
275
- try {
276
- await this.options.storage?.setItem(this.storageKey, JSON.stringify(next));
277
- } catch {
278
- }
279
- return { ...next };
293
+ return await this.adoptTransition(await this.authenticated(path, body), prior, generation, identified);
280
294
  } finally {
281
295
  this.transitioning = false;
282
296
  }
283
297
  }
298
+ /** Installs one new server owner and retains earlier credentials unless the owner logged out. */
299
+ async adoptTransition(value, prior, generation, identified) {
300
+ const next = this.validateSession(value);
301
+ if (generation !== this.generation || !prior || next.session_id === prior.session_id || (identified ? next.customer_id === null : next.customer_id !== null)) throw new Error("Invalid SDK identity transition.");
302
+ this.generation++;
303
+ this.cancelPending();
304
+ this.pending = null;
305
+ if (identified) this.remember(prior);
306
+ else this.owners.delete(prior.session_id);
307
+ this.current = next;
308
+ this.remember(next);
309
+ try {
310
+ await this.options.storage?.setItem(this.storageKey, JSON.stringify(next));
311
+ } catch {
312
+ }
313
+ return { ...next };
314
+ }
284
315
  /** Cancels requests on permission/ownership boundaries while preserving the current session for safe renewal. */
285
316
  cancelPending() {
286
317
  this.uploadRevision++;
@@ -296,10 +327,10 @@ var SagepilotInstallationClient = class {
296
327
  this.startup = null;
297
328
  this.owners.clear();
298
329
  }
299
- /** Rejects foreign, identified or malformed session responses before credentials are used. */
330
+ /** Rejects foreign or malformed session responses before credentials are used. */
300
331
  validateSession(value) {
301
332
  const session = value;
302
- if (!session || session.workspace_id !== this.workspaceId || session.installation_id !== this.installationId || session.platform !== this.options.platform || session.anonymous_id !== this.anonymousId || session.customer_id !== null && (typeof session.customer_id !== "string" || !UUID.test(session.customer_id)) || !UUID.test(session.session_id) || typeof session.session_token !== "string" || session.session_token.length > 16384 || !session.session_token || !Number.isFinite(Date.parse(session.expires_at)) || typeof session.collection_enabled !== "boolean" || session.visitor_cookie_enabled !== void 0 && typeof session.visitor_cookie_enabled !== "boolean" || !Number.isSafeInteger(session.revision)) {
333
+ if (!session || session.workspace_id !== this.workspaceId || session.installation_id !== this.installationId || session.platform !== this.options.platform || session.anonymous_id !== this.anonymousId || session.customer_id !== null && (typeof session.customer_id !== "string" || !UUID.test(session.customer_id)) || !UUID.test(session.session_id) || typeof session.session_token !== "string" || session.session_token.length > 16384 || !session.session_token || session.browser_resume_token != null && (session.platform !== "web" || typeof session.browser_resume_token !== "string" || !session.browser_resume_token || session.browser_resume_token.length > 8192) || !Number.isFinite(Date.parse(session.expires_at)) || typeof session.collection_enabled !== "boolean" || session.visitor_cookie_enabled !== void 0 && typeof session.visitor_cookie_enabled !== "boolean" || !Number.isSafeInteger(session.revision)) {
303
334
  throw new Error("SDK session response has invalid scope or ownership.");
304
335
  }
305
336
  const url = safeURL(session.ingestion_url);
@@ -364,7 +395,7 @@ function createSagepilotInstallationClient(options) {
364
395
 
365
396
  // package.json
366
397
  var name = "@sagepilot-ai/web-events-addon";
367
- var version = "0.2.4";
398
+ var version = "0.2.6";
368
399
 
369
400
  // src/projection.ts
370
401
  var sensitive = /password|passwd|secret|token|authorization|cookie|session|email|phone|contact|address|card|payment|cvv|ssn|dob|birth|customer|user|account|person|profile|name|description|message|content|text|search|query/i;
@@ -2366,7 +2397,6 @@ function createPopup(options) {
2366
2397
  }
2367
2398
  const result = await options.submit(contact);
2368
2399
  if (revision !== generation) return;
2369
- options.emit("popup_submitted");
2370
2400
  for (const input of inputs.values()) input.value = "";
2371
2401
  if (result.status === "verification_required") {
2372
2402
  let restartForm2 = function() {
@@ -2664,13 +2694,14 @@ function createSagepilotWebEvents(options) {
2664
2694
  captureBytes -= bytes;
2665
2695
  });
2666
2696
  };
2697
+ function emitPopup(action) {
2698
+ if (enabled()) capture("lifecycle", { action }, epoch, Date.now());
2699
+ }
2667
2700
  const popup = createPopup({
2668
2701
  client,
2669
2702
  storage,
2670
2703
  report,
2671
- emit(action) {
2672
- if (enabled()) capture("lifecycle", { action }, epoch, Date.now());
2673
- },
2704
+ emit: emitPopup,
2674
2705
  submit(input) {
2675
2706
  return addon.submitContact(input);
2676
2707
  },
@@ -2687,7 +2718,7 @@ function createSagepilotWebEvents(options) {
2687
2718
  detach(true);
2688
2719
  client.cancelPending();
2689
2720
  try {
2690
- await operation();
2721
+ return await operation();
2691
2722
  } finally {
2692
2723
  identityChanging = false;
2693
2724
  if (!destroyed) {
@@ -2916,17 +2947,21 @@ function createSagepilotWebEvents(options) {
2916
2947
  }
2917
2948
  },
2918
2949
  flush,
2919
- /** Submits contact-only data through the explicit identity endpoint, never through autocapture. */
2950
+ /** Keeps contact on the identity endpoint and fences capture while adopting the customer. */
2920
2951
  async submitContact(input) {
2921
- const anonymousId = client.session()?.anonymous_id;
2922
- const result = await client.submitContact(input);
2923
- if (result.status === "recorded") await completeVisitor(anonymousId);
2952
+ const session = client.session();
2953
+ const result = session?.popup?.verification === "none" && session.customer_id === null ? await changeIdentity(() => client.submitContact(input)) : await client.submitContact(input);
2954
+ if (result.status === "recorded") {
2955
+ if (result.popup_submission_recorded !== true) emitPopup("popup_submitted");
2956
+ await completeVisitor(session?.anonymous_id);
2957
+ }
2924
2958
  return result;
2925
2959
  },
2926
2960
  /** Verifies proof behind a capture fence; buffered anonymous observations remain anonymous. */
2927
2961
  async verifyContact(challengeId, code) {
2928
2962
  const anonymousId = client.session()?.anonymous_id;
2929
- await changeIdentity(() => client.verifyContact(challengeId, code));
2963
+ const session = await changeIdentity(() => client.verifyContact(challengeId, code));
2964
+ if (session.popup_submission_recorded !== true) emitPopup("popup_submitted");
2930
2965
  await completeVisitor(anonymousId);
2931
2966
  },
2932
2967
  /** Applies an explicit logout boundary without relabeling earlier observations. */