pulse-updates 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,7 +138,11 @@ On a recent iOS first open, the host may call `links.matchFirstOpen` with its na
138
138
  bundle id and locale. It may also provide bounded ephemeral release/model dimensions (app/OS
139
139
  version, hardware model code, device type, distribution, timezone offset and emulator flag). Pulse
140
140
  validates and omits malformed values. The request intentionally contains no unique device id,
141
- IDFV/IDFA, account, clipboard value or client-observed IP. Ambiguous matches produce no action.
141
+ IDFV/IDFA, clipboard value or client-observed IP. It carries the signed-in address only when the
142
+ integrator sets `sendAccountEmailOnFirstOpen` and the host passes one: an install that names its
143
+ account is bound to the message sent there instead of being inferred, which is the only
144
+ deterministic signal left where no store referrer survives. The switch is off by default, so
145
+ reading your own auth state never starts forwarding addresses by itself. Ambiguous matches produce no action.
142
146
  Sensitive actions are rejected for every probabilistic result even if a malformed server response
143
147
  claims otherwise. A random install-attempt nonce may accompany later deterministic resolver
144
148
  outcomes so the server can calibrate its earlier probabilistic decision against deterministic
@@ -157,6 +157,29 @@ class PulseController private constructor() {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Which binary this is — the versionCode, as a string.
162
+ *
163
+ * The runtime version is versionName, and several versionCodes ship under one. Where bundles
164
+ * are wrapped for integrity that means several keys behind one runtime version, and a bundle
165
+ * wrapped for another build cannot be opened: the app does not degrade, it stops starting. This
166
+ * is what lets the server keep those bundles apart. Null when the package cannot be read, in
167
+ * which case the header is omitted and the server serves as it always did.
168
+ */
169
+ internal val nativeBuildNumber: String?
170
+ get() = try {
171
+ val ctx = context
172
+ if (ctx == null) null else {
173
+ val info = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
174
+ @Suppress("DEPRECATION")
175
+ val code = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P)
176
+ info.longVersionCode else info.versionCode.toLong()
177
+ code.toString()
178
+ }
179
+ } catch (e: Exception) {
180
+ null
181
+ }
182
+
160
183
  /**
161
184
  * Load config from AndroidManifest.xml metadata (like expo-updates)
162
185
  */
