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/src/links.ts CHANGED
@@ -122,6 +122,14 @@ export interface AnonymousFirstOpenContext {
122
122
  /** JavaScript Date#getTimezoneOffset semantics, bounded to real-world UTC offsets. */
123
123
  timezoneOffsetMinutes?: number;
124
124
  isEmulator?: boolean;
125
+ /**
126
+ * The address the person is signed in with, when the host app has one. An install that arrives
127
+ * with an account does not need to be guessed at: the resolver binds it to the message sent to
128
+ * that address and skips probabilistic matching entirely. Omitted for signed-out first opens,
129
+ * and never read from the clipboard or any other ambient source — the app passes what it
130
+ * already knows, or nothing.
131
+ */
132
+ accountEmail?: string;
125
133
  }
126
134
 
127
135
  export type AnonymousFirstOpenResult =
@@ -261,6 +269,17 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
261
269
  randomUUID?: () => string;
262
270
  /** Phase-one public campaign exposures are 128-bit hex; account tokens use the wider grammar. */
263
271
  isPublicToken?: (token: string) => boolean;
272
+ /**
273
+ * Whether a first open may name the signed-in account.
274
+ *
275
+ * Off by default, and deliberately a second switch rather than "the host passed one, so send
276
+ * it": an address is the only piece of the first-open request that identifies a person, and an
277
+ * integrator adopting Pulse must choose to send it rather than discover later that reading their
278
+ * own auth state started forwarding addresses. With it on, an install that arrives signed in is
279
+ * bound to the message sent to that address instead of being inferred from device shape and
280
+ * timing — the only deterministic signal left where no store referrer survives.
281
+ */
282
+ sendAccountEmailOnFirstOpen?: boolean;
264
283
  }
265
284
 
