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,7 @@ exports.createDeferredLinkClient = void 0;
7
7
  exports.createPulseLinkClient = createPulseLinkClient;
8
8
  exports.extractDeferredHandoffToken = extractDeferredHandoffToken;
9
9
  exports.isOpaqueDeferredLinkToken = isOpaqueDeferredLinkToken;
10
+ exports.normalizeAccountEmail = normalizeAccountEmail;
10
11
  exports.normalizeAndroidInstallReferrerResult = normalizeAndroidInstallReferrerResult;
11
12
  exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
12
13
  /**
@@ -18,6 +19,14 @@ exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
18
19
  * optional account bridge below.
19
20
  */
20
21
 
22
+ /** Explicit receiver decision for one terminal-delivery attempt. */
23
+
24
+ /**
25
+ * Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
26
+ * install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
27
+ * can never make the client navigate, including when Encore reports a shadow observation.
28
+ */
29
+
21
30
  /** Implemented by the separate native `PulseAttribution` module. */
22
31
 
23
32
  const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
@@ -34,6 +43,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
34
43
  const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
35
44
  const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
36
45
  const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
46
+ const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
47
+ const CAPTURE_PRIORITY = {
48
+ direct_token: 3,
49
+ ios_user_paste: 2,
50
+ android_install_referrer: 1
51
+ };
52
+ const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
53
+ const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
37
54
  const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
38
55
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
39
56
  const DEFAULT_RETRY_BASE_MS = 15_000;
@@ -44,10 +61,14 @@ const MAX_APPLIED_IDS = 32;
44
61
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
45
62
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
46
63
  const MAX_PERSISTED_BYTES = 131_072;
64
+ const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
47
65
  const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
48
66
  const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
49
67
  const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
50
68
  const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
69
+
70
+ /** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
71
+
51
72
  const emptyState = () => ({
52
73
  version: 1,
53
74
  status: 'idle',
@@ -56,6 +77,7 @@ const emptyState = () => ({
56
77
  appliedIds: [],
57
78
  notifiedOutcomes: [],
58
79
  outcomeQueue: [],
80
+ terminalDelivery: null,
59
81
  firstOpen: {
60
82
  installAttemptId: null,
61
83
  completed: false,
@@ -156,6 +178,7 @@ function createPulseLinkClient(options) {
156
178
  }
157
179
  const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
158
180
  const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
181
+ const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
159
182
  const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
160
183
  const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
161
184
  const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
@@ -164,11 +187,26 @@ function createPulseLinkClient(options) {
164
187
  let state = readState(options.storage, storageKey);
165
188
  let disposed = false;
166
189
  let processing = null;
190
+ let firstOpenMatching = null;
167
191
  let processRequested = false;
168
192
  let retryTimer = null;
169
193
  let outcomeSending = null;
194
+ let terminalDeliverySending = null;
195
+ let terminalDeliveryGeneration = 0;
170
196
  let accountRetryAt = 0;
171
197
  let accountAttempts = 0;
198
+ let deterministicEpoch = 0;
199
+ // Invalidates every host callback that crossed an await when this client is reset/disposed.
200
+ // This is deliberately separate from deterministicEpoch: captures during a committed callback
201
+ // have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
202
+ // state write, retry and outcome from the old instance.
203
+ let lifecycleGeneration = 0;
204
+ // Host navigation is irreversible once any routing callback has started: it may perform its
205
+ // side effect synchronously before returning a Promise. Serialize captures against that commit
206
+ // boundary and keep the newer accepted token durable for the next client lifecycle instead of
207
+ // automatically opening a second journey behind the first one.
208
+ let routingApplicationInFlight = false;
209
+ let deterministicTokenDeferredAfterCommittedJourney = null;
172
210
  let lastFirstOpenContext = null;
173
211
  let unsubscribeAccount = null;
174
212
  const listeners = new Set();
@@ -177,6 +215,25 @@ function createPulseLinkClient(options) {
177
215
  options.onError?.(error);
178
216
  } catch {/* diagnostic hooks never break link handling */}
179
217
  };
