pulse-updates 1.3.7 → 1.3.9

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.
@@ -7,6 +7,14 @@
7
7
  * optional account bridge below.
8
8
  */
9
9
 
10
+ /** Explicit receiver decision for one terminal-delivery attempt. */
11
+
12
+ /**
13
+ * Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
14
+ * install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
15
+ * can never make the client navigate, including when Encore reports a shadow observation.
16
+ */
17
+
10
18
  /** Implemented by the separate native `PulseAttribution` module. */
11
19
 
12
20
  const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
@@ -23,6 +31,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
23
31
  const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
24
32
  const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
25
33
  const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
34
+ const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
35
+ const CAPTURE_PRIORITY = {
36
+ direct_token: 3,
37
+ ios_user_paste: 2,
38
+ android_install_referrer: 1
39
+ };
40
+ const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
41
+ const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
26
42
  const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
27
43
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
28
44
  const DEFAULT_RETRY_BASE_MS = 15_000;
@@ -33,10 +49,14 @@ const MAX_APPLIED_IDS = 32;
33
49
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
34
50
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
35
51
  const MAX_PERSISTED_BYTES = 131_072;
52
+ const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
36
53
  const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
37
54
  const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
38
55
  const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
39
56
  const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
57
+
58
+ /** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
59
+
40
60
  const emptyState = () => ({
41
61
  version: 1,
42
62
  status: 'idle',
@@ -45,6 +65,7 @@ const emptyState = () => ({
45
65
  appliedIds: [],
46
66
  notifiedOutcomes: [],
47
67
  outcomeQueue: [],
68
+ terminalDelivery: null,
48
69
  firstOpen: {
49
70
  installAttemptId: null,
50
71
  completed: false,
@@ -145,6 +166,7 @@ export function createPulseLinkClient(options) {
145
166
  }
146
167
  const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
147
168
  const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
169
+ const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
148
170
  const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
149
171
  const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
150
172
  const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
@@ -153,11 +175,26 @@ export function createPulseLinkClient(options) {
153
175
  let state = readState(options.storage, storageKey);
154
176
  let disposed = false;
155
177
  let processing = null;
178
+ let firstOpenMatching = null;
156
179
  let processRequested = false;
157
180
  let retryTimer = null;
158
181
  let outcomeSending = null;
182
+ let terminalDeliverySending = null;
183
+ let terminalDeliveryGeneration = 0;
159
184
  let accountRetryAt = 0;
160
185
  let accountAttempts = 0;
186
+ let deterministicEpoch = 0;
187
+ // Invalidates every host callback that crossed an await when this client is reset/disposed.
188
+ // This is deliberately separate from deterministicEpoch: captures during a committed callback
189
+ // have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
190
+ // state write, retry and outcome from the old instance.
191
+ let lifecycleGeneration = 0;
192
+ // Host navigation is irreversible once any routing callback has started: it may perform its
193
+ // side effect synchronously before returning a Promise. Serialize captures against that commit
194
+ // boundary and keep the newer accepted token durable for the next client lifecycle instead of
195
+ // automatically opening a second journey behind the first one.
196
+ let routingApplicationInFlight = false;
197
+ let deterministicTokenDeferredAfterCommittedJourney = null;
161
198
  let lastFirstOpenContext = null;
162
199
  let unsubscribeAccount = null;
163
200
  const listeners = new Set();
@@ -166,6 +203,25 @@ export function createPulseLinkClient(options) {
166
203
  options.onError?.(error);
167
204
  } catch {/* diagnostic hooks never break link handling */}
168
205
  };