266
285
  export interface DeferredLinkClient {
@@ -574,6 +593,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
574
593
  let accountRetryAt = 0;
575
594
  let accountAttempts = 0;
576
595
  let deterministicEpoch = 0;
596
+ // Invalidates every host callback that crossed an await when this client is reset/disposed.
597
+ // This is deliberately separate from deterministicEpoch: captures during a committed callback
598
+ // have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
599
+ // state write, retry and outcome from the old instance.
600
+ let lifecycleGeneration = 0;
577
601
  // Host navigation is irreversible once any routing callback has started: it may perform its
578
602
  // side effect synchronously before returning a Promise. Serialize captures against that commit
579
603
  // boundary and keep the newer accepted token durable for the next client lifecycle instead of
@@ -796,6 +820,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
796
820
  status: DeferredLinkStatus,
797
821
  outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
798
822
  ): void => {
823
+ const completionLifecycleGeneration = lifecycleGeneration;
824
+ const completionDeterministicEpoch = deterministicEpoch;
825
+ const completionIsCurrent = (): boolean => !disposed
826
+ && lifecycleGeneration === completionLifecycleGeneration
827
+ && deterministicEpoch === completionDeterministicEpoch;
799
828
  let shouldQueue = Boolean(options.onFirstOpenResult);
800
829
  if (shouldQueue && options.shouldQueueFirstOpenResult) {
801
830
  try {
@@ -804,6 +833,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
804
833
  shouldQueue = false;
805
834
  reportError(error);
806
835
  }
836
+ // Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
837
+ // deterministic destination. Never resurrect the terminal first-open state they invalidated.
838
+ if (!completionIsCurrent()) return;
807
839
  }
808
840
  if (!shouldQueue) {
809
841
  state = {
@@ -852,21 +884,36 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
852
884
  void flushTerminalDelivery();
853
885
  };
854
886
 
855
- const blockFirstOpenForDeterministic = (): void => {
887
+ const blockFirstOpenForDeterministic = (): boolean => {
888
+ const blockingLifecycleGeneration = lifecycleGeneration;
856
889
  deterministicEpoch += 1;
857
- if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
890
+ const blockingDeterministicEpoch = deterministicEpoch;
891
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
858
892
  state = {
859
893
  ...state,
860
894
  firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
861
895
  };
862
896
  persistAndNotify();
897
+ return !disposed
898
+ && lifecycleGeneration === blockingLifecycleGeneration
899
+ && deterministicEpoch === blockingDeterministicEpoch;
863
900
  };
864
901
 
865
- const isAccountReady = (): boolean => {
866
- try { return options.isAccountReady?.() ?? false; } catch (error) {
902
+ const readAccountReadiness = (): { ready: boolean; current: boolean } => {
903
+ const readinessLifecycleGeneration = lifecycleGeneration;
904
+ const readinessDeterministicEpoch = deterministicEpoch;
905
+ let ready = false;
906
+ try { ready = options.isAccountReady?.() ?? false; } catch (error) {
867
907
  reportError(error);
868
- return false;
869
908
  }
909
+ return {
910
+ ready,
911
+ // Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
912
+ // the caller's snapshot and must be observed before any waiting/retry/application write.
913
+ current: !disposed
914
+ && lifecycleGeneration === readinessLifecycleGeneration
915
+ && deterministicEpoch === readinessDeterministicEpoch,
916
+ };
870
917
  };
871
918
 
872
919
  const schedulePendingRetry = (): void => {
@@ -1101,7 +1148,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1101
1148
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
1102
1149
  return false;
1103
1150
  }
1104
- if (!isAccountReady()) {
1151
+ const accountReadiness = readAccountReadiness();
1152
+ if (!accountReadiness.current) return false;
1153
+ if (!accountReadiness.ready) {
1105
1154
  patchState({ status: 'waiting_for_account' });
1106
1155
  return false;
1107
1156
  }
@@ -1122,6 +1171,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1122
1171
  let applicationFailed = false;
1123
1172
  let applicationError: unknown;
1124
1173
  const applicationEpoch = deterministicEpoch;
1174
+ const applicationLifecycleGeneration = lifecycleGeneration;
1175
+ const lifecycleIsCurrent = (): boolean => !disposed
1176
+ && lifecycleGeneration === applicationLifecycleGeneration;
1125
1177
  const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
1126
1178
  && state.pending !== null
1127
1179
  // A provenance upgrade or rejected lower-priority capture for the token already being
@@ -1129,7 +1181,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1129
1181
  && (sourceToken === null || state.pending.token !== sourceToken);
1130
1182
  routingApplicationInFlight = true;
1131
1183
  emitOutcome(link, 'app_open_confirmed');
1184
+ if (!lifecycleIsCurrent()) {
1185
+ routingApplicationInFlight = false;
1186
+ return false;
1187
+ }
1132
1188
  emitOutcome(link, 'deferred_link_resolved');
1189
+ if (!lifecycleIsCurrent()) {
1190
+ routingApplicationInFlight = false;
1191
+ return false;
1192
+ }
1133
1193
  // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
1134
1194
  // a newer accepted destination, it still arrived before the routing callback and must win.
1135
1195
  if (hasNewerPendingIntent()) {
@@ -1153,10 +1213,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1153
1213
  routingApplicationInFlight = false;
1154
1214
  }
1155
1215
 
1216
+ // The callback may resolve after reset/dispose and after another client has already committed
1217
+ // a newer journey into the same storage. The old instance must not schedule a retry, rewrite
1218
+ // state, or emit action_applied regardless of whether the callback returned true/false/threw.
1219
+ if (!lifecycleIsCurrent()) return false;
1220
+
1156
1221
  const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
1157
1222
  if (applicationFailed) {
1158
1223
  reportError(applicationError);
1159
- if (newerIntentArrivedDuringApplication) {
1224
+ if (!lifecycleIsCurrent()) return false;
1225
+ // onError is host code too: it may capture a newer deterministic destination while reporting
1226
+ // this failure. Re-evaluate after the callback instead of arming a stale retry from the value
1227
+ // observed before diagnostics ran.
1228
+ if (hasNewerPendingIntent()) {
1160
1229
  // The current action did not commit. Resume the newer accepted intent that was captured
1161
1230
  // while its callback was suspended, without scheduling a retry for the superseded one.
1162
1231
  void process();
@@ -1200,11 +1269,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1200
1269
  appliedIds,
1201
1270
  };
1202
1271
  persistAndNotify();
1272
+ if (!lifecycleIsCurrent()) return false;
1203
1273
  if (link.matchBasis === 'account_bound') {
1204
1274
  accountAttempts = 0;
1205
1275
  accountRetryAt = 0;
1206
1276
  }
1207
1277
  emitOutcome(link, 'action_applied');
1278
+ if (!lifecycleIsCurrent()) return false;
1208
1279
  if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
1209
1280
  void process();
1210
1281
  }
@@ -1258,9 +1329,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1258
1329
  return;
1259
1330
  }
1260
1331
  const token = pending.token;
1332
+ const pendingLifecycleGeneration = lifecycleGeneration;
1261
1333
  patchState({ status: 'resolving' });
1334
+ if (disposed
1335
+ || lifecycleGeneration !== pendingLifecycleGeneration
1336
+ || state.pending?.token !== token) return;
1262
1337
  const publicResult = await resolvePublic(token, pending.matchBasis);
1263
- if (disposed || state.pending?.token !== token) return;
1338
+ if (disposed
1339
+ || lifecycleGeneration !== pendingLifecycleGeneration
1340
+ || state.pending?.token !== token) return;
1264
1341
  if (publicResult.kind === 'resolved') {
1265
1342
  await applyResolved(publicResult.link, token);
1266
1343
  return;
@@ -1270,7 +1347,16 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1270
1347
  return;
1271
1348
  }
1272
1349
 
1273
- if (!options.accountBridge || !isAccountReady()) {
1350
+ if (!options.accountBridge) {
1351
+ patchState({ status: 'waiting_for_account' });
1352
+ return;
1353
+ }
1354
+ const accountReadiness = readAccountReadiness();
1355
+ if (!accountReadiness.current
1356
+ || disposed
1357
+ || lifecycleGeneration !== pendingLifecycleGeneration
1358
+ || state.pending?.token !== token) return;
1359
+ if (!accountReadiness.ready) {
1274
1360
  patchState({ status: 'waiting_for_account' });
1275
1361
  return;
1276
1362
  }
@@ -1280,7 +1366,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1280
1366
  requestTimeoutMs,
1281
1367
  'account claim',
1282
1368
  );
1283
- if (disposed || state.pending?.token !== token) return;
1369
+ if (disposed
1370
+ || lifecycleGeneration !== pendingLifecycleGeneration
1371
+ || state.pending?.token !== token) return;
1284
1372
  const normalizedClaim = normalizeResolved<Action>(
1285
1373
  raw,
1286
1374
  token,
@@ -1295,19 +1383,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1295
1383
  confidence: 1,
1296
1384
  } : null;
1297
1385
  if (claimed) {
1298
- blockFirstOpenForDeterministic();
1386
+ if (!blockFirstOpenForDeterministic()) return;
1299
1387
  await applyResolved(claimed, token);
1300
1388
  }
1301
1389
  else clearPendingAsTerminal();
1302
1390
  } catch (error) {
1391
+ if (disposed
1392
+ || lifecycleGeneration !== pendingLifecycleGeneration
1393
+ || state.pending?.token !== token) return;
1303
1394
  reportError(error);
1304
- if (state.pending?.token === token) schedulePendingRetry();
1395
+ if (!disposed
1396
+ && lifecycleGeneration === pendingLifecycleGeneration
1397
+ && state.pending?.token === token) schedulePendingRetry();
1305
1398
  }
1306
1399
  return;
1307
1400
  }
1308
1401
 
1309
- if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
1402
+ if (!options.accountBridge) return;
1403
+ const accountReadiness = readAccountReadiness();
1404
+ if (!accountReadiness.current || state.pending) return;
1405
+ if (!accountReadiness.ready || accountRetryAt > now()) return;
1310
1406
  const accountPendingEpoch = deterministicEpoch;
1407
+ const accountPendingLifecycleGeneration = lifecycleGeneration;
1311
1408
  try {
1312
1409
  const raw = await withPromiseTimeout(
1313
1410
  () => options.accountBridge!.pending(),
@@ -1317,7 +1414,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1317
1414
  // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
1318
1415
  // user intent. Ignore the stale bridge response and let the requested drain resolve the
1319
1416
  // captured token; otherwise both deterministic destinations could be applied.
1320
- if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
1417
+ if (disposed
1418
+ || lifecycleGeneration !== accountPendingLifecycleGeneration
1419
+ || deterministicEpoch !== accountPendingEpoch
1420
+ || state.pending) return;
1321
1421
  if (!raw) {
1322
1422
  accountAttempts = 0;
1323
1423
  accountRetryAt = 0;
@@ -1346,7 +1446,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1346
1446
  // Reserve the first-open journey for deterministic recovery before invoking any host
1347
1447
  // callback. This also covers an already-applied account item restored from older SDK
1348
1448
  // state, which must still prevent a second probabilistic destination.
1349
- blockFirstOpenForDeterministic();
1449
+ if (!blockFirstOpenForDeterministic()) return;
1350
1450
  if (!state.appliedIds.includes(pendingLink.id)) {
1351
1451
  await applyResolved(pendingLink, null);
1352
1452
  } else if (state.status !== 'applied') {
@@ -1354,8 +1454,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1354
1454
  }
1355
1455
  }
1356
1456
  } catch (error) {
1357
- if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
1457
+ if (disposed
1458
+ || lifecycleGeneration !== accountPendingLifecycleGeneration
1459
+ || deterministicEpoch !== accountPendingEpoch
1460
+ || state.pending) return;
1358
1461
  reportError(error);
1462
+ if (disposed
1463
+ || lifecycleGeneration !== accountPendingLifecycleGeneration
1464
+ || deterministicEpoch !== accountPendingEpoch
1465
+ || state.pending) return;
1359
1466
  // No token was consumed; the authenticated server outbox remains authoritative.
1360
1467
  scheduleAccountRetry();
1361
1468
  }
@@ -1451,7 +1558,14 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1451
1558
  lastFirstOpenContext = context;
1452
1559
  if (state.pending) return 'deterministic_pending';
1453
1560
  if (state.firstOpen.completed) return 'already_completed';
1454
- if (state.firstOpen.nextRetryAt > now()) return 'backoff';
1561
+ if (state.firstOpen.nextRetryAt > now()) {
1562
+ // On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
1563
+ // context, so the persisted first-open deadline could not be part of its candidates. Re-arm
1564
+ // now that retrying is possible; otherwise a 503 followed by process death remains asleep
1565
+ // until some unrelated foreground/manual call invokes matchFirstOpen again.
1566
+ scheduleWake();
1567
+ return 'backoff';
1568
+ }
1455
1569
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1456
1570
 
1457
1571
  // The client starts account recovery at construction. Join that deterministic rail before
@@ -1460,15 +1574,22 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1460
1574
  await process();
1461
1575
  if (disposed) return 'ineligible';
1462
1576
  if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1463
- if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
1464
- if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1465
- return 'retry_scheduled';
1577
+ if (options.accountBridge) {
1578
+ const accountReadiness = readAccountReadiness();
1579
+ if (!accountReadiness.current) {
1580
+ return disposed ? 'ineligible' : 'deterministic_pending';
1581
+ }
1582
+ if (accountReadiness.ready && accountRetryAt > now()) {
1583
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1584
+ return 'retry_scheduled';
1585
+ }
1466
1586
  }
1467
1587
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1468
1588
 
1469
1589
  const installAttemptId = state.firstOpen.installAttemptId
1470
1590
  ?? makeInstallAttemptId(options.randomUUID);
1471
1591
  const startingDeterministicEpoch = deterministicEpoch;
1592
+ const startingLifecycleGeneration = lifecycleGeneration;
1472
1593
  state = {
1473
1594
  ...state,
1474
1595
  status: 'resolving',
@@ -1478,8 +1599,37 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1478
1599
  attemptedAt: now(),
1479
1600
  },
1480
1601
  };
1481
- persistAndNotify();
1602
+ const firstOpenReservationPersisted = persistAndNotify();
1603
+ // Persistence notifies host state hooks synchronously. They may reset/dispose this client or
1604
+ // capture a deterministic destination. In all three cases the reserved id no longer belongs to
1605
+ // the current lifecycle and must never cross the network boundary.
1606
+ if (disposed) return 'ineligible';
1607
+ if (lifecycleGeneration !== startingLifecycleGeneration
1608
+ || deterministicEpoch !== startingDeterministicEpoch
1609
+ || state.pending
1610
+ || state.firstOpen.completed) return 'deterministic_pending';
1611
+ if (options.storage && !firstOpenReservationPersisted) {
1612
+ // An unpersisted id must never leave the process: after a crash the retry would mint a new
1613
+ // id and the server could count/route the same install twice. Keep the exact nonce in memory,
1614
+ // arm a persistence retry without incrementing matcher attempts, and send nothing until a
1615
+ // synchronous durable write succeeds. Memory-only clients have no restart contract and may
1616
+ // continue normally.
1617
+ state = {
1618
+ ...state,
1619
+ status: 'retryable_error',
1620
+ firstOpen: {
1621
+ ...state.firstOpen,
1622
+ nextRetryAt: now() + retryBaseMs,
1623
+ },
1624
+ };
1625
+ reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
1626
+ notifyState();
1627
+ return 'retry_scheduled';
1628
+ }
1482
1629
 
1630
+ const accountEmail = options.sendAccountEmailOnFirstOpen === true
1631
+ ? normalizeAccountEmail(context.accountEmail)
1632
+ : null;
1483
1633
  const body = {
1484
1634
  appBundleId: context.appBundleId.trim(),
1485
1635
  platform: 'ios' as const,
@@ -1487,6 +1637,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1487
1637
  firstOpen: true,
1488
1638
  installAttemptId,
1489
1639
  ...normalizeAnonymousFirstOpenSignals(context),
1640
+ // Sent only when the app already knows it. The resolver treats an address that matches
1641
+ // exactly one recent message as proof of origin; anything else falls back to the
1642
+ // probabilistic path, so a shared or unknown address costs nothing.
1643
+ ...(accountEmail ? { accountEmail } : {}),
1490
1644
  };
1491
1645
  try {
1492
1646
  const response = await withTimeout(
@@ -1637,6 +1791,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1637
1791
  const captureAndroidInstallReferrer = async (
1638
1792
  bridge: AndroidInstallReferrerBridge,
1639
1793
  ): Promise<AndroidInstallReferrerResult> => {
1794
+ const referrerLifecycleGeneration = lifecycleGeneration;
1795
+ const referrerDeterministicEpoch = deterministicEpoch;
1640
1796
  let raw: unknown;
1641
1797
  try {
1642
1798
  raw = await bridge.getDeferredHandoff();
@@ -1645,6 +1801,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1645
1801
  return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
1646
1802
  }
1647
1803
  const result = normalizeAndroidInstallReferrerResult(raw);
1804
+ if (disposed
1805
+ || lifecycleGeneration !== referrerLifecycleGeneration
1806
+ || deterministicEpoch !== referrerDeterministicEpoch) return result;
1648
1807
  if (result.status === 'OK' && result.token) {
1649
1808
  capture(result.token, 'android_install_referrer');
1650
1809
  }
@@ -1708,6 +1867,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1708
1867
  dispose: () => {
1709
1868
  if (disposed) return;
1710
1869
  disposed = true;
1870
+ lifecycleGeneration += 1;
1711
1871
  deterministicEpoch += 1;
1712
1872
  terminalDeliveryGeneration += 1;
1713
1873
  if (retryTimer) clearTimeout(retryTimer);
@@ -1719,6 +1879,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1719
1879
  reset: () => {
1720
1880
  accountRetryAt = 0;
1721
1881
  accountAttempts = 0;
1882
+ lifecycleGeneration += 1;
1722
1883
  deterministicEpoch += 1;
1723
1884
  terminalDeliveryGeneration += 1;
1724
1885
  terminalDeliverySending = null;
@@ -1967,6 +2128,20 @@ function resolverOutcomeRetryDelay(
1967
2128
  return Math.max(1, Math.floor(ceiling * jitter));
1968
2129
  }
1969
2130
 
2131
+ /**
2132
+ * An address is only useful to the resolver if it is the same shape the send was recorded with.
2133
+ * Anything that is not plausibly an address is dropped rather than sent: a malformed value can
2134
+ * only ever fail to match, and not sending it keeps the payload free of stray user input.
2135
+ */
2136
+ export function normalizeAccountEmail(raw: string | undefined | null): string | null {
2137
+ const value = (raw ?? '').trim().toLowerCase();
2138
+ if (value.length < 3 || value.length > 255) return null;
2139
+ const at = value.indexOf('@');
2140
+ if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
2141
+ if (/\s/.test(value)) return null;
2142
+ return value;
2143
+ }
2144
+
1970
2145
  async function withTimeout(
1971
2146
  fetcher: typeof fetch,
1972
2147
  input: string,