@@ -1339,6 +1362,12 @@ object PulseRemoteLoader {
1339
1362
  // Stable device id so the server can bucket this device into staged rollouts (without it,
1340
1363
  // a device is held back from any partial rollout until it reaches 100%).
1341
1364
  connection.setRequestProperty("Pulse-Device-Id", PulseController.getInstance().deviceId)
1365
+ // WHICH binary is asking. The runtime version is versionName, and several versionCodes
1366
+ // can ship under one — so where bundles are wrapped for integrity, one runtime version
1367
+ // can mean several keys, and a bundle wrapped for another build cannot be opened at all.
1368
+ // versionCode is the number that always moves, so the server can keep a bundle prepared
1369
+ // for one build away from the others. Servers that do not know the header ignore it.
1370
+ PulseController.getInstance().nativeBuildNumber?.let { connection.setRequestProperty("Pulse-Native-Build", it) }
1342
1371
 
1343
1372
  pulseLog(TAG, "checkForUpdate: url=${config.updateUrl}")
1344
1373
  pulseLog(TAG, "checkForUpdate: runtimeVersion=${config.runtimeVersion}")
@@ -1364,6 +1364,16 @@ final class PulseRemoteLoader {
1364
1364
  // Stable device id so the server can bucket this device into staged rollouts (without it,
1365
1365
  // a device is held back from any partial rollout until it reaches 100%).
1366
1366
  request.setValue(PulseController.shared.deviceId, forHTTPHeaderField: "Pulse-Device-Id")
1367
+ // WHICH binary is asking. The runtime version does not answer that on iOS: it is
1368
+ // MARKETING_VERSION, which stays put across re-submissions, so one runtime version can be
1369
+ // several builds. Where bundles are wrapped for integrity each build holds its own key, and
1370
+ // a bundle wrapped for another one cannot be opened — the app does not degrade, it stops
1371
+ // starting. CFBundleVersion is the number that does move, so the server can keep a bundle
1372
+ // prepared for one build away from the others. A server that does not know the header
1373
+ // ignores it, and a release not tied to a build is still served to everyone.
1374
+ if let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String, !build.isEmpty {
1375
+ request.setValue(build, forHTTPHeaderField: "Pulse-Native-Build")
1376
+ }
1367
1377
 
1368
1378
  if let channel = config.channel {
1369
1379
  request.setValue(channel, forHTTPHeaderField: "X-Pulse-Channel-Name")
@@ -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
  /**
@@ -195,6 +196,11 @@ function createPulseLinkClient(options) {
195
196
  let accountRetryAt = 0;
196
197
  let accountAttempts = 0;
197
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;
198
204
  // Host navigation is irreversible once any routing callback has started: it may perform its
199
205
  // side effect synchronously before returning a Promise. Serialize captures against that commit
200
206
  // boundary and keep the newer accepted token durable for the next client lifecycle instead of
@@ -413,6 +419,9 @@ function createPulseLinkClient(options) {
413
419
  return acknowledgeTerminalDelivery(tombstone);
414
420
  };
415
421
  const completeFirstOpen = (status, outcome) => {
422
+ const completionLifecycleGeneration = lifecycleGeneration;
423
+ const completionDeterministicEpoch = deterministicEpoch;
424
+ const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
416
425
  let shouldQueue = Boolean(options.onFirstOpenResult);
417
426
  if (shouldQueue && options.shouldQueueFirstOpenResult) {
418
427
  try {
@@ -421,6 +430,9 @@ function createPulseLinkClient(options) {
421
430
  shouldQueue = false;
422
431
  reportError(error);
423
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;
424
436
  }
425
437
  if (!shouldQueue) {
426
438
  state = {
@@ -464,8 +476,10 @@ function createPulseLinkClient(options) {
464
476
  void flushTerminalDelivery();
465
477
  };
466
478
  const blockFirstOpenForDeterministic = () => {
479
+ const blockingLifecycleGeneration = lifecycleGeneration;
467
480
  deterministicEpoch += 1;
468
- if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
481
+ const blockingDeterministicEpoch = deterministicEpoch;
482
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
469
483
  state = {
470
484
  ...state,
471
485
  firstOpen: {
@@ -475,14 +489,23 @@ function createPulseLinkClient(options) {
475
489
  }
476
490
  };
477
491
  persistAndNotify();
492
+ return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
478
493
  };
479
- const isAccountReady = () => {
494
+ const readAccountReadiness = () => {
495
+ const readinessLifecycleGeneration = lifecycleGeneration;
496
+ const readinessDeterministicEpoch = deterministicEpoch;
497
+ let ready = false;
480
498
  try {
481
- return options.isAccountReady?.() ?? false;
499
+ ready = options.isAccountReady?.() ?? false;
482
500
  } catch (error) {
483
501
  reportError(error);
484
- return false;
485
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
+ };
486
509
  };
487
510
  const schedulePendingRetry = () => {
488
511
  const pending = state.pending;
@@ -690,7 +713,9 @@ function createPulseLinkClient(options) {
690
713
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
691
714
  return false;
692
715
  }
693
- if (!isAccountReady()) {
716
+ const accountReadiness = readAccountReadiness();
717
+ if (!accountReadiness.current) return false;
718
+ if (!accountReadiness.ready) {
694
719
  patchState({
695
720
  status: 'waiting_for_account'
696
721
  });
@@ -712,13 +737,23 @@ function createPulseLinkClient(options) {
712
737
  let applicationFailed = false;
713
738
  let applicationError;
714
739
  const applicationEpoch = deterministicEpoch;
740
+ const applicationLifecycleGeneration = lifecycleGeneration;
741
+ const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
715
742
  const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
716
743
  // A provenance upgrade or rejected lower-priority capture for the token already being
717
744
  // applied does not represent another destination and must not leave that token pending.
718
745
  && (sourceToken === null || state.pending.token !== sourceToken);
719
746
  routingApplicationInFlight = true;
720
747
  emitOutcome(link, 'app_open_confirmed');
748
+ if (!lifecycleIsCurrent()) {
749
+ routingApplicationInFlight = false;
750
+ return false;
751
+ }
721
752
  emitOutcome(link, 'deferred_link_resolved');
753
+ if (!lifecycleIsCurrent()) {
754
+ routingApplicationInFlight = false;
755
+ return false;
756
+ }
722
757
  // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
723
758
  // a newer accepted destination, it still arrived before the routing callback and must win.
724
759
  if (hasNewerPendingIntent()) {
@@ -741,10 +776,19 @@ function createPulseLinkClient(options) {
741
776
  } finally {
742
777
  routingApplicationInFlight = false;
743
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;
744
784
  const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
745
785
  if (applicationFailed) {
746
786
  reportError(applicationError);
747
- if (newerIntentArrivedDuringApplication) {
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()) {
748
792
  // The current action did not commit. Resume the newer accepted intent that was captured
749
793
  // while its callback was suspended, without scheduling a retry for the superseded one.
750
794
  void process();
@@ -779,11 +823,13 @@ function createPulseLinkClient(options) {
779
823
  appliedIds
780
824
  };
781
825
  persistAndNotify();
826
+ if (!lifecycleIsCurrent()) return false;
782
827
  if (link.matchBasis === 'account_bound') {
783
828
  accountAttempts = 0;
784
829
  accountRetryAt = 0;
785
830
  }
786
831
  emitOutcome(link, 'action_applied');
832
+ if (!lifecycleIsCurrent()) return false;
787
833
  if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
788
834
  void process();
789
835
  }
@@ -836,11 +882,13 @@ function createPulseLinkClient(options) {
836
882
  return;
837
883
  }
838
884
  const token = pending.token;
885
+ const pendingLifecycleGeneration = lifecycleGeneration;
839
886
  patchState({
840
887
  status: 'resolving'
841
888
  });
889
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
842
890
  const publicResult = await resolvePublic(token, pending.matchBasis);
843
- if (disposed || state.pending?.token !== token) return;
891
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
844
892
  if (publicResult.kind === 'resolved') {
845
893
  await applyResolved(publicResult.link, token);
846
894
  return;
@@ -849,7 +897,15 @@ function createPulseLinkClient(options) {
849
897
  schedulePendingRetry();
850
898
  return;
851
899
  }
852
- 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) {
853
909
  patchState({
854
910
  status: 'waiting_for_account'
855
911
  });
@@ -857,7 +913,7 @@ function createPulseLinkClient(options) {
857
913
  }
858
914
  try {
859
915
  const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
860
- if (disposed || state.pending?.token !== token) return;
916
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
861
917
  const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
862
918
  const claimed = normalizedClaim ? {
863
919
  ...normalizedClaim,
@@ -866,23 +922,28 @@ function createPulseLinkClient(options) {
866
922
  confidence: 1
867
923
  } : null;
868
924
  if (claimed) {
869
- blockFirstOpenForDeterministic();
925
+ if (!blockFirstOpenForDeterministic()) return;
870
926
  await applyResolved(claimed, token);
871
927
  } else clearPendingAsTerminal();
872
928
  } catch (error) {
929
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
873
930
  reportError(error);
874
- if (state.pending?.token === token) schedulePendingRetry();
931
+ if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
875
932
  }
876
933
  return;
877
934
  }
878
- 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;
879
939
  const accountPendingEpoch = deterministicEpoch;
940
+ const accountPendingLifecycleGeneration = lifecycleGeneration;
880
941
  try {
881
942
  const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
882
943
  // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
883
944
  // user intent. Ignore the stale bridge response and let the requested drain resolve the
884
945
  // captured token; otherwise both deterministic destinations could be applied.
885
- if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
946
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
886
947
  if (!raw) {
887
948
  accountAttempts = 0;
888
949
  accountRetryAt = 0;
@@ -905,7 +966,7 @@ function createPulseLinkClient(options) {
905
966
  // Reserve the first-open journey for deterministic recovery before invoking any host
906
967
  // callback. This also covers an already-applied account item restored from older SDK
907
968
  // state, which must still prevent a second probabilistic destination.
908
- blockFirstOpenForDeterministic();
969
+ if (!blockFirstOpenForDeterministic()) return;
909
970
  if (!state.appliedIds.includes(pendingLink.id)) {
910
971
  await applyResolved(pendingLink, null);
911
972
  } else if (state.status !== 'applied') {
@@ -915,8 +976,9 @@ function createPulseLinkClient(options) {
915
976
  }
916
977
  }
917
978
  } catch (error) {
918
- if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
979
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
919
980
  reportError(error);
981
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
920
982
  // No token was consumed; the authenticated server outbox remains authoritative.
921
983
  scheduleAccountRetry();
922
984
  }
@@ -1019,7 +1081,14 @@ function createPulseLinkClient(options) {
1019
1081
  lastFirstOpenContext = context;
1020
1082
  if (state.pending) return 'deterministic_pending';
1021
1083
  if (state.firstOpen.completed) return 'already_completed';
1022
- 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
+ }
1023
1092
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1024
1093
 
1025
1094
  // The client starts account recovery at construction. Join that deterministic rail before
@@ -1028,13 +1097,20 @@ function createPulseLinkClient(options) {
1028
1097
  await process();
1029
1098
  if (disposed) return 'ineligible';
1030
1099
  if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1031
- if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
1032
- if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1033
- return 'retry_scheduled';
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
+ }
1034
1109
  }
1035
1110
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1036
1111
  const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
1037
1112
  const startingDeterministicEpoch = deterministicEpoch;
1113
+ const startingLifecycleGeneration = lifecycleGeneration;
1038
1114
  state = {
1039
1115
  ...state,
1040
1116
  status: 'resolving',
@@ -1044,14 +1120,44 @@ function createPulseLinkClient(options) {
1044
1120
  attemptedAt: now()
1045
1121
  }
1046
1122
  };
1047
- 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 = options.sendAccountEmailOnFirstOpen === true ? normalizeAccountEmail(context.accountEmail) : null;
1048
1148
  const body = {
1049
1149
  appBundleId: context.appBundleId.trim(),
1050
1150
  platform: 'ios',
1051
1151
  locale: normalizeLocale(context.locale),
1052
1152
  firstOpen: true,
1053
1153
  installAttemptId,
1054
- ...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
+ } : {})
1055
1161
  };
1056
1162
  try {
1057
1163
  const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
@@ -1177,6 +1283,8 @@ function createPulseLinkClient(options) {
1177
1283
  return owned;
1178
1284
  };
1179
1285
  const captureAndroidInstallReferrer = async bridge => {
1286
+ const referrerLifecycleGeneration = lifecycleGeneration;
1287
+ const referrerDeterministicEpoch = deterministicEpoch;
1180
1288
  let raw;
1181
1289
  try {
1182
1290
  raw = await bridge.getDeferredHandoff();
@@ -1189,6 +1297,7 @@ function createPulseLinkClient(options) {
1189
1297
  };
1190
1298
  }
1191
1299
  const result = normalizeAndroidInstallReferrerResult(raw);
1300
+ if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
1192
1301
  if (result.status === 'OK' && result.token) {
1193
1302
  capture(result.token, 'android_install_referrer');
1194
1303
  }
@@ -1245,6 +1354,7 @@ function createPulseLinkClient(options) {
1245
1354
  dispose: () => {
1246
1355
  if (disposed) return;
1247
1356
  disposed = true;
1357
+ lifecycleGeneration += 1;
1248
1358
  deterministicEpoch += 1;
1249
1359
  terminalDeliveryGeneration += 1;
1250
1360
  if (retryTimer) clearTimeout(retryTimer);
@@ -1256,6 +1366,7 @@ function createPulseLinkClient(options) {
1256
1366
  reset: () => {
1257
1367
  accountRetryAt = 0;
1258
1368
  accountAttempts = 0;
1369
+ lifecycleGeneration += 1;
1259
1370
  deterministicEpoch += 1;
1260
1371
  terminalDeliveryGeneration += 1;
1261
1372
  terminalDeliverySending = null;
@@ -1456,6 +1567,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
1456
1567
  const jitter = 0.5 + hash / 0xffffffff * 0.5;
1457
1568
  return Math.max(1, Math.floor(ceiling * jitter));
1458
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
+ }
1459
1584
  async function withTimeout(fetcher, input, init, timeoutMs) {
1460
1585
  const controller = new AbortController();
1461
1586
  const timer = setTimeout(() => controller.abort(), timeoutMs);