206
+
207
+ // A build without a receiver has not opted into retaining this analytics envelope. First replace
208
+ // an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
209
+ // may fail, but a later app version can then only retry local deletion, never resurrect delivery.
210
+ if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
211
+ const tombstone = {
212
+ eventId: state.terminalDelivery.eventId,
213
+ disposition: 'drop',
214
+ attempts: state.terminalDelivery.attempts,
215
+ nextRetryAt: 0
216
+ };
217
+ state = {
218
+ ...state,
219
+ terminalDelivery: tombstone
220
+ };
221
+ if (!writeState(options.storage, storageKey, state)) {
222
+ reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
223
+ }
224
+ }
169
225
  const snapshot = () => ({
170
226
  status: state.status,
171
227
  pending: state.pending ? {
@@ -177,8 +233,7 @@ export function createPulseLinkClient(options) {
177
233
  ...state.firstOpen
178
234
  }
179
235
  });
180
- const persistAndNotify = () => {
181
- writeState(options.storage, storageKey, state);
236
+ const notifyState = () => {
182
237
  const value = snapshot();
183
238
  for (const listener of listeners) {
184
239
  try {
@@ -194,6 +249,11 @@ export function createPulseLinkClient(options) {
194
249
  }
195
250
  scheduleWake();
196
251
  };
252
+ const persistAndNotify = () => {
253
+ const persisted = writeState(options.storage, storageKey, state);
254
+ notifyState();
255
+ return persisted;
256
+ };
197
257
  const patchState = patch => {
198
258
  state = {
199
259
  ...state,
@@ -211,13 +271,229 @@ export function createPulseLinkClient(options) {
211
271
  };
212
272
  persistAndNotify();
213
273
  };
214
- const isAccountReady = () => {
274
+ const terminalDeliveryOutcome = queued => ({
275
+ eventId: queued.eventId,
276
+ status: queued.status,
277
+ rail: queued.rail,
278
+ routed: queued.routed,
279
+ retryable: false,
280
+ reason: queued.reason,
281
+ occurredAt: queued.occurredAt,
282
+ ...(queued.matchBasis !== undefined ? {
283
+ matchBasis: queued.matchBasis
284
+ } : {}),
285
+ ...(queued.confidence !== undefined ? {
286
+ confidence: queued.confidence
287
+ } : {}),
288
+ ...(queued.campaignId !== undefined ? {
289
+ campaignId: queued.campaignId
290
+ } : {}),
291
+ ...(queued.experimentId !== undefined ? {
292
+ experimentId: queued.experimentId
293
+ } : {}),
294
+ ...(queued.variantId !== undefined ? {
295
+ variantId: queued.variantId
296
+ } : {})
297
+ });
298
+ const scheduleTerminalDeliveryRetry = queued => {
299
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
300
+ const attempts = Math.min(queued.attempts + 1, 100_000);
301
+ state = {
302
+ ...state,
303
+ terminalDelivery: {
304
+ ...queued,
305
+ attempts,
306
+ nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
307
+ }
308
+ };
309
+ persistAndNotify();
310
+ };
311
+ const acknowledgeTerminalDelivery = queued => {
312
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
313
+ const acknowledgedState = {
314
+ ...state,
315
+ terminalDelivery: null
316
+ };
317
+ // Clearing the record is itself transactional. If this write fails after the receiver
318
+ // accepted the event, retain and replay the same eventId: that is why the contract is
319
+ // at-least-once and why receivers must deduplicate.
320
+ if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
321
+ reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
322
+ scheduleTerminalDeliveryRetry(queued);
323
+ return false;
324
+ }
325
+ state = acknowledgedState;
326
+ notifyState();
327
+ return true;
328
+ };
329
+ const flushTerminalDelivery = () => {
330
+ if (disposed) return Promise.resolve(false);
331
+ if (terminalDeliverySending) return terminalDeliverySending;
332
+ const queued = state.terminalDelivery;
333
+ if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
334
+ if (queued.disposition === 'drop') {
335
+ return Promise.resolve(acknowledgeTerminalDelivery(queued));
336
+ }
337
+ if (!options.onFirstOpenResult) return Promise.resolve(false);
338
+ const generation = terminalDeliveryGeneration;
339
+ const run = async () => {
340
+ // Never call the receiver before the complete record is durable. With no storage adapter,
341
+ // this is an explicitly memory-only degradation rather than a cross-restart guarantee.
342
+ if (!writeState(options.storage, storageKey, state)) {
343
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
344
+ scheduleTerminalDeliveryRetry(queued);
345
+ return false;
346
+ }
347
+ let rawDisposition;
348
+ try {
349
+ rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
350
+ } catch (error) {
351
+ reportError(error);
352
+ scheduleTerminalDeliveryRetry(queued);
353
+ return false;
354
+ }
355
+ let disposition;
356
+ if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
357
+ disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
358
+ } else {
359
+ try {
360
+ const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
361
+ disposition = normalizeTerminalDeliveryDisposition(resolved);
362
+ } catch (error) {
363
+ if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
364
+ if (!disposed && terminalDeliveryGeneration === generation) {
365
+ scheduleTerminalDeliveryRetry(queued);
366
+ }
367
+ return false;
368
+ }
369
+ }
370
+ if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
371
+ if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
372
+ if (disposition === 'drop') return dropTerminalDelivery(queued);
373
+ scheduleTerminalDeliveryRetry(queued);
374
+ return false;
375
+ };
376
+ let owned;
377
+ owned = run().finally(() => {
378
+ if (terminalDeliverySending === owned) terminalDeliverySending = null;
379
+ const pending = state.terminalDelivery;
380
+ if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
381
+ void flushTerminalDelivery();
382
+ }
383
+ });
384
+ terminalDeliverySending = owned;
385
+ return owned;
386
+ };
387
+ const dropTerminalDelivery = queued => {
388
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
389
+ const tombstone = {
390
+ eventId: queued.eventId,
391
+ disposition: 'drop',
392
+ attempts: queued.attempts,
393
+ nextRetryAt: 0
394
+ };
395
+ state = {
396
+ ...state,
397
+ terminalDelivery: tombstone
398
+ };
399
+ // Persist the sticky disposition before trying to delete it. A failed local deletion can then
400
+ // retry only deletion after restart; it must never call the receiver or resurrect collection.
401
+ if (!writeState(options.storage, storageKey, state)) {
402
+ reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
403
+ scheduleTerminalDeliveryRetry(tombstone);
404
+ return false;
405
+ }
406
+ notifyState();
407
+ return acknowledgeTerminalDelivery(tombstone);
408
+ };
409
+ const completeFirstOpen = (status, outcome) => {
410
+ const completionLifecycleGeneration = lifecycleGeneration;
411
+ const completionDeterministicEpoch = deterministicEpoch;
412
+ const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
413
+ let shouldQueue = Boolean(options.onFirstOpenResult);
414
+ if (shouldQueue && options.shouldQueueFirstOpenResult) {
415
+ try {
416
+ shouldQueue = options.shouldQueueFirstOpenResult() === true;
417
+ } catch (error) {
418
+ shouldQueue = false;
419
+ reportError(error);
420
+ }
421
+ // Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
422
+ // deterministic destination. Never resurrect the terminal first-open state they invalidated.
423
+ if (!completionIsCurrent()) return;
424
+ }
425
+ if (!shouldQueue) {
426
+ state = {
427
+ ...state,
428
+ status,
429
+ terminalDelivery: null,
430
+ firstOpen: {
431
+ ...state.firstOpen,
432
+ completed: true,
433
+ attempts: 0,
434
+ nextRetryAt: 0
435
+ }
436
+ };
437
+ persistAndNotify();
438
+ return;
439
+ }
440
+ const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
441
+ const delivery = state.terminalDelivery ?? {
442
+ ...outcome,
443
+ eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
444
+ retryable: false,
445
+ occurredAt: new Date(now()).toISOString(),
446
+ attempts: 0,
447
+ nextRetryAt: 0
448
+ };
449
+ state = {
450
+ ...state,
451
+ status,
452
+ terminalDelivery: delivery,
453
+ firstOpen: {
454
+ ...state.firstOpen,
455
+ completed: true,
456
+ attempts: 0,
457
+ nextRetryAt: 0
458
+ }
459
+ };
460
+ if (!persistAndNotify()) {
461
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
462
+ }
463
+ // Delivery is deliberately detached from routing/matcher completion.
464
+ void flushTerminalDelivery();
465
+ };
466
+ const blockFirstOpenForDeterministic = () => {
467
+ const blockingLifecycleGeneration = lifecycleGeneration;
468
+ deterministicEpoch += 1;
469
+ const blockingDeterministicEpoch = deterministicEpoch;
470
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
471
+ state = {
472
+ ...state,
473
+ firstOpen: {
474
+ ...state.firstOpen,
475
+ completed: true,
476
+ nextRetryAt: 0
477
+ }
478
+ };
479
+ persistAndNotify();
480
+ return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
481
+ };
482
+ const readAccountReadiness = () => {
483
+ const readinessLifecycleGeneration = lifecycleGeneration;
484
+ const readinessDeterministicEpoch = deterministicEpoch;
485
+ let ready = false;
215
486
  try {
216
- return options.isAccountReady?.() ?? false;
487
+ ready = options.isAccountReady?.() ?? false;
217
488
  } catch (error) {
218
489
  reportError(error);
219
- return false;
220
490
  }
491
+ return {
492
+ ready,
493
+ // Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
494
+ // the caller's snapshot and must be observed before any waiting/retry/application write.
495
+ current: !disposed && lifecycleGeneration === readinessLifecycleGeneration && deterministicEpoch === readinessDeterministicEpoch
496
+ };
221
497
  };
222
498
  const schedulePendingRetry = () => {
223
499
  const pending = state.pending;
@@ -425,7 +701,9 @@ export function createPulseLinkClient(options) {
425
701
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
426
702
  return false;
427
703
  }
428
- if (!isAccountReady()) {
704
+ const accountReadiness = readAccountReadiness();
705
+ if (!accountReadiness.current) return false;
706
+ if (!accountReadiness.ready) {
429
707
  patchState({
430
708
  status: 'waiting_for_account'
431
709
  });
@@ -443,9 +721,34 @@ export function createPulseLinkClient(options) {
443
721
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
444
722
  return false;
445
723
  }
724
+ let applied = false;
725
+ let applicationFailed = false;
726
+ let applicationError;
727
+ const applicationEpoch = deterministicEpoch;
728
+ const applicationLifecycleGeneration = lifecycleGeneration;
729
+ const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
730
+ const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
731
+ // A provenance upgrade or rejected lower-priority capture for the token already being
732
+ // applied does not represent another destination and must not leave that token pending.
733
+ && (sourceToken === null || state.pending.token !== sourceToken);
734
+ routingApplicationInFlight = true;
446
735
  emitOutcome(link, 'app_open_confirmed');
736
+ if (!lifecycleIsCurrent()) {
737
+ routingApplicationInFlight = false;
738
+ return false;
739
+ }
447
740
  emitOutcome(link, 'deferred_link_resolved');
448
- let applied;
741
+ if (!lifecycleIsCurrent()) {
742
+ routingApplicationInFlight = false;
743
+ return false;
744
+ }
745
+ // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
746
+ // a newer accepted destination, it still arrived before the routing callback and must win.
747
+ if (hasNewerPendingIntent()) {
748
+ routingApplicationInFlight = false;
749
+ void process();
750
+ return false;
751
+ }
449
752
  try {
450
753
  if (link.deepLink) {
451
754
  applied = await options.onDeepLink(link.deepLink, link);
@@ -456,18 +759,50 @@ export function createPulseLinkClient(options) {
456
759
  return false;
457
760
  }
458
761
  } catch (error) {
459
- reportError(error);
762
+ applicationFailed = true;
763
+ applicationError = error;
764
+ } finally {
765
+ routingApplicationInFlight = false;
766
+ }
767
+
768
+ // The callback may resolve after reset/dispose and after another client has already committed
769
+ // a newer journey into the same storage. The old instance must not schedule a retry, rewrite
770
+ // state, or emit action_applied regardless of whether the callback returned true/false/threw.
771
+ if (!lifecycleIsCurrent()) return false;
772
+ const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
773
+ if (applicationFailed) {
774
+ reportError(applicationError);
775
+ if (!lifecycleIsCurrent()) return false;
776
+ // onError is host code too: it may capture a newer deterministic destination while reporting
777
+ // this failure. Re-evaluate after the callback instead of arming a stale retry from the value
778
+ // observed before diagnostics ran.
779
+ if (hasNewerPendingIntent()) {
780
+ // The current action did not commit. Resume the newer accepted intent that was captured
781
+ // while its callback was suspended, without scheduling a retry for the superseded one.
782
+ void process();
783
+ return false;
784
+ }
460
785
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
461
786
  return false;
462
787
  }
463
788
  if (applied === false) {
789
+ if (newerIntentArrivedDuringApplication) {
790
+ void process();
791
+ return false;
792
+ }
464
793
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
465
794
  return false;
466
795
  }
796
+ if (newerIntentArrivedDuringApplication) {
797
+ // Invocation of any host routing callback is the last safe commit boundary. A successful
798
+ // callback may already have navigated, so automatically applying the newly captured token
799
+ // now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
800
+ deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
801
+ }
467
802
  const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
468
803
  const newlyAppliedSet = new Set(newlyApplied);
469
804
  const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
470
- const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
805
+ const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
471
806
  state = {
472
807
  ...state,
473
808
  status: stillPending ? 'pending' : 'applied',
@@ -476,12 +811,16 @@ export function createPulseLinkClient(options) {
476
811
  appliedIds
477
812
  };
478
813
  persistAndNotify();
814
+ if (!lifecycleIsCurrent()) return false;
479
815
  if (link.matchBasis === 'account_bound') {
480
816
  accountAttempts = 0;
481
817
  accountRetryAt = 0;
482
818
  }
483
819
  emitOutcome(link, 'action_applied');
484
- if (stillPending) void process();
820
+ if (!lifecycleIsCurrent()) return false;
821
+ if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
822
+ void process();
823
+ }
485
824
  return true;
486
825
  };
487
826
  const resolvePublic = async (token, basis) => {
@@ -518,19 +857,26 @@ export function createPulseLinkClient(options) {
518
857
  };
519
858
  const processOnce = async () => {
520
859
  if (disposed) return;
860
+ // `capture()` can re-enter while any host callback is awaiting. Never start another routing
861
+ // application concurrently, and never auto-drain the exact token preserved after an already
862
+ // committed journey in this client lifecycle.
863
+ if (routingApplicationInFlight) return;
521
864
  const pending = state.pending;
522
865
  if (pending) {
866
+ if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
523
867
  if (pending.nextRetryAt > now()) return;
524
868
  if (now() - pending.receivedAt > tokenMaxAgeMs) {
525
869
  clearPendingAsTerminal();
526
870
  return;
527
871
  }
528
872
  const token = pending.token;
873
+ const pendingLifecycleGeneration = lifecycleGeneration;
529
874
  patchState({
530
875
  status: 'resolving'
531
876
  });
877
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
532
878
  const publicResult = await resolvePublic(token, pending.matchBasis);
533
- if (disposed || state.pending?.token !== token) return;
879
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
534
880
  if (publicResult.kind === 'resolved') {
535
881
  await applyResolved(publicResult.link, token);
536
882
  return;
@@ -539,15 +885,23 @@ export function createPulseLinkClient(options) {
539
885
  schedulePendingRetry();
540
886
  return;
541
887
  }
542
- if (!options.accountBridge || !isAccountReady()) {
888
+ if (!options.accountBridge) {
889
+ patchState({
890
+ status: 'waiting_for_account'
891
+ });
892
+ return;
893
+ }
894
+ const accountReadiness = readAccountReadiness();
895
+ if (!accountReadiness.current || disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
896
+ if (!accountReadiness.ready) {
543
897
  patchState({
544
898
  status: 'waiting_for_account'
545
899
  });
546
900
  return;
547
901
  }
548
902
  try {
549
- const raw = await options.accountBridge.claim(token);
550
- if (disposed || state.pending?.token !== token) return;
903
+ const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
904
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
551
905
  const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
552
906
  const claimed = normalizedClaim ? {
553
907
  ...normalizedClaim,
@@ -555,16 +909,29 @@ export function createPulseLinkClient(options) {
555
909
  matchGuaranteed: true,
556
910
  confidence: 1
557
911
  } : null;
558
- if (claimed) await applyResolved(claimed, token);else clearPendingAsTerminal();
912
+ if (claimed) {
913
+ if (!blockFirstOpenForDeterministic()) return;
914
+ await applyResolved(claimed, token);
915
+ } else clearPendingAsTerminal();
559
916
  } catch (error) {
917
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
560
918
  reportError(error);
561
- if (state.pending?.token === token) schedulePendingRetry();
919
+ if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
562
920
  }
563
921
  return;
564
922
  }
565
- if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
923
+ if (!options.accountBridge) return;
924
+ const accountReadiness = readAccountReadiness();
925
+ if (!accountReadiness.current || state.pending) return;
926
+ if (!accountReadiness.ready || accountRetryAt > now()) return;
927
+ const accountPendingEpoch = deterministicEpoch;
928
+ const accountPendingLifecycleGeneration = lifecycleGeneration;
566
929
  try {
567
- const raw = await options.accountBridge.pending();
930
+ const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
931
+ // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
932
+ // user intent. Ignore the stale bridge response and let the requested drain resolve the
933
+ // captured token; otherwise both deterministic destinations could be applied.
934
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
568
935
  if (!raw) {
569
936
  accountAttempts = 0;
570
937
  accountRetryAt = 0;
@@ -583,21 +950,31 @@ export function createPulseLinkClient(options) {
583
950
  matchGuaranteed: true,
584
951
  confidence: 1
585
952
  } : null;
586
- if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
587
- await applyResolved(pendingLink, null);
588
- } else if (pendingLink && state.status !== 'applied') {
589
- patchState({
590
- status: 'applied'
591
- });
953
+ if (pendingLink) {
954
+ // Reserve the first-open journey for deterministic recovery before invoking any host
955
+ // callback. This also covers an already-applied account item restored from older SDK
956
+ // state, which must still prevent a second probabilistic destination.
957
+ if (!blockFirstOpenForDeterministic()) return;
958
+ if (!state.appliedIds.includes(pendingLink.id)) {
959
+ await applyResolved(pendingLink, null);
960
+ } else if (state.status !== 'applied') {
961
+ patchState({
962
+ status: 'applied'
963
+ });
964
+ }
592
965
  }
593
966
  } catch (error) {
967
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
594
968
  reportError(error);
969
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
595
970
  // No token was consumed; the authenticated server outbox remains authoritative.
596
971
  scheduleAccountRetry();
597
972
  }
598
973
  };
599
974
  const process = async () => {
600
975
  if (disposed) return;
976
+ // Application delivery is independent: never await it on the routing drain.
977
+ void flushTerminalDelivery();
601
978
  processRequested = true;
602
979
  if (processing) return processing;
603
980
  const run = (async () => {
@@ -621,6 +998,7 @@ export function createPulseLinkClient(options) {
621
998
  const token = normalizeDeferredHandoffToken(rawToken);
622
999
  if (!token) return false;
623
1000
  if (state.appliedIds.includes(token)) {
1001
+ deterministicEpoch += 1;
624
1002
  if (!state.firstOpen.completed) patchFirstOpen({
625
1003
  completed: true,
626
1004
  nextRetryAt: 0
@@ -628,7 +1006,8 @@ export function createPulseLinkClient(options) {
628
1006
  return true;
629
1007
  }
630
1008
  if (state.pending?.token === token) {
631
- if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
1009
+ deterministicEpoch += 1;
1010
+ if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
632
1011
  state = {
633
1012
  ...state,
634
1013
  pending: {
@@ -651,6 +1030,20 @@ export function createPulseLinkClient(options) {
651
1030
  void process();
652
1031
  return true;
653
1032
  }
1033
+
1034
+ // Exact transports are deterministic, but when more than one arrives during cold start their
1035
+ // provenance still has an explicit precedence. A lower-priority late callback must not replace
1036
+ // the URL the user intentionally opened; equal priority keeps last-touch behaviour.
1037
+ if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
1038
+ deterministicEpoch += 1;
1039
+ if (!state.firstOpen.completed) patchFirstOpen({
1040
+ completed: true,
1041
+ nextRetryAt: 0
1042
+ });
1043
+ void process();
1044
+ return true;
1045
+ }
1046
+ deterministicEpoch += 1;
654
1047
  state = {
655
1048
  ...state,
656
1049
  status: 'pending',
@@ -671,14 +1064,41 @@ export function createPulseLinkClient(options) {
671
1064
  void process();
672
1065
  return true;
673
1066
  };
674
- const matchFirstOpen = async context => {
1067
+ const matchFirstOpenOnce = async context => {
675
1068
  if (disposed) return 'ineligible';
676
1069
  lastFirstOpenContext = context;
677
1070
  if (state.pending) return 'deterministic_pending';
678
1071
  if (state.firstOpen.completed) return 'already_completed';
679
- if (state.firstOpen.nextRetryAt > now()) return 'backoff';
1072
+ if (state.firstOpen.nextRetryAt > now()) {
1073
+ // On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
1074
+ // context, so the persisted first-open deadline could not be part of its candidates. Re-arm
1075
+ // now that retrying is possible; otherwise a 503 followed by process death remains asleep
1076
+ // until some unrelated foreground/manual call invokes matchFirstOpen again.
1077
+ scheduleWake();
1078
+ return 'backoff';
1079
+ }
1080
+ if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1081
+
1082
+ // The client starts account recovery at construction. Join that deterministic rail before
1083
+ // asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
1084
+ // after the probabilistic destination and the user observes both journeys.
1085
+ await process();
1086
+ if (disposed) return 'ineligible';
1087
+ if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1088
+ if (options.accountBridge) {
1089
+ const accountReadiness = readAccountReadiness();
1090
+ if (!accountReadiness.current) {
1091
+ return disposed ? 'ineligible' : 'deterministic_pending';
1092
+ }
1093
+ if (accountReadiness.ready && accountRetryAt > now()) {
1094
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1095
+ return 'retry_scheduled';
1096
+ }
1097
+ }
680
1098
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
681
1099
  const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
1100
+ const startingDeterministicEpoch = deterministicEpoch;
1101
+ const startingLifecycleGeneration = lifecycleGeneration;
682
1102
  state = {
683
1103
  ...state,
684
1104
  status: 'resolving',
@@ -688,14 +1108,44 @@ export function createPulseLinkClient(options) {
688
1108
  attemptedAt: now()
689
1109
  }
690
1110
  };
691
- persistAndNotify();
1111
+ const firstOpenReservationPersisted = persistAndNotify();
1112
+ // Persistence notifies host state hooks synchronously. They may reset/dispose this client or
1113
+ // capture a deterministic destination. In all three cases the reserved id no longer belongs to
1114
+ // the current lifecycle and must never cross the network boundary.
1115
+ if (disposed) return 'ineligible';
1116
+ if (lifecycleGeneration !== startingLifecycleGeneration || deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
1117
+ if (options.storage && !firstOpenReservationPersisted) {
1118
+ // An unpersisted id must never leave the process: after a crash the retry would mint a new
1119
+ // id and the server could count/route the same install twice. Keep the exact nonce in memory,
1120
+ // arm a persistence retry without incrementing matcher attempts, and send nothing until a
1121
+ // synchronous durable write succeeds. Memory-only clients have no restart contract and may
1122
+ // continue normally.
1123
+ state = {
1124
+ ...state,
1125
+ status: 'retryable_error',
1126
+ firstOpen: {
1127
+ ...state.firstOpen,
1128
+ nextRetryAt: now() + retryBaseMs
1129
+ }
1130
+ };
1131
+ reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
1132
+ notifyState();
1133
+ return 'retry_scheduled';
1134
+ }
1135
+ const accountEmail = normalizeAccountEmail(context.accountEmail);
692
1136
  const body = {
693
1137
  appBundleId: context.appBundleId.trim(),
694
1138
  platform: 'ios',
695
1139
  locale: normalizeLocale(context.locale),
696
1140
  firstOpen: true,
697
1141
  installAttemptId,
698
- ...normalizeAnonymousFirstOpenSignals(context)
1142
+ ...normalizeAnonymousFirstOpenSignals(context),
1143
+ // Sent only when the app already knows it. The resolver treats an address that matches
1144
+ // exactly one recent message as proof of origin; anything else falls back to the
1145
+ // probabilistic path, so a shared or unknown address costs nothing.
1146
+ ...(accountEmail ? {
1147
+ accountEmail
1148
+ } : {})
699
1149
  };
700
1150
  try {
701
1151
  const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
@@ -706,26 +1156,23 @@ export function createPulseLinkClient(options) {
706
1156
  },
707
1157
  body: JSON.stringify(body)
708
1158
  }, requestTimeoutMs);
709
- if (state.pending) return 'deterministic_pending';
710
- if (state.firstOpen.completed) return 'deterministic_pending';
1159
+ if (disposed) return 'ineligible';
1160
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
711
1161
  if (response.status === 204) {
712
- patchFirstOpen({
713
- completed: true,
714
- attempts: 0,
715
- nextRetryAt: 0
716
- });
717
- patchState({
718
- status: 'idle'
1162
+ completeFirstOpen('idle', {
1163
+ status: 'NOT_FOUND',
1164
+ rail: 'no_route',
1165
+ routed: false,
1166
+ reason: firstOpenNoRouteReason(response)
719
1167
  });
720
1168
  return 'no_match';
721
1169
  }
722
- if (response.status === 400 || response.status === 404 || response.status === 410) {
723
- patchFirstOpen({
724
- completed: true,
725
- nextRetryAt: 0
726
- });
727
- patchState({
728
- status: 'terminal_error'
1170
+ if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
1171
+ completeFirstOpen('terminal_error', {
1172
+ status: 'FAILURE',
1173
+ rail: 'no_route',
1174
+ routed: false,
1175
+ reason: 'invalid_request'
729
1176
  });
730
1177
  return 'terminal_error';
731
1178
  }
@@ -734,45 +1181,98 @@ export function createPulseLinkClient(options) {
734
1181
  scheduleFirstOpenRetry();
735
1182
  return 'retry_scheduled';
736
1183
  }
737
- patchFirstOpen({
738
- completed: true,
739
- nextRetryAt: 0
1184
+ completeFirstOpen('terminal_error', {
1185
+ status: 'FAILURE',
1186
+ rail: 'no_route',
1187
+ routed: false,
1188
+ reason: 'invalid_response'
740
1189
  });
741
- patchState({
742
- status: 'terminal_error'
1190
+ return 'terminal_error';
1191
+ }
1192
+ let rawLink;
1193
+ try {
1194
+ rawLink = await response.json();
1195
+ } catch {
1196
+ completeFirstOpen('terminal_error', {
1197
+ status: 'FAILURE',
1198
+ rail: 'no_route',
1199
+ routed: false,
1200
+ reason: 'invalid_response'
743
1201
  });
744
1202
  return 'terminal_error';
745
1203
  }
746
- const link = normalizeResolved(await response.json(), installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
747
- // The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
748
- // evidence to guaranteed or return a deterministic/account basis.
749
- if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.action && sensitiveActions.has(link.action)) {
750
- patchFirstOpen({
751
- completed: true,
752
- nextRetryAt: 0
1204
+ if (disposed) return 'ineligible';
1205
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
1206
+ const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
1207
+ if (!link) {
1208
+ completeFirstOpen('terminal_error', {
1209
+ status: 'FAILURE',
1210
+ rail: 'no_route',
1211
+ routed: false,
1212
+ reason: 'invalid_response'
753
1213
  });
754
- patchState({
755
- status: 'terminal_error'
1214
+ return 'terminal_error';
1215
+ }
1216
+ // A probabilistic result is navigation intent, never a remote URL transport. Only the two
1217
+ // harmless closed discovery actions are accepted; all billing/account/entitlement actions
1218
+ // and even an app-owned deepLink are rejected before application code runs.
1219
+ if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
1220
+ completeFirstOpen('terminal_error', {
1221
+ status: 'FAILURE',
1222
+ rail: 'no_route',
1223
+ routed: false,
1224
+ reason: 'policy_rejected'
756
1225
  });
757
1226
  return 'terminal_error';
758
1227
  }
1228
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
759
1229
  if (await applyResolved(link, null)) {
760
- patchFirstOpen({
761
- completed: true,
762
- attempts: 0,
763
- nextRetryAt: 0
1230
+ // `applyResolved()` returns true only after the host callback crossed its irreversible
1231
+ // commit boundary successfully. A deterministic capture can arrive while that callback
1232
+ // awaits; it remains pending for the next lifecycle, but it must not erase the terminal
1233
+ // truth that this probabilistic journey really routed.
1234
+ completeFirstOpen(state.pending ? 'pending' : 'applied', {
1235
+ status: 'FOUND',
1236
+ rail: 'fast_route',
1237
+ routed: true,
1238
+ reason: 'matched',
1239
+ matchBasis: link.matchBasis,
1240
+ confidence: link.confidence,
1241
+ ...(link.campaignId !== undefined ? {
1242
+ campaignId: link.campaignId
1243
+ } : {}),
1244
+ ...(link.experimentId !== undefined ? {
1245
+ experimentId: link.experimentId
1246
+ } : {}),
1247
+ ...(link.variantId !== undefined ? {
1248
+ variantId: link.variantId
1249
+ } : {})
764
1250
  });
765
1251
  return 'matched';
766
1252
  }
1253
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
767
1254
  if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
768
1255
  return 'retry_scheduled';
769
1256
  } catch (error) {
1257
+ if (disposed) return 'ineligible';
770
1258
  reportError(error);
1259
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
771
1260
  scheduleFirstOpenRetry();
772
1261
  return 'retry_scheduled';
773
1262
  }
774
1263
  };
1264
+ const matchFirstOpen = context => {
1265
+ if (firstOpenMatching) return firstOpenMatching;
1266
+ let owned;
1267
+ owned = matchFirstOpenOnce(context).finally(() => {
1268
+ if (firstOpenMatching === owned) firstOpenMatching = null;
1269
+ });
1270
+ firstOpenMatching = owned;
1271
+ return owned;
1272
+ };
775
1273
  const captureAndroidInstallReferrer = async bridge => {
1274
+ const referrerLifecycleGeneration = lifecycleGeneration;
1275
+ const referrerDeterministicEpoch = deterministicEpoch;
776
1276
  let raw;
777
1277
  try {
778
1278
  raw = await bridge.getDeferredHandoff();
@@ -785,6 +1285,7 @@ export function createPulseLinkClient(options) {
785
1285
  };
786
1286
  }
787
1287
  const result = normalizeAndroidInstallReferrerResult(raw);
1288
+ if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
788
1289
  if (result.status === 'OK' && result.token) {
789
1290
  capture(result.token, 'android_install_referrer');
790
1291
  }
@@ -796,13 +1297,14 @@ export function createPulseLinkClient(options) {
796
1297
  retryTimer = null;
797
1298
  }
798
1299
  if (disposed || options.autoRetry === false) return;
799
- const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0].filter(value => value > now());
1300
+ const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0, state.terminalDelivery?.nextRetryAt ?? 0].filter(value => value > now());
800
1301
  if (candidates.length === 0) return;
801
1302
  const next = Math.min(...candidates);
802
1303
  retryTimer = setTimeout(() => {
803
1304
  retryTimer = null;
804
1305
  void process();
805
1306
  void flushResolverOutcomes();
1307
+ void flushTerminalDelivery();
806
1308
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
807
1309
  void matchFirstOpen(lastFirstOpenContext);
808
1310
  }
@@ -822,6 +1324,7 @@ export function createPulseLinkClient(options) {
822
1324
  scheduleWake();
823
1325
  void process();
824
1326
  void flushResolverOutcomes();
1327
+ void flushTerminalDelivery();
825
1328
  return {
826
1329
  capture,
827
1330
  captureUrl: url => {
@@ -839,6 +1342,9 @@ export function createPulseLinkClient(options) {
839
1342
  dispose: () => {
840
1343
  if (disposed) return;
841
1344
  disposed = true;
1345
+ lifecycleGeneration += 1;
1346
+ deterministicEpoch += 1;
1347
+ terminalDeliveryGeneration += 1;
842
1348
  if (retryTimer) clearTimeout(retryTimer);
843
1349
  retryTimer = null;
844
1350
  unsubscribeAccount?.();
@@ -848,6 +1354,11 @@ export function createPulseLinkClient(options) {
848
1354
  reset: () => {
849
1355
  accountRetryAt = 0;
850
1356
  accountAttempts = 0;
1357
+ lifecycleGeneration += 1;
1358
+ deterministicEpoch += 1;
1359
+ terminalDeliveryGeneration += 1;
1360
+ terminalDeliverySending = null;
1361
+ deterministicTokenDeferredAfterCommittedJourney = null;
851
1362
  state = emptyState();
852
1363
  persistAndNotify();
853
1364
  }
@@ -891,11 +1402,11 @@ function stableOutcomeKey(linkId, name) {
891
1402
  }
892
1403
  return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
893
1404
  }
894
- function boundedMetadata(value) {
1405
+ function boundedMetadata(value, maxLength = 256) {
895
1406
  if (value === null) return null;
896
1407
  if (typeof value !== 'string' && typeof value !== 'number') return undefined;
897
1408
  const result = String(value).trim();
898
- return result.length > 0 && result.length <= 256 ? result : undefined;
1409
+ return result.length > 0 && result.length <= maxLength ? result : undefined;
899
1410
  }
900
1411
  function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
901
1412
  if (!raw || typeof raw !== 'object') return null;
@@ -917,9 +1428,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
917
1428
  if (!Number.isFinite(expiry) || expiry <= now) return null;
918
1429
  }
919
1430
  const source = boundedMetadata(value.source);
920
- const campaignId = boundedMetadata(value.campaignId);
921
- const experimentId = boundedMetadata(value.experimentId);
922
- const variantId = boundedMetadata(value.variantId);
1431
+ const campaignId = boundedMetadata(value.campaignId, 128);
1432
+ const experimentId = boundedMetadata(value.experimentId, 128);
1433
+ const variantId = boundedMetadata(value.variantId, 128);
923
1434
  return {
924
1435
  id,
925
1436
  ...(action ? {
@@ -956,6 +1467,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
956
1467
  function normalizeLocale(locale) {
957
1468
  return locale.trim().replace(/_/g, '-');
958
1469
  }
1470
+ function firstOpenNoRouteReason(response) {
1471
+ let raw = '';
1472
+ try {
1473
+ raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
1474
+ } catch {
1475
+ raw = '';
1476
+ }
1477
+ return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
1478
+ }
959
1479
  function normalizeAnonymousFirstOpenSignals(context) {
960
1480
  const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
961
1481
  const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
@@ -1035,6 +1555,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
1035
1555
  const jitter = 0.5 + hash / 0xffffffff * 0.5;
1036
1556
  return Math.max(1, Math.floor(ceiling * jitter));
1037
1557
  }
1558
+
1559
+ /**
1560
+ * An address is only useful to the resolver if it is the same shape the send was recorded with.
1561
+ * Anything that is not plausibly an address is dropped rather than sent: a malformed value can
1562
+ * only ever fail to match, and not sending it keeps the payload free of stray user input.
1563
+ */
1564
+ export function normalizeAccountEmail(raw) {
1565
+ const value = (raw ?? '').trim().toLowerCase();
1566
+ if (value.length < 3 || value.length > 255) return null;
1567
+ const at = value.indexOf('@');
1568
+ if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
1569
+ if (/\s/.test(value)) return null;
1570
+ return value;
1571
+ }
1038
1572
  async function withTimeout(fetcher, input, init, timeoutMs) {
1039
1573
  const controller = new AbortController();
1040
1574
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1047,6 +1581,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
1047
1581
  clearTimeout(timer);
1048
1582
  }
1049
1583
  }
1584
+ async function withPromiseTimeout(factory, timeoutMs, operation) {
1585
+ let timer = null;
1586
+ try {
1587
+ return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
1588
+ timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
1589
+ })]);
1590
+ } finally {
1591
+ if (timer) clearTimeout(timer);
1592
+ }
1593
+ }
1594
+ async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
1595
+ let timer = null;
1596
+ try {
1597
+ return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
1598
+ timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
1599
+ })]);
1600
+ } finally {
1601
+ if (timer) clearTimeout(timer);
1602
+ }
1603
+ }
1604
+ function normalizeTerminalDeliveryDisposition(value) {
1605
+ if (value === true || value === 'accepted') return 'accepted';
1606
+ if (value === 'drop') return 'drop';
1607
+ return 'retry';
1608
+ }
1050
1609
  async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
1051
1610
  return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
1052
1611
  method: 'POST',
@@ -1113,6 +1672,56 @@ function normalizeOccurredAt(value) {
1113
1672
  if (!Number.isFinite(timestamp)) return null;
1114
1673
  return new Date(timestamp).toISOString() === value ? value : null;
1115
1674
  }
1675
+ function readTerminalDelivery(raw) {
1676
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1677
+ const value = raw;
1678
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
1679
+ const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
1680
+ const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
1681
+ const reason = typeof value.reason === 'string' ? value.reason : null;
1682
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
1683
+ const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
1684
+ const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
1685
+ if (value.disposition === 'drop') {
1686
+ const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
1687
+ if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
1688
+ return {
1689
+ eventId,
1690
+ disposition: 'drop',
1691
+ attempts,
1692
+ nextRetryAt
1693
+ };
1694
+ }
1695
+ const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
1696
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
1697
+ const metadata = {};
1698
+ let metadataValid = true;
1699
+ for (const key of ['campaignId', 'experimentId', 'variantId']) {
1700
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
1701
+ const field = value[key];
1702
+ if (field === null) metadata[key] = null;else if (typeof field === 'string' && field.length > 0 && field.length <= 128 && field.trim() === field) metadata[key] = field;else metadataValid = false;
1703
+ }
1704
+ const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
1705
+ const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis') || Object.prototype.hasOwnProperty.call(value, 'confidence') || Object.prototype.hasOwnProperty.call(value, 'campaignId') || Object.prototype.hasOwnProperty.call(value, 'experimentId') || Object.prototype.hasOwnProperty.call(value, 'variantId');
1706
+ const validSemanticOutcome = status === 'FOUND' ? rail === 'fast_route' && value.routed === true && reason === 'matched' && hasFoundDimensions && matchBasis === 'unique_probabilistic' && confidence !== null && metadataValid : status === 'NOT_FOUND' ? rail === 'no_route' && value.routed === false && reason !== null && NO_ROUTE_REASONS.has(reason) && !hasAnyAttributionDimension : status === 'FAILURE' ? rail === 'no_route' && value.routed === false && reason !== null && FAILURE_REASONS.has(reason) && !hasAnyAttributionDimension : false;
1707
+ if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
1708
+ return {
1709
+ eventId,
1710
+ status,
1711
+ rail,
1712
+ routed: value.routed,
1713
+ retryable: false,
1714
+ reason,
1715
+ occurredAt,
1716
+ ...(status === 'FOUND' ? {
1717
+ matchBasis: matchBasis,
1718
+ confidence: confidence,
1719
+ ...metadata
1720
+ } : {}),
1721
+ attempts,
1722
+ nextRetryAt
1723
+ };
1724
+ }
1116
1725
  function readState(storage, key) {
1117
1726
  if (!storage) return emptyState();
1118
1727
  try {
@@ -1139,6 +1748,7 @@ function readState(storage, key) {
1139
1748
  const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
1140
1749
  const notifiedOutcomes = Array.isArray(parsed.notifiedOutcomes) ? parsed.notifiedOutcomes.filter(value => typeof value === 'string' && /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value)).slice(-MAX_NOTIFIED_OUTCOMES) : [];
1141
1750
  const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
1751
+ const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
1142
1752
  return {
1143
1753
  version: 1,
1144
1754
  status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
@@ -1147,6 +1757,7 @@ function readState(storage, key) {
1147
1757
  appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
1148
1758
  notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
1149
1759
  outcomeQueue,
1760
+ terminalDelivery,
1150
1761
  firstOpen: {
1151
1762
  installAttemptId: attemptId,
1152
1763
  completed: firstValue.completed === true,
@@ -1159,11 +1770,24 @@ function readState(storage, key) {
1159
1770
  return emptyState();
1160
1771
  }
1161
1772
  }
1162
- function writeState(storage, key, state) {
1163
- if (!storage) return;
1773
+ function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
1774
+ if (!storage) return true;
1775
+ const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
1776
+ if (knownAsync && !allowKnownAsyncBestEffort) return false;
1164
1777
  try {
1165
- void storage.set(key, JSON.stringify(state));
1166
- } catch {/* in-memory operation continues */}
1778
+ const result = storage.set(key, JSON.stringify(state));
1779
+ if (isThenable(result)) {
1780
+ ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
1781
+ void Promise.resolve(result).catch(() => undefined);
1782
+ return false;
1783
+ }
1784
+ return !knownAsync;
1785
+ } catch {
1786
+ return false;
1787
+ }
1788
+ }
1789
+ function isThenable(value) {
1790
+ return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
1167
1791
  }
1168
1792
  function safeTimestamp(value) {
1169
1793
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;