218
+
219
+ // A build without a receiver has not opted into retaining this analytics envelope. First replace
220
+ // an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
221
+ // may fail, but a later app version can then only retry local deletion, never resurrect delivery.
222
+ if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
223
+ const tombstone = {
224
+ eventId: state.terminalDelivery.eventId,
225
+ disposition: 'drop',
226
+ attempts: state.terminalDelivery.attempts,
227
+ nextRetryAt: 0
228
+ };
229
+ state = {
230
+ ...state,
231
+ terminalDelivery: tombstone
232
+ };
233
+ if (!writeState(options.storage, storageKey, state)) {
234
+ reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
235
+ }
236
+ }
180
237
  const snapshot = () => ({
181
238
  status: state.status,
182
239
  pending: state.pending ? {
@@ -188,8 +245,7 @@ function createPulseLinkClient(options) {
188
245
  ...state.firstOpen
189
246
  }
190
247
  });
191
- const persistAndNotify = () => {
192
- writeState(options.storage, storageKey, state);
248
+ const notifyState = () => {
193
249
  const value = snapshot();
194
250
  for (const listener of listeners) {
195
251
  try {
@@ -205,6 +261,11 @@ function createPulseLinkClient(options) {
205
261
  }
206
262
  scheduleWake();
207
263
  };
264
+ const persistAndNotify = () => {
265
+ const persisted = writeState(options.storage, storageKey, state);
266
+ notifyState();
267
+ return persisted;
268
+ };
208
269
  const patchState = patch => {
209
270
  state = {
210
271
  ...state,
@@ -222,13 +283,229 @@ function createPulseLinkClient(options) {
222
283
  };
223
284
  persistAndNotify();
224
285
  };
225
- const isAccountReady = () => {
286
+ const terminalDeliveryOutcome = queued => ({
287
+ eventId: queued.eventId,
288
+ status: queued.status,
289
+ rail: queued.rail,
290
+ routed: queued.routed,
291
+ retryable: false,
292
+ reason: queued.reason,
293
+ occurredAt: queued.occurredAt,
294
+ ...(queued.matchBasis !== undefined ? {
295
+ matchBasis: queued.matchBasis
296
+ } : {}),
297
+ ...(queued.confidence !== undefined ? {
298
+ confidence: queued.confidence
299
+ } : {}),
300
+ ...(queued.campaignId !== undefined ? {
301
+ campaignId: queued.campaignId
302
+ } : {}),
303
+ ...(queued.experimentId !== undefined ? {
304
+ experimentId: queued.experimentId
305
+ } : {}),
306
+ ...(queued.variantId !== undefined ? {
307
+ variantId: queued.variantId
308
+ } : {})
309
+ });
310
+ const scheduleTerminalDeliveryRetry = queued => {
311
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
312
+ const attempts = Math.min(queued.attempts + 1, 100_000);
313
+ state = {
314
+ ...state,
315
+ terminalDelivery: {
316
+ ...queued,
317
+ attempts,
318
+ nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
319
+ }
320
+ };
321
+ persistAndNotify();
322
+ };
323
+ const acknowledgeTerminalDelivery = queued => {
324
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
325
+ const acknowledgedState = {
326
+ ...state,
327
+ terminalDelivery: null
328
+ };
329
+ // Clearing the record is itself transactional. If this write fails after the receiver
330
+ // accepted the event, retain and replay the same eventId: that is why the contract is
331
+ // at-least-once and why receivers must deduplicate.
332
+ if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
333
+ reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
334
+ scheduleTerminalDeliveryRetry(queued);
335
+ return false;
336
+ }
337
+ state = acknowledgedState;
338
+ notifyState();
339
+ return true;
340
+ };
341
+ const flushTerminalDelivery = () => {
342
+ if (disposed) return Promise.resolve(false);
343
+ if (terminalDeliverySending) return terminalDeliverySending;
344
+ const queued = state.terminalDelivery;
345
+ if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
346
+ if (queued.disposition === 'drop') {
347
+ return Promise.resolve(acknowledgeTerminalDelivery(queued));
348
+ }
349
+ if (!options.onFirstOpenResult) return Promise.resolve(false);
350
+ const generation = terminalDeliveryGeneration;
351
+ const run = async () => {
352
+ // Never call the receiver before the complete record is durable. With no storage adapter,
353
+ // this is an explicitly memory-only degradation rather than a cross-restart guarantee.
354
+ if (!writeState(options.storage, storageKey, state)) {
355
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
356
+ scheduleTerminalDeliveryRetry(queued);
357
+ return false;
358
+ }
359
+ let rawDisposition;
360
+ try {
361
+ rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
362
+ } catch (error) {
363
+ reportError(error);
364
+ scheduleTerminalDeliveryRetry(queued);
365
+ return false;
366
+ }
367
+ let disposition;
368
+ if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
369
+ disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
370
+ } else {
371
+ try {
372
+ const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
373
+ disposition = normalizeTerminalDeliveryDisposition(resolved);
374
+ } catch (error) {
375
+ if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
376
+ if (!disposed && terminalDeliveryGeneration === generation) {
377
+ scheduleTerminalDeliveryRetry(queued);
378
+ }
379
+ return false;
380
+ }
381
+ }
382
+ if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
383
+ if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
384
+ if (disposition === 'drop') return dropTerminalDelivery(queued);
385
+ scheduleTerminalDeliveryRetry(queued);
386
+ return false;
387
+ };
388
+ let owned;
389
+ owned = run().finally(() => {
390
+ if (terminalDeliverySending === owned) terminalDeliverySending = null;
391
+ const pending = state.terminalDelivery;
392
+ if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
393
+ void flushTerminalDelivery();
394
+ }
395
+ });
396
+ terminalDeliverySending = owned;
397
+ return owned;
398
+ };
399
+ const dropTerminalDelivery = queued => {
400
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
401
+ const tombstone = {
402
+ eventId: queued.eventId,
403
+ disposition: 'drop',
404
+ attempts: queued.attempts,
405
+ nextRetryAt: 0
406
+ };
407
+ state = {
408
+ ...state,
409
+ terminalDelivery: tombstone
410
+ };
411
+ // Persist the sticky disposition before trying to delete it. A failed local deletion can then
412
+ // retry only deletion after restart; it must never call the receiver or resurrect collection.
413
+ if (!writeState(options.storage, storageKey, state)) {
414
+ reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
415
+ scheduleTerminalDeliveryRetry(tombstone);
416
+ return false;
417
+ }
418
+ notifyState();
419
+ return acknowledgeTerminalDelivery(tombstone);
420
+ };
421
+ const completeFirstOpen = (status, outcome) => {
422
+ const completionLifecycleGeneration = lifecycleGeneration;
423
+ const completionDeterministicEpoch = deterministicEpoch;
424
+ const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
425
+ let shouldQueue = Boolean(options.onFirstOpenResult);
426
+ if (shouldQueue && options.shouldQueueFirstOpenResult) {
427
+ try {
428
+ shouldQueue = options.shouldQueueFirstOpenResult() === true;
429
+ } catch (error) {
430
+ shouldQueue = false;
431
+ reportError(error);
432
+ }
433
+ // Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
434
+ // deterministic destination. Never resurrect the terminal first-open state they invalidated.
435
+ if (!completionIsCurrent()) return;
436
+ }
437
+ if (!shouldQueue) {
438
+ state = {
439
+ ...state,
440
+ status,
441
+ terminalDelivery: null,
442
+ firstOpen: {
443
+ ...state.firstOpen,
444
+ completed: true,
445
+ attempts: 0,
446
+ nextRetryAt: 0
447
+ }
448
+ };
449
+ persistAndNotify();
450
+ return;
451
+ }
452
+ const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
453
+ const delivery = state.terminalDelivery ?? {
454
+ ...outcome,
455
+ eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
456
+ retryable: false,
457
+ occurredAt: new Date(now()).toISOString(),
458
+ attempts: 0,
459
+ nextRetryAt: 0
460
+ };
461
+ state = {
462
+ ...state,
463
+ status,
464
+ terminalDelivery: delivery,
465
+ firstOpen: {
466
+ ...state.firstOpen,
467
+ completed: true,
468
+ attempts: 0,
469
+ nextRetryAt: 0
470
+ }
471
+ };
472
+ if (!persistAndNotify()) {
473
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
474
+ }
475
+ // Delivery is deliberately detached from routing/matcher completion.
476
+ void flushTerminalDelivery();
477
+ };
478
+ const blockFirstOpenForDeterministic = () => {
479
+ const blockingLifecycleGeneration = lifecycleGeneration;
480
+ deterministicEpoch += 1;
481
+ const blockingDeterministicEpoch = deterministicEpoch;
482
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
483
+ state = {
484
+ ...state,
485
+ firstOpen: {
486
+ ...state.firstOpen,
487
+ completed: true,
488
+ nextRetryAt: 0
489
+ }
490
+ };
491
+ persistAndNotify();
492
+ return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
493
+ };
494
+ const readAccountReadiness = () => {
495
+ const readinessLifecycleGeneration = lifecycleGeneration;
496
+ const readinessDeterministicEpoch = deterministicEpoch;
497
+ let ready = false;
226
498
  try {
227
- return options.isAccountReady?.() ?? false;
499
+ ready = options.isAccountReady?.() ?? false;
228
500
  } catch (error) {
229
501
  reportError(error);
230
- return false;
231
502
  }
503
+ return {
504
+ ready,
505
+ // Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
506
+ // the caller's snapshot and must be observed before any waiting/retry/application write.
507
+ current: !disposed && lifecycleGeneration === readinessLifecycleGeneration && deterministicEpoch === readinessDeterministicEpoch
508
+ };
232
509
  };
233
510
  const schedulePendingRetry = () => {
234
511
  const pending = state.pending;
@@ -436,7 +713,9 @@ function createPulseLinkClient(options) {
436
713
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
437
714
  return false;
438
715
  }
439
- if (!isAccountReady()) {
716
+ const accountReadiness = readAccountReadiness();
717
+ if (!accountReadiness.current) return false;
718
+ if (!accountReadiness.ready) {
440
719
  patchState({
441
720
  status: 'waiting_for_account'
442
721
  });
@@ -454,9 +733,34 @@ function createPulseLinkClient(options) {
454
733
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
455
734
  return false;
456
735
  }
736
+ let applied = false;
737
+ let applicationFailed = false;
738
+ let applicationError;
739
+ const applicationEpoch = deterministicEpoch;
740
+ const applicationLifecycleGeneration = lifecycleGeneration;
741
+ const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
742
+ const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
743
+ // A provenance upgrade or rejected lower-priority capture for the token already being
744
+ // applied does not represent another destination and must not leave that token pending.
745
+ && (sourceToken === null || state.pending.token !== sourceToken);
746
+ routingApplicationInFlight = true;
457
747
  emitOutcome(link, 'app_open_confirmed');
748
+ if (!lifecycleIsCurrent()) {
749
+ routingApplicationInFlight = false;
750
+ return false;
751
+ }
458
752
  emitOutcome(link, 'deferred_link_resolved');
459
- let applied;
753
+ if (!lifecycleIsCurrent()) {
754
+ routingApplicationInFlight = false;
755
+ return false;
756
+ }
757
+ // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
758
+ // a newer accepted destination, it still arrived before the routing callback and must win.
759
+ if (hasNewerPendingIntent()) {
760
+ routingApplicationInFlight = false;
761
+ void process();
762
+ return false;
763
+ }
460
764
  try {
461
765
  if (link.deepLink) {
462
766
  applied = await options.onDeepLink(link.deepLink, link);
@@ -467,18 +771,50 @@ function createPulseLinkClient(options) {
467
771
  return false;
468
772
  }
469
773
  } catch (error) {
470
- reportError(error);
774
+ applicationFailed = true;
775
+ applicationError = error;
776
+ } finally {
777
+ routingApplicationInFlight = false;
778
+ }
779
+
780
+ // The callback may resolve after reset/dispose and after another client has already committed
781
+ // a newer journey into the same storage. The old instance must not schedule a retry, rewrite
782
+ // state, or emit action_applied regardless of whether the callback returned true/false/threw.
783
+ if (!lifecycleIsCurrent()) return false;
784
+ const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
785
+ if (applicationFailed) {
786
+ reportError(applicationError);
787
+ if (!lifecycleIsCurrent()) return false;
788
+ // onError is host code too: it may capture a newer deterministic destination while reporting
789
+ // this failure. Re-evaluate after the callback instead of arming a stale retry from the value
790
+ // observed before diagnostics ran.
791
+ if (hasNewerPendingIntent()) {
792
+ // The current action did not commit. Resume the newer accepted intent that was captured
793
+ // while its callback was suspended, without scheduling a retry for the superseded one.
794
+ void process();
795
+ return false;
796
+ }
471
797
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
472
798
  return false;
473
799
  }
474
800
  if (applied === false) {
801
+ if (newerIntentArrivedDuringApplication) {
802
+ void process();
803
+ return false;
804
+ }
475
805
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
476
806
  return false;
477
807
  }
808
+ if (newerIntentArrivedDuringApplication) {
809
+ // Invocation of any host routing callback is the last safe commit boundary. A successful
810
+ // callback may already have navigated, so automatically applying the newly captured token
811
+ // now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
812
+ deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
813
+ }
478
814
  const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
479
815
  const newlyAppliedSet = new Set(newlyApplied);
480
816
  const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
481
- const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
817
+ const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
482
818
  state = {
483
819
  ...state,
484
820
  status: stillPending ? 'pending' : 'applied',
@@ -487,12 +823,16 @@ function createPulseLinkClient(options) {
487
823
  appliedIds
488
824
  };
489
825
  persistAndNotify();
826
+ if (!lifecycleIsCurrent()) return false;
490
827
  if (link.matchBasis === 'account_bound') {
491
828
  accountAttempts = 0;
492
829
  accountRetryAt = 0;
493
830
  }
494
831
  emitOutcome(link, 'action_applied');
495
- if (stillPending) void process();
832
+ if (!lifecycleIsCurrent()) return false;
833
+ if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
834
+ void process();
835
+ }
496
836
  return true;
497
837
  };
498
838
  const resolvePublic = async (token, basis) => {
@@ -529,19 +869,26 @@ function createPulseLinkClient(options) {
529
869
  };
530
870
  const processOnce = async () => {
531
871
  if (disposed) return;
872
+ // `capture()` can re-enter while any host callback is awaiting. Never start another routing
873
+ // application concurrently, and never auto-drain the exact token preserved after an already
874
+ // committed journey in this client lifecycle.
875
+ if (routingApplicationInFlight) return;
532
876
  const pending = state.pending;
533
877
  if (pending) {
878
+ if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
534
879
  if (pending.nextRetryAt > now()) return;
535
880
  if (now() - pending.receivedAt > tokenMaxAgeMs) {
536
881
  clearPendingAsTerminal();
537
882
  return;
538
883
  }
539
884
  const token = pending.token;
885
+ const pendingLifecycleGeneration = lifecycleGeneration;
540
886
  patchState({
541
887
  status: 'resolving'
542
888
  });
889
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
543
890
  const publicResult = await resolvePublic(token, pending.matchBasis);
544
- if (disposed || state.pending?.token !== token) return;
891
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
545
892
  if (publicResult.kind === 'resolved') {
546
893
  await applyResolved(publicResult.link, token);
547
894
  return;
@@ -550,15 +897,23 @@ function createPulseLinkClient(options) {
550
897
  schedulePendingRetry();
551
898
  return;
552
899
  }
553
- if (!options.accountBridge || !isAccountReady()) {
900
+ if (!options.accountBridge) {
901
+ patchState({
902
+ status: 'waiting_for_account'
903
+ });
904
+ return;
905
+ }
906
+ const accountReadiness = readAccountReadiness();
907
+ if (!accountReadiness.current || disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
908
+ if (!accountReadiness.ready) {
554
909
  patchState({
555
910
  status: 'waiting_for_account'
556
911
  });
557
912
  return;
558
913
  }
559
914
  try {
560
- const raw = await options.accountBridge.claim(token);
561
- if (disposed || state.pending?.token !== token) return;
915
+ const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
916
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
562
917
  const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
563
918
  const claimed = normalizedClaim ? {
564
919
  ...normalizedClaim,
@@ -566,16 +921,29 @@ function createPulseLinkClient(options) {
566
921
  matchGuaranteed: true,
567
922
  confidence: 1
568
923
  } : null;
569
- if (claimed) await applyResolved(claimed, token);else clearPendingAsTerminal();
924
+ if (claimed) {
925
+ if (!blockFirstOpenForDeterministic()) return;
926
+ await applyResolved(claimed, token);
927
+ } else clearPendingAsTerminal();
570
928
  } catch (error) {
929
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
571
930
  reportError(error);
572
- if (state.pending?.token === token) schedulePendingRetry();
931
+ if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
573
932
  }
574
933
  return;
575
934
  }
576
- if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
935
+ if (!options.accountBridge) return;
936
+ const accountReadiness = readAccountReadiness();
937
+ if (!accountReadiness.current || state.pending) return;
938
+ if (!accountReadiness.ready || accountRetryAt > now()) return;
939
+ const accountPendingEpoch = deterministicEpoch;
940
+ const accountPendingLifecycleGeneration = lifecycleGeneration;
577
941
  try {
578
- const raw = await options.accountBridge.pending();
942
+ const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
943
+ // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
944
+ // user intent. Ignore the stale bridge response and let the requested drain resolve the
945
+ // captured token; otherwise both deterministic destinations could be applied.
946
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
579
947
  if (!raw) {
580
948
  accountAttempts = 0;
581
949
  accountRetryAt = 0;
@@ -594,21 +962,31 @@ function createPulseLinkClient(options) {
594
962
  matchGuaranteed: true,
595
963
  confidence: 1
596
964
  } : null;
597
- if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
598
- await applyResolved(pendingLink, null);
599
- } else if (pendingLink && state.status !== 'applied') {
600
- patchState({
601
- status: 'applied'
602
- });
965
+ if (pendingLink) {
966
+ // Reserve the first-open journey for deterministic recovery before invoking any host
967
+ // callback. This also covers an already-applied account item restored from older SDK
968
+ // state, which must still prevent a second probabilistic destination.
969
+ if (!blockFirstOpenForDeterministic()) return;
970
+ if (!state.appliedIds.includes(pendingLink.id)) {
971
+ await applyResolved(pendingLink, null);
972
+ } else if (state.status !== 'applied') {
973
+ patchState({
974
+ status: 'applied'
975
+ });
976
+ }
603
977
  }
604
978
  } catch (error) {
979
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
605
980
  reportError(error);
981
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
606
982
  // No token was consumed; the authenticated server outbox remains authoritative.
607
983
  scheduleAccountRetry();
608
984
  }
609
985
  };
610
986
  const process = async () => {
611
987
  if (disposed) return;
988
+ // Application delivery is independent: never await it on the routing drain.
989
+ void flushTerminalDelivery();
612
990
  processRequested = true;
613
991
  if (processing) return processing;
614
992
  const run = (async () => {
@@ -632,6 +1010,7 @@ function createPulseLinkClient(options) {
632
1010
  const token = normalizeDeferredHandoffToken(rawToken);
633
1011
  if (!token) return false;
634
1012
  if (state.appliedIds.includes(token)) {
1013
+ deterministicEpoch += 1;
635
1014
  if (!state.firstOpen.completed) patchFirstOpen({
636
1015
  completed: true,
637
1016
  nextRetryAt: 0
@@ -639,7 +1018,8 @@ function createPulseLinkClient(options) {
639
1018
  return true;
640
1019
  }
641
1020
  if (state.pending?.token === token) {
642
- if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
1021
+ deterministicEpoch += 1;
1022
+ if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
643
1023
  state = {
644
1024
  ...state,
645
1025
  pending: {
@@ -662,6 +1042,20 @@ function createPulseLinkClient(options) {
662
1042
  void process();
663
1043
  return true;
664
1044
  }
1045
+
1046
+ // Exact transports are deterministic, but when more than one arrives during cold start their
1047
+ // provenance still has an explicit precedence. A lower-priority late callback must not replace
1048
+ // the URL the user intentionally opened; equal priority keeps last-touch behaviour.
1049
+ if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
1050
+ deterministicEpoch += 1;
1051
+ if (!state.firstOpen.completed) patchFirstOpen({
1052
+ completed: true,
1053
+ nextRetryAt: 0
1054
+ });
1055
+ void process();
1056
+ return true;
1057
+ }
1058
+ deterministicEpoch += 1;
665
1059
  state = {
666
1060
  ...state,
667
1061
  status: 'pending',
@@ -682,14 +1076,41 @@ function createPulseLinkClient(options) {
682
1076
  void process();
683
1077
  return true;
684
1078
  };
685
- const matchFirstOpen = async context => {
1079
+ const matchFirstOpenOnce = async context => {
686
1080
  if (disposed) return 'ineligible';
687
1081
  lastFirstOpenContext = context;
688
1082
  if (state.pending) return 'deterministic_pending';
689
1083
  if (state.firstOpen.completed) return 'already_completed';
690
- if (state.firstOpen.nextRetryAt > now()) return 'backoff';
1084
+ if (state.firstOpen.nextRetryAt > now()) {
1085
+ // On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
1086
+ // context, so the persisted first-open deadline could not be part of its candidates. Re-arm
1087
+ // now that retrying is possible; otherwise a 503 followed by process death remains asleep
1088
+ // until some unrelated foreground/manual call invokes matchFirstOpen again.
1089
+ scheduleWake();
1090
+ return 'backoff';
1091
+ }
1092
+ if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1093
+
1094
+ // The client starts account recovery at construction. Join that deterministic rail before
1095
+ // asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
1096
+ // after the probabilistic destination and the user observes both journeys.
1097
+ await process();
1098
+ if (disposed) return 'ineligible';
1099
+ if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1100
+ if (options.accountBridge) {
1101
+ const accountReadiness = readAccountReadiness();
1102
+ if (!accountReadiness.current) {
1103
+ return disposed ? 'ineligible' : 'deterministic_pending';
1104
+ }
1105
+ if (accountReadiness.ready && accountRetryAt > now()) {
1106
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1107
+ return 'retry_scheduled';
1108
+ }
1109
+ }
691
1110
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
692
1111
  const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
1112
+ const startingDeterministicEpoch = deterministicEpoch;
1113
+ const startingLifecycleGeneration = lifecycleGeneration;
693
1114
  state = {
694
1115
  ...state,
695
1116
  status: 'resolving',
@@ -699,14 +1120,44 @@ function createPulseLinkClient(options) {
699
1120
  attemptedAt: now()
700
1121
  }
701
1122
  };
702
- persistAndNotify();
1123
+ const firstOpenReservationPersisted = persistAndNotify();
1124
+ // Persistence notifies host state hooks synchronously. They may reset/dispose this client or
1125
+ // capture a deterministic destination. In all three cases the reserved id no longer belongs to
1126
+ // the current lifecycle and must never cross the network boundary.
1127
+ if (disposed) return 'ineligible';
1128
+ if (lifecycleGeneration !== startingLifecycleGeneration || deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
1129
+ if (options.storage && !firstOpenReservationPersisted) {
1130
+ // An unpersisted id must never leave the process: after a crash the retry would mint a new
1131
+ // id and the server could count/route the same install twice. Keep the exact nonce in memory,
1132
+ // arm a persistence retry without incrementing matcher attempts, and send nothing until a
1133
+ // synchronous durable write succeeds. Memory-only clients have no restart contract and may
1134
+ // continue normally.
1135
+ state = {
1136
+ ...state,
1137
+ status: 'retryable_error',
1138
+ firstOpen: {
1139
+ ...state.firstOpen,
1140
+ nextRetryAt: now() + retryBaseMs
1141
+ }
1142
+ };
1143
+ reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
1144
+ notifyState();
1145
+ return 'retry_scheduled';
1146
+ }
1147
+ const accountEmail = normalizeAccountEmail(context.accountEmail);
703
1148
  const body = {
704
1149
  appBundleId: context.appBundleId.trim(),
705
1150
  platform: 'ios',
706
1151
  locale: normalizeLocale(context.locale),
707
1152
  firstOpen: true,
708
1153
  installAttemptId,
709
- ...normalizeAnonymousFirstOpenSignals(context)
1154
+ ...normalizeAnonymousFirstOpenSignals(context),
1155
+ // Sent only when the app already knows it. The resolver treats an address that matches
1156
+ // exactly one recent message as proof of origin; anything else falls back to the
1157
+ // probabilistic path, so a shared or unknown address costs nothing.
1158
+ ...(accountEmail ? {
1159
+ accountEmail
1160
+ } : {})
710
1161
  };
711
1162
  try {
712
1163
  const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
@@ -717,26 +1168,23 @@ function createPulseLinkClient(options) {
717
1168
  },
718
1169
  body: JSON.stringify(body)
719
1170
  }, requestTimeoutMs);
720
- if (state.pending) return 'deterministic_pending';
721
- if (state.firstOpen.completed) return 'deterministic_pending';
1171
+ if (disposed) return 'ineligible';
1172
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
722
1173
  if (response.status === 204) {
723
- patchFirstOpen({
724
- completed: true,
725
- attempts: 0,
726
- nextRetryAt: 0
727
- });
728
- patchState({
729
- status: 'idle'
1174
+ completeFirstOpen('idle', {
1175
+ status: 'NOT_FOUND',
1176
+ rail: 'no_route',
1177
+ routed: false,
1178
+ reason: firstOpenNoRouteReason(response)
730
1179
  });
731
1180
  return 'no_match';
732
1181
  }
733
- if (response.status === 400 || response.status === 404 || response.status === 410) {
734
- patchFirstOpen({
735
- completed: true,
736
- nextRetryAt: 0
737
- });
738
- patchState({
739
- status: 'terminal_error'
1182
+ if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
1183
+ completeFirstOpen('terminal_error', {
1184
+ status: 'FAILURE',
1185
+ rail: 'no_route',
1186
+ routed: false,
1187
+ reason: 'invalid_request'
740
1188
  });
741
1189
  return 'terminal_error';
742
1190
  }
@@ -745,45 +1193,98 @@ function createPulseLinkClient(options) {
745
1193
  scheduleFirstOpenRetry();
746
1194
  return 'retry_scheduled';
747
1195
  }
748
- patchFirstOpen({
749
- completed: true,
750
- nextRetryAt: 0
1196
+ completeFirstOpen('terminal_error', {
1197
+ status: 'FAILURE',
1198
+ rail: 'no_route',
1199
+ routed: false,
1200
+ reason: 'invalid_response'
751
1201
  });
752
- patchState({
753
- status: 'terminal_error'
1202
+ return 'terminal_error';
1203
+ }
1204
+ let rawLink;
1205
+ try {
1206
+ rawLink = await response.json();
1207
+ } catch {
1208
+ completeFirstOpen('terminal_error', {
1209
+ status: 'FAILURE',
1210
+ rail: 'no_route',
1211
+ routed: false,
1212
+ reason: 'invalid_response'
754
1213
  });
755
1214
  return 'terminal_error';
756
1215
  }
757
- const link = normalizeResolved(await response.json(), installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
758
- // The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
759
- // evidence to guaranteed or return a deterministic/account basis.
760
- if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.action && sensitiveActions.has(link.action)) {
761
- patchFirstOpen({
762
- completed: true,
763
- nextRetryAt: 0
1216
+ if (disposed) return 'ineligible';
1217
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
1218
+ const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
1219
+ if (!link) {
1220
+ completeFirstOpen('terminal_error', {
1221
+ status: 'FAILURE',
1222
+ rail: 'no_route',
1223
+ routed: false,
1224
+ reason: 'invalid_response'
764
1225
  });
765
- patchState({
766
- status: 'terminal_error'
1226
+ return 'terminal_error';
1227
+ }
1228
+ // A probabilistic result is navigation intent, never a remote URL transport. Only the two
1229
+ // harmless closed discovery actions are accepted; all billing/account/entitlement actions
1230
+ // and even an app-owned deepLink are rejected before application code runs.
1231
+ if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
1232
+ completeFirstOpen('terminal_error', {
1233
+ status: 'FAILURE',
1234
+ rail: 'no_route',
1235
+ routed: false,
1236
+ reason: 'policy_rejected'
767
1237
  });
768
1238
  return 'terminal_error';
769
1239
  }
1240
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
770
1241
  if (await applyResolved(link, null)) {
771
- patchFirstOpen({
772
- completed: true,
773
- attempts: 0,
774
- nextRetryAt: 0
1242
+ // `applyResolved()` returns true only after the host callback crossed its irreversible
1243
+ // commit boundary successfully. A deterministic capture can arrive while that callback
1244
+ // awaits; it remains pending for the next lifecycle, but it must not erase the terminal
1245
+ // truth that this probabilistic journey really routed.
1246
+ completeFirstOpen(state.pending ? 'pending' : 'applied', {
1247
+ status: 'FOUND',
1248
+ rail: 'fast_route',
1249
+ routed: true,
1250
+ reason: 'matched',
1251
+ matchBasis: link.matchBasis,
1252
+ confidence: link.confidence,
1253
+ ...(link.campaignId !== undefined ? {
1254
+ campaignId: link.campaignId
1255
+ } : {}),
1256
+ ...(link.experimentId !== undefined ? {
1257
+ experimentId: link.experimentId
1258
+ } : {}),
1259
+ ...(link.variantId !== undefined ? {
1260
+ variantId: link.variantId
1261
+ } : {})
775
1262
  });
776
1263
  return 'matched';
777
1264
  }
1265
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
778
1266
  if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
779
1267
  return 'retry_scheduled';
780
1268
  } catch (error) {
1269
+ if (disposed) return 'ineligible';
781
1270
  reportError(error);
1271
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
782
1272
  scheduleFirstOpenRetry();
783
1273
  return 'retry_scheduled';
784
1274
  }
785
1275
  };
1276
+ const matchFirstOpen = context => {
1277
+ if (firstOpenMatching) return firstOpenMatching;
1278
+ let owned;
1279
+ owned = matchFirstOpenOnce(context).finally(() => {
1280
+ if (firstOpenMatching === owned) firstOpenMatching = null;
1281
+ });
1282
+ firstOpenMatching = owned;
1283
+ return owned;
1284
+ };
786
1285
  const captureAndroidInstallReferrer = async bridge => {
1286
+ const referrerLifecycleGeneration = lifecycleGeneration;
1287
+ const referrerDeterministicEpoch = deterministicEpoch;
787
1288
  let raw;
788
1289
  try {
789
1290
  raw = await bridge.getDeferredHandoff();
@@ -796,6 +1297,7 @@ function createPulseLinkClient(options) {
796
1297
  };
797
1298
  }
798
1299
  const result = normalizeAndroidInstallReferrerResult(raw);
1300
+ if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
799
1301
  if (result.status === 'OK' && result.token) {
800
1302
  capture(result.token, 'android_install_referrer');
801
1303
  }
@@ -807,13 +1309,14 @@ function createPulseLinkClient(options) {
807
1309
  retryTimer = null;
808
1310
  }
809
1311
  if (disposed || options.autoRetry === false) return;
810
- const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0].filter(value => value > now());
1312
+ 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());
811
1313
  if (candidates.length === 0) return;
812
1314
  const next = Math.min(...candidates);
813
1315
  retryTimer = setTimeout(() => {
814
1316
  retryTimer = null;
815
1317
  void process();
816
1318
  void flushResolverOutcomes();
1319
+ void flushTerminalDelivery();
817
1320
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
818
1321
  void matchFirstOpen(lastFirstOpenContext);
819
1322
  }
@@ -833,6 +1336,7 @@ function createPulseLinkClient(options) {
833
1336
  scheduleWake();
834
1337
  void process();
835
1338
  void flushResolverOutcomes();
1339
+ void flushTerminalDelivery();
836
1340
  return {
837
1341
  capture,
838
1342
  captureUrl: url => {
@@ -850,6 +1354,9 @@ function createPulseLinkClient(options) {
850
1354
  dispose: () => {
851
1355
  if (disposed) return;
852
1356
  disposed = true;
1357
+ lifecycleGeneration += 1;
1358
+ deterministicEpoch += 1;
1359
+ terminalDeliveryGeneration += 1;
853
1360
  if (retryTimer) clearTimeout(retryTimer);
854
1361
  retryTimer = null;
855
1362
  unsubscribeAccount?.();
@@ -859,6 +1366,11 @@ function createPulseLinkClient(options) {
859
1366
  reset: () => {
860
1367
  accountRetryAt = 0;
861
1368
  accountAttempts = 0;
1369
+ lifecycleGeneration += 1;
1370
+ deterministicEpoch += 1;
1371
+ terminalDeliveryGeneration += 1;
1372
+ terminalDeliverySending = null;
1373
+ deterministicTokenDeferredAfterCommittedJourney = null;
862
1374
  state = emptyState();
863
1375
  persistAndNotify();
864
1376
  }
@@ -902,11 +1414,11 @@ function stableOutcomeKey(linkId, name) {
902
1414
  }
903
1415
  return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
904
1416
  }
905
- function boundedMetadata(value) {
1417
+ function boundedMetadata(value, maxLength = 256) {
906
1418
  if (value === null) return null;
907
1419
  if (typeof value !== 'string' && typeof value !== 'number') return undefined;
908
1420
  const result = String(value).trim();
909
- return result.length > 0 && result.length <= 256 ? result : undefined;
1421
+ return result.length > 0 && result.length <= maxLength ? result : undefined;
910
1422
  }
911
1423
  function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
912
1424
  if (!raw || typeof raw !== 'object') return null;
@@ -928,9 +1440,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
928
1440
  if (!Number.isFinite(expiry) || expiry <= now) return null;
929
1441
  }
930
1442
  const source = boundedMetadata(value.source);
931
- const campaignId = boundedMetadata(value.campaignId);
932
- const experimentId = boundedMetadata(value.experimentId);
933
- const variantId = boundedMetadata(value.variantId);
1443
+ const campaignId = boundedMetadata(value.campaignId, 128);
1444
+ const experimentId = boundedMetadata(value.experimentId, 128);
1445
+ const variantId = boundedMetadata(value.variantId, 128);
934
1446
  return {
935
1447
  id,
936
1448
  ...(action ? {
@@ -967,6 +1479,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
967
1479
  function normalizeLocale(locale) {
968
1480
  return locale.trim().replace(/_/g, '-');
969
1481
  }
1482
+ function firstOpenNoRouteReason(response) {
1483
+ let raw = '';
1484
+ try {
1485
+ raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
1486
+ } catch {
1487
+ raw = '';
1488
+ }
1489
+ return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
1490
+ }
970
1491
  function normalizeAnonymousFirstOpenSignals(context) {
971
1492
  const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
972
1493
  const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
@@ -1046,6 +1567,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
1046
1567
  const jitter = 0.5 + hash / 0xffffffff * 0.5;
1047
1568
  return Math.max(1, Math.floor(ceiling * jitter));
1048
1569
  }
1570
+
1571
+ /**
1572
+ * An address is only useful to the resolver if it is the same shape the send was recorded with.
1573
+ * Anything that is not plausibly an address is dropped rather than sent: a malformed value can
1574
+ * only ever fail to match, and not sending it keeps the payload free of stray user input.
1575
+ */
1576
+ function normalizeAccountEmail(raw) {
1577
+ const value = (raw ?? '').trim().toLowerCase();
1578
+ if (value.length < 3 || value.length > 255) return null;
1579
+ const at = value.indexOf('@');
1580
+ if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
1581
+ if (/\s/.test(value)) return null;
1582
+ return value;
1583
+ }
1049
1584
  async function withTimeout(fetcher, input, init, timeoutMs) {
1050
1585
  const controller = new AbortController();
1051
1586
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1058,6 +1593,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
1058
1593
  clearTimeout(timer);
1059
1594
  }
1060
1595
  }
1596
+ async function withPromiseTimeout(factory, timeoutMs, operation) {
1597
+ let timer = null;
1598
+ try {
1599
+ return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
1600
+ timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
1601
+ })]);
1602
+ } finally {
1603
+ if (timer) clearTimeout(timer);
1604
+ }
1605
+ }
1606
+ async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
1607
+ let timer = null;
1608
+ try {
1609
+ return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
1610
+ timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
1611
+ })]);
1612
+ } finally {
1613
+ if (timer) clearTimeout(timer);
1614
+ }
1615
+ }
1616
+ function normalizeTerminalDeliveryDisposition(value) {
1617
+ if (value === true || value === 'accepted') return 'accepted';
1618
+ if (value === 'drop') return 'drop';
1619
+ return 'retry';
1620
+ }
1061
1621
  async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
1062
1622
  return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
1063
1623
  method: 'POST',
@@ -1124,6 +1684,56 @@ function normalizeOccurredAt(value) {
1124
1684
  if (!Number.isFinite(timestamp)) return null;
1125
1685
  return new Date(timestamp).toISOString() === value ? value : null;
1126
1686
  }
1687
+ function readTerminalDelivery(raw) {
1688
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1689
+ const value = raw;
1690
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
1691
+ const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
1692
+ const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
1693
+ const reason = typeof value.reason === 'string' ? value.reason : null;
1694
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
1695
+ const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
1696
+ const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
1697
+ if (value.disposition === 'drop') {
1698
+ const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
1699
+ if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
1700
+ return {
1701
+ eventId,
1702
+ disposition: 'drop',
1703
+ attempts,
1704
+ nextRetryAt
1705
+ };
1706
+ }
1707
+ const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
1708
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
1709
+ const metadata = {};
1710
+ let metadataValid = true;
1711
+ for (const key of ['campaignId', 'experimentId', 'variantId']) {
1712
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
1713
+ const field = value[key];
1714
+ 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;
1715
+ }
1716
+ const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
1717
+ 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');
1718
+ 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;
1719
+ if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
1720
+ return {
1721
+ eventId,
1722
+ status,
1723
+ rail,
1724
+ routed: value.routed,
1725
+ retryable: false,
1726
+ reason,
1727
+ occurredAt,
1728
+ ...(status === 'FOUND' ? {
1729
+ matchBasis: matchBasis,
1730
+ confidence: confidence,
1731
+ ...metadata
1732
+ } : {}),
1733
+ attempts,
1734
+ nextRetryAt
1735
+ };
1736
+ }
1127
1737
  function readState(storage, key) {
1128
1738
  if (!storage) return emptyState();
1129
1739
  try {
@@ -1150,6 +1760,7 @@ function readState(storage, key) {
1150
1760
  const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
1151
1761
  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) : [];
1152
1762
  const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
1763
+ const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
1153
1764
  return {
1154
1765
  version: 1,
1155
1766
  status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
@@ -1158,6 +1769,7 @@ function readState(storage, key) {
1158
1769
  appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
1159
1770
  notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
1160
1771
  outcomeQueue,
1772
+ terminalDelivery,
1161
1773
  firstOpen: {
1162
1774
  installAttemptId: attemptId,
1163
1775
  completed: firstValue.completed === true,
@@ -1170,11 +1782,24 @@ function readState(storage, key) {
1170
1782
  return emptyState();
1171
1783
  }
1172
1784
  }
1173
- function writeState(storage, key, state) {
1174
- if (!storage) return;
1785
+ function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
1786
+ if (!storage) return true;
1787
+ const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
1788
+ if (knownAsync && !allowKnownAsyncBestEffort) return false;
1175
1789
  try {
1176
- void storage.set(key, JSON.stringify(state));
1177
- } catch {/* in-memory operation continues */}
1790
+ const result = storage.set(key, JSON.stringify(state));
1791
+ if (isThenable(result)) {
1792
+ ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
1793
+ void Promise.resolve(result).catch(() => undefined);
1794
+ return false;
1795
+ }
1796
+ return !knownAsync;
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1801
+ function isThenable(value) {
1802
+ return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
1178
1803
  }
1179
1804
  function safeTimestamp(value) {
1180
1805
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;