@sendoracloud/sdk-react-native 1.35.0 → 1.38.0

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/dist/index.cjs CHANGED
@@ -65,6 +65,12 @@ async function getAsyncStorage() {
65
65
  })();
66
66
  return asyncStoragePromise;
67
67
  }
68
+ var warned = /* @__PURE__ */ new Set();
69
+ function warnOnce(key, message) {
70
+ if (warned.has(key)) return;
71
+ warned.add(key);
72
+ console.warn(message);
73
+ }
68
74
  var Storage = class {
69
75
  constructor(opts = {}) {
70
76
  this.cache = /* @__PURE__ */ new Map();
@@ -73,6 +79,12 @@ var Storage = class {
73
79
  this.secureKeys = new Set(opts.secureKeys ?? []);
74
80
  this.prefix = opts.prefix ?? DEFAULT_PREFIX;
75
81
  this.securePrefix = opts.prefix ?? "";
82
+ if (this.secure === null && this.secureKeys.size > 0) {
83
+ warnOnce(
84
+ "no-secure-adapter",
85
+ "[sendora] No `secureStorage` adapter supplied \u2014 auth tokens (refresh token, access token, cached user) will be stored in PLAIN AsyncStorage, which is included in iOS/Android device backups. Pass `secureStorage` in init() with a Keychain/Keystore-backed adapter (e.g. expo-secure-store). The session still persists either way; this is about where the token rests."
86
+ );
87
+ }
76
88
  }
77
89
  /** The adapter-facing name for a key. Bare by default; see `prefix`. */
78
90
  secureName(key) {
@@ -126,6 +138,10 @@ var Storage = class {
126
138
  }
127
139
  }
128
140
  } catch {
141
+ warnOnce(
142
+ "secure-read-degraded",
143
+ "[sendora] The `secureStorage` adapter failed on read; falling back to plain AsyncStorage for this session. Tokens are preserved but no longer encrypted at rest. Check your adapter."
144
+ );
129
145
  if (store) {
130
146
  try {
131
147
  const legacy = await store.getItem(this.prefix + k);
@@ -142,6 +158,10 @@ var Storage = class {
142
158
  this.cache.set(key, value);
143
159
  if (this.isSecure(key)) {
144
160
  void this.secure.setItem(this.secureName(key), value).catch(() => {
161
+ warnOnce(
162
+ "secure-write-degraded",
163
+ "[sendora] The `secureStorage` adapter failed on write; this value fell back to plain AsyncStorage. The session is preserved but this token is no longer encrypted at rest. Check your adapter."
164
+ );
145
165
  void getAsyncStorage().then((store) => {
146
166
  if (store) store.setItem(this.prefix + key, value).catch(() => {
147
167
  });
@@ -208,7 +228,7 @@ var Storage = class {
208
228
  // package.json
209
229
  var package_default = {
210
230
  name: "@sendoracloud/sdk-react-native",
211
- version: "1.35.0",
231
+ version: "1.38.0",
212
232
  description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
213
233
  type: "module",
214
234
  main: "./dist/index.cjs",
@@ -264,7 +284,7 @@ var package_default = {
264
284
  scripts: {
265
285
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
266
286
  typecheck: "tsc --noEmit",
267
- test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
287
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts"
268
288
  },
269
289
  devDependencies: {
270
290
  "@types/react": "^19.2.16",
@@ -496,6 +516,12 @@ function classifyAuthErrorKind(code, status) {
496
516
  return "credential_in_use";
497
517
  case "LAST_CREDENTIAL":
498
518
  return "last_credential";
519
+ // ⚠ MUST be a code case, not a status one. The tail of this function is
520
+ // `if (status >= 400) return "invalid_credential"`, which would otherwise
521
+ // claim the 403 and report a step-up refusal as a bad credential —
522
+ // documented as "needs NEW input", which is the opposite of the truth.
523
+ case "RECENT_AUTH_REQUIRED":
524
+ return "recent_auth_required";
499
525
  case "NOT_ANONYMOUS":
500
526
  case "FORBIDDEN_NON_ANONYMOUS":
501
527
  return "already_identified";
@@ -1582,13 +1608,24 @@ var Auth = class {
1582
1608
  const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
1583
1609
  return Boolean(data.confirmed);
1584
1610
  }
1611
+ /**
1612
+ * Disable MFA.
1613
+ *
1614
+ * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1615
+ * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1616
+ *
1617
+ * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1618
+ * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1619
+ * surface — the app reported MFA as off while TOTP stayed enrolled.
1620
+ */
1585
1621
  async disableMfa() {
1586
- await post(
1622
+ const res = await post(
1587
1623
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1588
1624
  "/api/v1/auth-service/mfa/disable",
1589
1625
  {},
1590
1626
  this.bearerHeaders()
1591
1627
  );
1628
+ await this.unwrap(res, "MFA_DISABLE_FAILED");
1592
1629
  }
1593
1630
  // ============================================================
1594
1631
  // DEVICE SESSIONS (Bearer-authenticated)
@@ -1603,19 +1640,28 @@ var Auth = class {
1603
1640
  const parsed = await res.json();
1604
1641
  return Array.isArray(parsed.data) ? parsed.data : [];
1605
1642
  }
1643
+ /**
1644
+ * Revoke one of the caller's own sessions ("sign out that device").
1645
+ * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1646
+ * that reports success on a failure is the one place a user acts on the claim
1647
+ * and then stops worrying.
1648
+ */
1606
1649
  async revokeSession(sessionId) {
1607
- await del(
1650
+ const res = await del(
1608
1651
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1609
1652
  `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
1610
1653
  this.bearerHeaders()
1611
1654
  );
1655
+ await this.unwrap(res, "SESSION_REVOKE_FAILED");
1612
1656
  }
1657
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1613
1658
  async revokeAllSessions() {
1614
- await del(
1659
+ const res = await del(
1615
1660
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1616
1661
  "/api/v1/auth-service/sessions/me",
1617
1662
  this.bearerHeaders()
1618
1663
  );
1664
+ await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
1619
1665
  }
1620
1666
  /**
1621
1667
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -1781,7 +1827,10 @@ var Auth = class {
1781
1827
  if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1782
1828
  const parsed = await res.json().catch(() => ({}));
1783
1829
  if (!res.ok || !parsed.success) {
1784
- throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
1830
+ throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
1831
+ status: res.status,
1832
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
1833
+ });
1785
1834
  }
1786
1835
  return parsed.data;
1787
1836
  }
@@ -2107,7 +2156,7 @@ var Auth = class {
2107
2156
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
2108
2157
  this.hooks.storage.remove(REFRESH_KEY);
2109
2158
  this.stopProactiveRefreshCron();
2110
- await this.hooks.onAnonymousWipe();
2159
+ await this.hooks.onAnonymousWipe(reason);
2111
2160
  if (reason !== "replaced") {
2112
2161
  this.emitAuthState({ event: "signed_out", user: null, reason });
2113
2162
  }
@@ -2578,33 +2627,31 @@ var Links = class {
2578
2627
  }
2579
2628
  }
2580
2629
  /**
2581
- * Cold-launch deferred deep link match. When neither `fingerprintHash` nor
2582
- * `installReferrer` is supplied, the SDK auto-discovers:
2583
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
2584
- * accurate when present).
2585
- * - Otherwise: computes the canonical device fingerprint hash.
2630
+ * Cold-launch deferred deep-link match.
2631
+ *
2632
+ * ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
2633
+ * fingerprint on iOS when you passed no signal; it no longer does — Apple
2634
+ * prohibits deriving a device identifier from device signals for attribution
2635
+ * (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
2636
+ * exactly that. Signal resolution now:
2637
+ * - Android: probes `react-native-play-install-referrer` — deterministic, the
2638
+ * preferred and compliant path.
2639
+ * - iOS: no deterministic signal exists (the App Store has no install referrer).
2640
+ * With `allowProbabilisticMatch: true` the server matches on a coarse
2641
+ * IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
2642
+ * returns `null` and you route the user through your normal onboarding UX —
2643
+ * which is the expected iOS cold-launch outcome, not an error.
2586
2644
  *
2587
2645
  * Fires `onLinkOpened` with `isDeferred: true` on success.
2588
2646
  */
2589
2647
  async matchDeferred(input = {}) {
2590
- let fingerprintHash = input.fingerprintHash;
2648
+ const fingerprintHash = input.fingerprintHash;
2591
2649
  let installReferrer = input.installReferrer;
2592
2650
  if (!fingerprintHash && !installReferrer) {
2593
2651
  installReferrer = await getPlayInstallReferrer() ?? void 0;
2594
- if (!installReferrer) {
2595
- try {
2596
- fingerprintHash = await computeDeviceFingerprint();
2597
- } catch (err) {
2598
- if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
2599
- }
2600
- }
2601
- }
2602
- if (!fingerprintHash && !installReferrer) {
2603
- throw new LinkError(
2604
- "INVALID_INPUT",
2605
- "links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
2606
- );
2607
2652
  }
2653
+ const canMatch = !!fingerprintHash || !!installReferrer || input.allowProbabilisticMatch === true;
2654
+ if (!canMatch) return null;
2608
2655
  const body = {};
2609
2656
  if (fingerprintHash) body.fingerprintHash = fingerprintHash;
2610
2657
  if (installReferrer) body.installReferrer = installReferrer;
@@ -3052,9 +3099,11 @@ var SendoraSDK = class {
3052
3099
  if (userId) this.storage.set(USER_ID_KEY, userId);
3053
3100
  else this.storage.remove(USER_ID_KEY);
3054
3101
  },
3055
- onAnonymousWipe: async () => {
3056
- this.anonId = uuid();
3057
- this.storage.set(ANON_KEY, this.anonId);
3102
+ onAnonymousWipe: async (reason) => {
3103
+ if (reason === "user" || reason === "account_deleted") {
3104
+ this.anonId = uuid();
3105
+ this.storage.set(ANON_KEY, this.anonId);
3106
+ }
3058
3107
  this.userId = null;
3059
3108
  this.storage.remove(USER_ID_KEY);
3060
3109
  }
package/dist/index.d.cts CHANGED
@@ -464,7 +464,7 @@ type UnlinkResult = {
464
464
  * OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
465
465
  * the stored refresh token exactly as they were.
466
466
  */
467
- type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "already_identified" | "cancelled" | "config" | "unknown";
467
+ type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
468
468
  declare class AuthError extends Error {
469
469
  code: string;
470
470
  /**
@@ -497,12 +497,15 @@ interface AuthHooks {
497
497
  * identity (analytics userId, push token registration, etc). */
498
498
  onIdentityChange: (userId: string | null) => void;
499
499
  /**
500
- * Called when the SDK switches from one identity to another via
501
- * signIn() or non-anonymous signUp(). The parent rotates anonId
502
- * + drops any pending events so the new user can't carry over
503
- * orphaned attribution.
500
+ * Called from `wipeLocalIdentity` whenever the local session is cleared. The
501
+ * `reason` lets the parent decide whether to rotate the anonymous analytics id:
502
+ * it rotates ONLY at a person-boundary ("user" = explicit signOut,
503
+ * "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
504
+ * involuntary session death ("session_expired") — those are the same person, and
505
+ * the anon id must survive so pre-auth analytics link to them (Firebase-FID
506
+ * semantics; the industry norm).
504
507
  */
505
- onAnonymousWipe: () => Promise<void>;
508
+ onAnonymousWipe: (reason: WipeReason) => Promise<void>;
506
509
  apiUrl: string;
507
510
  publicKey: string;
508
511
  debug: boolean;
@@ -565,6 +568,12 @@ type AuthStateChange = {
565
568
  user: AuthUser | null;
566
569
  };
567
570
  type AuthStateListener = (change: AuthStateChange) => void;
571
+ /**
572
+ * Internal marker for a wipe that is immediately followed by a `persist()` of
573
+ * a NEW session (the sign-in success path). Not a sign-out — emitting one
574
+ * would make every successful sign-in look like a logout to subscribers.
575
+ */
576
+ type WipeReason = AuthSignedOutReason | "replaced";
568
577
  declare class Auth {
569
578
  private hooks;
570
579
  private user;
@@ -1109,6 +1118,16 @@ declare class Auth {
1109
1118
  recoveryCodes: string[];
1110
1119
  }>;
1111
1120
  confirmMfa(code: string): Promise<boolean>;
1121
+ /**
1122
+ * Disable MFA.
1123
+ *
1124
+ * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1125
+ * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1126
+ *
1127
+ * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1128
+ * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1129
+ * surface — the app reported MFA as off while TOTP stayed enrolled.
1130
+ */
1112
1131
  disableMfa(): Promise<void>;
1113
1132
  listMySessions(): Promise<Array<{
1114
1133
  id: string;
@@ -1117,7 +1136,14 @@ declare class Auth {
1117
1136
  lastUsedAt: string | null;
1118
1137
  createdAt: string;
1119
1138
  }>>;
1139
+ /**
1140
+ * Revoke one of the caller's own sessions ("sign out that device").
1141
+ * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1142
+ * that reports success on a failure is the one place a user acts on the claim
1143
+ * and then stops worrying.
1144
+ */
1120
1145
  revokeSession(sessionId: string): Promise<void>;
1146
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1121
1147
  revokeAllSessions(): Promise<void>;
1122
1148
  /**
1123
1149
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -1383,11 +1409,33 @@ interface LinkOpenedEvent<T extends LinkData = LinkData> {
1383
1409
  }
1384
1410
  type LinkOpenedHandler<T extends LinkData = LinkData> = (event: LinkOpenedEvent<T>) => void;
1385
1411
  interface LinkMatchInput {
1386
- /** Hex-encoded SHA-256 — pass output of `computeDeviceFingerprint()`. */
1412
+ /**
1413
+ * Hex-encoded SHA-256 device fingerprint.
1414
+ *
1415
+ * ⚠ **Do not supply this on iOS.** Apple prohibits deriving a device identifier
1416
+ * from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), and it
1417
+ * does not care which side of the network computed it — passing a hash you built
1418
+ * on-device is the same practice. The SDK no longer computes one for you (it used
1419
+ * to, implicitly, on iOS). Left here for the rare Android/self-hosted caller that
1420
+ * has a legitimate deterministic hash; on iOS, omit it.
1421
+ */
1387
1422
  fingerprintHash?: string;
1388
1423
  /** Raw Play Install Referrer. Sendora probes
1389
1424
  * `react-native-play-install-referrer` automatically when this is omitted. */
1390
1425
  installReferrer?: string;
1426
+ /**
1427
+ * Opt in to probabilistic matching on iOS.
1428
+ *
1429
+ * iOS has no deterministic deferred-install signal (no install referrer), and the
1430
+ * SDK will not fingerprint the device. The only remaining matcher is the server's
1431
+ * coarse IP + timestamp window — the same class of practice Apple scrutinises, and
1432
+ * it collides across a shared NAT / iCloud Private Relay. So it is **off by
1433
+ * default**: without this flag, `matchDeferred()` on iOS matches nothing and
1434
+ * returns `null` (route the user through your normal onboarding UX). Set it only
1435
+ * if you accept the accuracy and compliance trade-off. No effect on Android, where
1436
+ * the Play Install Referrer is deterministic.
1437
+ */
1438
+ allowProbabilisticMatch?: boolean;
1391
1439
  iosBundleId?: string;
1392
1440
  androidPackageName?: string;
1393
1441
  }
@@ -1420,12 +1468,14 @@ interface LinkStats {
1420
1468
  */
1421
1469
  declare function extractShortcodeFromUrl(url: string, linkDomain?: string): string | null;
1422
1470
  /**
1423
- * Canonical device-fingerprint recipe. Identical byte-for-byte across the
1424
- * RN / iOS / Android SDKs so a click logged from one platform and an
1425
- * install completing on another won't accidentally match.
1471
+ * Canonical device-fingerprint recipe (`${platform}|${screenW}x${screenH}|${timezone}|${locale}`
1472
+ * → lowercase hex SHA-256), identical across the RN / iOS / Android SDKs.
1426
1473
  *
1427
- * Input: `${platform}|${screenW}x${screenH}|${timezone}|${locale}`
1428
- * Output: lowercase hex SHA-256.
1474
+ * @deprecated ⚠ Do not use on iOS. Apple prohibits deriving a device identifier
1475
+ * from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), regardless
1476
+ * of ATT — and this is exactly that. `matchDeferred()` no longer calls it
1477
+ * implicitly. It is retained only for an Android/self-hosted caller with a
1478
+ * legitimate deterministic use, and is slated for removal in a future major.
1429
1479
  */
1430
1480
  declare function computeDeviceFingerprint(): Promise<string>;
1431
1481
  /** Probe optional `react-native-play-install-referrer` peer. Returns the
@@ -1494,11 +1544,20 @@ declare class Links {
1494
1544
  */
1495
1545
  handleUniversalLink(url: string): Promise<boolean>;
1496
1546
  /**
1497
- * Cold-launch deferred deep link match. When neither `fingerprintHash` nor
1498
- * `installReferrer` is supplied, the SDK auto-discovers:
1499
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
1500
- * accurate when present).
1501
- * - Otherwise: computes the canonical device fingerprint hash.
1547
+ * Cold-launch deferred deep-link match.
1548
+ *
1549
+ * ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
1550
+ * fingerprint on iOS when you passed no signal; it no longer does — Apple
1551
+ * prohibits deriving a device identifier from device signals for attribution
1552
+ * (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
1553
+ * exactly that. Signal resolution now:
1554
+ * - Android: probes `react-native-play-install-referrer` — deterministic, the
1555
+ * preferred and compliant path.
1556
+ * - iOS: no deterministic signal exists (the App Store has no install referrer).
1557
+ * With `allowProbabilisticMatch: true` the server matches on a coarse
1558
+ * IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
1559
+ * returns `null` and you route the user through your normal onboarding UX —
1560
+ * which is the expected iOS cold-launch outcome, not an error.
1502
1561
  *
1503
1562
  * Fires `onLinkOpened` with `isDeferred: true` on success.
1504
1563
  */
package/dist/index.d.ts CHANGED
@@ -464,7 +464,7 @@ type UnlinkResult = {
464
464
  * OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
465
465
  * the stored refresh token exactly as they were.
466
466
  */
467
- type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "already_identified" | "cancelled" | "config" | "unknown";
467
+ type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
468
468
  declare class AuthError extends Error {
469
469
  code: string;
470
470
  /**
@@ -497,12 +497,15 @@ interface AuthHooks {
497
497
  * identity (analytics userId, push token registration, etc). */
498
498
  onIdentityChange: (userId: string | null) => void;
499
499
  /**
500
- * Called when the SDK switches from one identity to another via
501
- * signIn() or non-anonymous signUp(). The parent rotates anonId
502
- * + drops any pending events so the new user can't carry over
503
- * orphaned attribution.
500
+ * Called from `wipeLocalIdentity` whenever the local session is cleared. The
501
+ * `reason` lets the parent decide whether to rotate the anonymous analytics id:
502
+ * it rotates ONLY at a person-boundary ("user" = explicit signOut,
503
+ * "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
504
+ * involuntary session death ("session_expired") — those are the same person, and
505
+ * the anon id must survive so pre-auth analytics link to them (Firebase-FID
506
+ * semantics; the industry norm).
504
507
  */
505
- onAnonymousWipe: () => Promise<void>;
508
+ onAnonymousWipe: (reason: WipeReason) => Promise<void>;
506
509
  apiUrl: string;
507
510
  publicKey: string;
508
511
  debug: boolean;
@@ -565,6 +568,12 @@ type AuthStateChange = {
565
568
  user: AuthUser | null;
566
569
  };
567
570
  type AuthStateListener = (change: AuthStateChange) => void;
571
+ /**
572
+ * Internal marker for a wipe that is immediately followed by a `persist()` of
573
+ * a NEW session (the sign-in success path). Not a sign-out — emitting one
574
+ * would make every successful sign-in look like a logout to subscribers.
575
+ */
576
+ type WipeReason = AuthSignedOutReason | "replaced";
568
577
  declare class Auth {
569
578
  private hooks;
570
579
  private user;
@@ -1109,6 +1118,16 @@ declare class Auth {
1109
1118
  recoveryCodes: string[];
1110
1119
  }>;
1111
1120
  confirmMfa(code: string): Promise<boolean>;
1121
+ /**
1122
+ * Disable MFA.
1123
+ *
1124
+ * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1125
+ * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1126
+ *
1127
+ * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1128
+ * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1129
+ * surface — the app reported MFA as off while TOTP stayed enrolled.
1130
+ */
1112
1131
  disableMfa(): Promise<void>;
1113
1132
  listMySessions(): Promise<Array<{
1114
1133
  id: string;
@@ -1117,7 +1136,14 @@ declare class Auth {
1117
1136
  lastUsedAt: string | null;
1118
1137
  createdAt: string;
1119
1138
  }>>;
1139
+ /**
1140
+ * Revoke one of the caller's own sessions ("sign out that device").
1141
+ * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1142
+ * that reports success on a failure is the one place a user acts on the claim
1143
+ * and then stops worrying.
1144
+ */
1120
1145
  revokeSession(sessionId: string): Promise<void>;
1146
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1121
1147
  revokeAllSessions(): Promise<void>;
1122
1148
  /**
1123
1149
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -1383,11 +1409,33 @@ interface LinkOpenedEvent<T extends LinkData = LinkData> {
1383
1409
  }
1384
1410
  type LinkOpenedHandler<T extends LinkData = LinkData> = (event: LinkOpenedEvent<T>) => void;
1385
1411
  interface LinkMatchInput {
1386
- /** Hex-encoded SHA-256 — pass output of `computeDeviceFingerprint()`. */
1412
+ /**
1413
+ * Hex-encoded SHA-256 device fingerprint.
1414
+ *
1415
+ * ⚠ **Do not supply this on iOS.** Apple prohibits deriving a device identifier
1416
+ * from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), and it
1417
+ * does not care which side of the network computed it — passing a hash you built
1418
+ * on-device is the same practice. The SDK no longer computes one for you (it used
1419
+ * to, implicitly, on iOS). Left here for the rare Android/self-hosted caller that
1420
+ * has a legitimate deterministic hash; on iOS, omit it.
1421
+ */
1387
1422
  fingerprintHash?: string;
1388
1423
  /** Raw Play Install Referrer. Sendora probes
1389
1424
  * `react-native-play-install-referrer` automatically when this is omitted. */
1390
1425
  installReferrer?: string;
1426
+ /**
1427
+ * Opt in to probabilistic matching on iOS.
1428
+ *
1429
+ * iOS has no deterministic deferred-install signal (no install referrer), and the
1430
+ * SDK will not fingerprint the device. The only remaining matcher is the server's
1431
+ * coarse IP + timestamp window — the same class of practice Apple scrutinises, and
1432
+ * it collides across a shared NAT / iCloud Private Relay. So it is **off by
1433
+ * default**: without this flag, `matchDeferred()` on iOS matches nothing and
1434
+ * returns `null` (route the user through your normal onboarding UX). Set it only
1435
+ * if you accept the accuracy and compliance trade-off. No effect on Android, where
1436
+ * the Play Install Referrer is deterministic.
1437
+ */
1438
+ allowProbabilisticMatch?: boolean;
1391
1439
  iosBundleId?: string;
1392
1440
  androidPackageName?: string;
1393
1441
  }
@@ -1420,12 +1468,14 @@ interface LinkStats {
1420
1468
  */
1421
1469
  declare function extractShortcodeFromUrl(url: string, linkDomain?: string): string | null;
1422
1470
  /**
1423
- * Canonical device-fingerprint recipe. Identical byte-for-byte across the
1424
- * RN / iOS / Android SDKs so a click logged from one platform and an
1425
- * install completing on another won't accidentally match.
1471
+ * Canonical device-fingerprint recipe (`${platform}|${screenW}x${screenH}|${timezone}|${locale}`
1472
+ * → lowercase hex SHA-256), identical across the RN / iOS / Android SDKs.
1426
1473
  *
1427
- * Input: `${platform}|${screenW}x${screenH}|${timezone}|${locale}`
1428
- * Output: lowercase hex SHA-256.
1474
+ * @deprecated ⚠ Do not use on iOS. Apple prohibits deriving a device identifier
1475
+ * from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), regardless
1476
+ * of ATT — and this is exactly that. `matchDeferred()` no longer calls it
1477
+ * implicitly. It is retained only for an Android/self-hosted caller with a
1478
+ * legitimate deterministic use, and is slated for removal in a future major.
1429
1479
  */
1430
1480
  declare function computeDeviceFingerprint(): Promise<string>;
1431
1481
  /** Probe optional `react-native-play-install-referrer` peer. Returns the
@@ -1494,11 +1544,20 @@ declare class Links {
1494
1544
  */
1495
1545
  handleUniversalLink(url: string): Promise<boolean>;
1496
1546
  /**
1497
- * Cold-launch deferred deep link match. When neither `fingerprintHash` nor
1498
- * `installReferrer` is supplied, the SDK auto-discovers:
1499
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
1500
- * accurate when present).
1501
- * - Otherwise: computes the canonical device fingerprint hash.
1547
+ * Cold-launch deferred deep-link match.
1548
+ *
1549
+ * ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
1550
+ * fingerprint on iOS when you passed no signal; it no longer does — Apple
1551
+ * prohibits deriving a device identifier from device signals for attribution
1552
+ * (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
1553
+ * exactly that. Signal resolution now:
1554
+ * - Android: probes `react-native-play-install-referrer` — deterministic, the
1555
+ * preferred and compliant path.
1556
+ * - iOS: no deterministic signal exists (the App Store has no install referrer).
1557
+ * With `allowProbabilisticMatch: true` the server matches on a coarse
1558
+ * IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
1559
+ * returns `null` and you route the user through your normal onboarding UX —
1560
+ * which is the expected iOS cold-launch outcome, not an error.
1502
1561
  *
1503
1562
  * Fires `onLinkOpened` with `isDeferred: true` on success.
1504
1563
  */
package/dist/index.js CHANGED
@@ -20,6 +20,12 @@ async function getAsyncStorage() {
20
20
  })();
21
21
  return asyncStoragePromise;
22
22
  }
23
+ var warned = /* @__PURE__ */ new Set();
24
+ function warnOnce(key, message) {
25
+ if (warned.has(key)) return;
26
+ warned.add(key);
27
+ console.warn(message);
28
+ }
23
29
  var Storage = class {
24
30
  constructor(opts = {}) {
25
31
  this.cache = /* @__PURE__ */ new Map();
@@ -28,6 +34,12 @@ var Storage = class {
28
34
  this.secureKeys = new Set(opts.secureKeys ?? []);
29
35
  this.prefix = opts.prefix ?? DEFAULT_PREFIX;
30
36
  this.securePrefix = opts.prefix ?? "";
37
+ if (this.secure === null && this.secureKeys.size > 0) {
38
+ warnOnce(
39
+ "no-secure-adapter",
40
+ "[sendora] No `secureStorage` adapter supplied \u2014 auth tokens (refresh token, access token, cached user) will be stored in PLAIN AsyncStorage, which is included in iOS/Android device backups. Pass `secureStorage` in init() with a Keychain/Keystore-backed adapter (e.g. expo-secure-store). The session still persists either way; this is about where the token rests."
41
+ );
42
+ }
31
43
  }
32
44
  /** The adapter-facing name for a key. Bare by default; see `prefix`. */
33
45
  secureName(key) {
@@ -81,6 +93,10 @@ var Storage = class {
81
93
  }
82
94
  }
83
95
  } catch {
96
+ warnOnce(
97
+ "secure-read-degraded",
98
+ "[sendora] The `secureStorage` adapter failed on read; falling back to plain AsyncStorage for this session. Tokens are preserved but no longer encrypted at rest. Check your adapter."
99
+ );
84
100
  if (store) {
85
101
  try {
86
102
  const legacy = await store.getItem(this.prefix + k);
@@ -97,6 +113,10 @@ var Storage = class {
97
113
  this.cache.set(key, value);
98
114
  if (this.isSecure(key)) {
99
115
  void this.secure.setItem(this.secureName(key), value).catch(() => {
116
+ warnOnce(
117
+ "secure-write-degraded",
118
+ "[sendora] The `secureStorage` adapter failed on write; this value fell back to plain AsyncStorage. The session is preserved but this token is no longer encrypted at rest. Check your adapter."
119
+ );
100
120
  void getAsyncStorage().then((store) => {
101
121
  if (store) store.setItem(this.prefix + key, value).catch(() => {
102
122
  });
@@ -163,7 +183,7 @@ var Storage = class {
163
183
  // package.json
164
184
  var package_default = {
165
185
  name: "@sendoracloud/sdk-react-native",
166
- version: "1.35.0",
186
+ version: "1.38.0",
167
187
  description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
168
188
  type: "module",
169
189
  main: "./dist/index.cjs",
@@ -219,7 +239,7 @@ var package_default = {
219
239
  scripts: {
220
240
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
221
241
  typecheck: "tsc --noEmit",
222
- test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
242
+ test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts"
223
243
  },
224
244
  devDependencies: {
225
245
  "@types/react": "^19.2.16",
@@ -451,6 +471,12 @@ function classifyAuthErrorKind(code, status) {
451
471
  return "credential_in_use";
452
472
  case "LAST_CREDENTIAL":
453
473
  return "last_credential";
474
+ // ⚠ MUST be a code case, not a status one. The tail of this function is
475
+ // `if (status >= 400) return "invalid_credential"`, which would otherwise
476
+ // claim the 403 and report a step-up refusal as a bad credential —
477
+ // documented as "needs NEW input", which is the opposite of the truth.
478
+ case "RECENT_AUTH_REQUIRED":
479
+ return "recent_auth_required";
454
480
  case "NOT_ANONYMOUS":
455
481
  case "FORBIDDEN_NON_ANONYMOUS":
456
482
  return "already_identified";
@@ -1537,13 +1563,24 @@ var Auth = class {
1537
1563
  const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
1538
1564
  return Boolean(data.confirmed);
1539
1565
  }
1566
+ /**
1567
+ * Disable MFA.
1568
+ *
1569
+ * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1570
+ * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1571
+ *
1572
+ * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1573
+ * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1574
+ * surface — the app reported MFA as off while TOTP stayed enrolled.
1575
+ */
1540
1576
  async disableMfa() {
1541
- await post(
1577
+ const res = await post(
1542
1578
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1543
1579
  "/api/v1/auth-service/mfa/disable",
1544
1580
  {},
1545
1581
  this.bearerHeaders()
1546
1582
  );
1583
+ await this.unwrap(res, "MFA_DISABLE_FAILED");
1547
1584
  }
1548
1585
  // ============================================================
1549
1586
  // DEVICE SESSIONS (Bearer-authenticated)
@@ -1558,19 +1595,28 @@ var Auth = class {
1558
1595
  const parsed = await res.json();
1559
1596
  return Array.isArray(parsed.data) ? parsed.data : [];
1560
1597
  }
1598
+ /**
1599
+ * Revoke one of the caller's own sessions ("sign out that device").
1600
+ * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1601
+ * that reports success on a failure is the one place a user acts on the claim
1602
+ * and then stops worrying.
1603
+ */
1561
1604
  async revokeSession(sessionId) {
1562
- await del(
1605
+ const res = await del(
1563
1606
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1564
1607
  `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
1565
1608
  this.bearerHeaders()
1566
1609
  );
1610
+ await this.unwrap(res, "SESSION_REVOKE_FAILED");
1567
1611
  }
1612
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1568
1613
  async revokeAllSessions() {
1569
- await del(
1614
+ const res = await del(
1570
1615
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1571
1616
  "/api/v1/auth-service/sessions/me",
1572
1617
  this.bearerHeaders()
1573
1618
  );
1619
+ await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
1574
1620
  }
1575
1621
  /**
1576
1622
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -1736,7 +1782,10 @@ var Auth = class {
1736
1782
  if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1737
1783
  const parsed = await res.json().catch(() => ({}));
1738
1784
  if (!res.ok || !parsed.success) {
1739
- throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`);
1785
+ throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
1786
+ status: res.status,
1787
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
1788
+ });
1740
1789
  }
1741
1790
  return parsed.data;
1742
1791
  }
@@ -2062,7 +2111,7 @@ var Auth = class {
2062
2111
  this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
2063
2112
  this.hooks.storage.remove(REFRESH_KEY);
2064
2113
  this.stopProactiveRefreshCron();
2065
- await this.hooks.onAnonymousWipe();
2114
+ await this.hooks.onAnonymousWipe(reason);
2066
2115
  if (reason !== "replaced") {
2067
2116
  this.emitAuthState({ event: "signed_out", user: null, reason });
2068
2117
  }
@@ -2538,33 +2587,31 @@ var Links = class {
2538
2587
  }
2539
2588
  }
2540
2589
  /**
2541
- * Cold-launch deferred deep link match. When neither `fingerprintHash` nor
2542
- * `installReferrer` is supplied, the SDK auto-discovers:
2543
- * - Android: probes `react-native-play-install-referrer` (preferred — 100%
2544
- * accurate when present).
2545
- * - Otherwise: computes the canonical device fingerprint hash.
2590
+ * Cold-launch deferred deep-link match.
2591
+ *
2592
+ * ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
2593
+ * fingerprint on iOS when you passed no signal; it no longer does — Apple
2594
+ * prohibits deriving a device identifier from device signals for attribution
2595
+ * (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
2596
+ * exactly that. Signal resolution now:
2597
+ * - Android: probes `react-native-play-install-referrer` — deterministic, the
2598
+ * preferred and compliant path.
2599
+ * - iOS: no deterministic signal exists (the App Store has no install referrer).
2600
+ * With `allowProbabilisticMatch: true` the server matches on a coarse
2601
+ * IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
2602
+ * returns `null` and you route the user through your normal onboarding UX —
2603
+ * which is the expected iOS cold-launch outcome, not an error.
2546
2604
  *
2547
2605
  * Fires `onLinkOpened` with `isDeferred: true` on success.
2548
2606
  */
2549
2607
  async matchDeferred(input = {}) {
2550
- let fingerprintHash = input.fingerprintHash;
2608
+ const fingerprintHash = input.fingerprintHash;
2551
2609
  let installReferrer = input.installReferrer;
2552
2610
  if (!fingerprintHash && !installReferrer) {
2553
2611
  installReferrer = await getPlayInstallReferrer() ?? void 0;
2554
- if (!installReferrer) {
2555
- try {
2556
- fingerprintHash = await computeDeviceFingerprint();
2557
- } catch (err) {
2558
- if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
2559
- }
2560
- }
2561
- }
2562
- if (!fingerprintHash && !installReferrer) {
2563
- throw new LinkError(
2564
- "INVALID_INPUT",
2565
- "links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
2566
- );
2567
2612
  }
2613
+ const canMatch = !!fingerprintHash || !!installReferrer || input.allowProbabilisticMatch === true;
2614
+ if (!canMatch) return null;
2568
2615
  const body = {};
2569
2616
  if (fingerprintHash) body.fingerprintHash = fingerprintHash;
2570
2617
  if (installReferrer) body.installReferrer = installReferrer;
@@ -3012,9 +3059,11 @@ var SendoraSDK = class {
3012
3059
  if (userId) this.storage.set(USER_ID_KEY, userId);
3013
3060
  else this.storage.remove(USER_ID_KEY);
3014
3061
  },
3015
- onAnonymousWipe: async () => {
3016
- this.anonId = uuid();
3017
- this.storage.set(ANON_KEY, this.anonId);
3062
+ onAnonymousWipe: async (reason) => {
3063
+ if (reason === "user" || reason === "account_deleted") {
3064
+ this.anonId = uuid();
3065
+ this.storage.set(ANON_KEY, this.anonId);
3066
+ }
3018
3067
  this.userId = null;
3019
3068
  this.storage.remove(USER_ID_KEY);
3020
3069
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.35.0",
3
+ "version": "1.38.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -56,7 +56,7 @@
56
56
  "scripts": {
57
57
  "build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
58
58
  "typecheck": "tsc --noEmit",
59
- "test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
59
+ "test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.2.16",