@sendoracloud/sdk-react-native 1.34.0 → 1.36.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.34.0",
231
+ version: "1.36.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"
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"
268
288
  },
269
289
  devDependencies: {
270
290
  "@types/react": "^19.2.16",
@@ -494,6 +514,14 @@ function classifyAuthErrorKind(code, status) {
494
514
  return "account_locked";
495
515
  case "CREDENTIAL_IN_USE":
496
516
  return "credential_in_use";
517
+ case "LAST_CREDENTIAL":
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";
497
525
  case "NOT_ANONYMOUS":
498
526
  case "FORBIDDEN_NON_ANONYMOUS":
499
527
  return "already_identified";
@@ -1333,6 +1361,56 @@ var Auth = class {
1333
1361
  }
1334
1362
  return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
1335
1363
  }
1364
+ /**
1365
+ * Remove one linked provider from the signed-in account.
1366
+ *
1367
+ * The write half of `listLinkedIdentities()` — what a "Disconnect" button
1368
+ * next to each entry on a Connected Accounts screen calls.
1369
+ *
1370
+ * ⚠ The server REFUSES to remove the last way into the account, with
1371
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
1372
+ * account with no credentials still exists and still holds the user's data,
1373
+ * but nothing can ever authenticate into it again — so this is a permanent
1374
+ * lockout, not an inconvenience. A password counts as a credential.
1375
+ *
1376
+ * Prefer to prevent the tap rather than explain the refusal: read
1377
+ * `listLinkedIdentities()` and disable Disconnect when
1378
+ * `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
1379
+ * backstop, not the UX.
1380
+ *
1381
+ * Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
1382
+ * provider is not linked to this account. Removing an already-absent
1383
+ * provider is NOT treated as success — a silent no-op would let a buggy
1384
+ * screen report "disconnected" for a credential that is still attached.
1385
+ */
1386
+ async unlink(provider) {
1387
+ const token = await this.getAccessToken();
1388
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
1389
+ const res = await del(
1390
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1391
+ `/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
1392
+ { Authorization: `Bearer ${token}` }
1393
+ );
1394
+ if (!res) throw new AuthError("NETWORK_ERROR", "Failed to unlink identity");
1395
+ let parsed;
1396
+ try {
1397
+ parsed = await res.json();
1398
+ } catch {
1399
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1400
+ }
1401
+ if (!parsed.success || !parsed.data) {
1402
+ throw new AuthError(
1403
+ parsed.error?.code ?? "UNLINK_FAILED",
1404
+ parsed.error?.message ?? "Failed to unlink identity",
1405
+ { status: res.status }
1406
+ );
1407
+ }
1408
+ return {
1409
+ provider: parsed.data.provider ?? provider,
1410
+ removed: Number(parsed.data.removed ?? 0),
1411
+ remaining: Number(parsed.data.remaining ?? 0)
1412
+ };
1413
+ }
1336
1414
  /**
1337
1415
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1338
1416
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -1530,13 +1608,24 @@ var Auth = class {
1530
1608
  const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
1531
1609
  return Boolean(data.confirmed);
1532
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
+ */
1533
1621
  async disableMfa() {
1534
- await post(
1622
+ const res = await post(
1535
1623
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1536
1624
  "/api/v1/auth-service/mfa/disable",
1537
1625
  {},
1538
1626
  this.bearerHeaders()
1539
1627
  );
1628
+ await this.unwrap(res, "MFA_DISABLE_FAILED");
1540
1629
  }
1541
1630
  // ============================================================
1542
1631
  // DEVICE SESSIONS (Bearer-authenticated)
@@ -1551,19 +1640,28 @@ var Auth = class {
1551
1640
  const parsed = await res.json();
1552
1641
  return Array.isArray(parsed.data) ? parsed.data : [];
1553
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
+ */
1554
1649
  async revokeSession(sessionId) {
1555
- await del(
1650
+ const res = await del(
1556
1651
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1557
1652
  `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
1558
1653
  this.bearerHeaders()
1559
1654
  );
1655
+ await this.unwrap(res, "SESSION_REVOKE_FAILED");
1560
1656
  }
1657
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1561
1658
  async revokeAllSessions() {
1562
- await del(
1659
+ const res = await del(
1563
1660
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1564
1661
  "/api/v1/auth-service/sessions/me",
1565
1662
  this.bearerHeaders()
1566
1663
  );
1664
+ await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
1567
1665
  }
1568
1666
  /**
1569
1667
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -1729,7 +1827,10 @@ var Auth = class {
1729
1827
  if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1730
1828
  const parsed = await res.json().catch(() => ({}));
1731
1829
  if (!res.ok || !parsed.success) {
1732
- 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
+ });
1733
1834
  }
1734
1835
  return parsed.data;
1735
1836
  }
package/dist/index.d.cts CHANGED
@@ -442,6 +442,19 @@ type LinkedIdentitiesResponse = {
442
442
  identities: LinkedIdentity[];
443
443
  hasPassword: boolean;
444
444
  };
445
+ /**
446
+ * Result of removing one provider from the signed-in account.
447
+ *
448
+ * `removed` can exceed 1: an account may hold more than one row for a provider
449
+ * (distinct provider user ids across projects), and unlinking takes all of them
450
+ * — the floor counts them all as leaving, so reporting only one would be a lie
451
+ * a caller could act on.
452
+ */
453
+ type UnlinkResult = {
454
+ provider: string;
455
+ removed: number;
456
+ remaining: number;
457
+ };
445
458
  /**
446
459
  * Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
447
460
  * string-matching the ~20 raw `code`s the backend and transport can produce.
@@ -451,7 +464,7 @@ type LinkedIdentitiesResponse = {
451
464
  * OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
452
465
  * the stored refresh token exactly as they were.
453
466
  */
454
- type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "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";
455
468
  declare class AuthError extends Error {
456
469
  code: string;
457
470
  /**
@@ -1034,6 +1047,29 @@ declare class Auth {
1034
1047
  * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1035
1048
  */
1036
1049
  listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
1050
+ /**
1051
+ * Remove one linked provider from the signed-in account.
1052
+ *
1053
+ * The write half of `listLinkedIdentities()` — what a "Disconnect" button
1054
+ * next to each entry on a Connected Accounts screen calls.
1055
+ *
1056
+ * ⚠ The server REFUSES to remove the last way into the account, with
1057
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
1058
+ * account with no credentials still exists and still holds the user's data,
1059
+ * but nothing can ever authenticate into it again — so this is a permanent
1060
+ * lockout, not an inconvenience. A password counts as a credential.
1061
+ *
1062
+ * Prefer to prevent the tap rather than explain the refusal: read
1063
+ * `listLinkedIdentities()` and disable Disconnect when
1064
+ * `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
1065
+ * backstop, not the UX.
1066
+ *
1067
+ * Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
1068
+ * provider is not linked to this account. Removing an already-absent
1069
+ * provider is NOT treated as success — a silent no-op would let a buggy
1070
+ * screen report "disconnected" for a credential that is still attached.
1071
+ */
1072
+ unlink(provider: string): Promise<UnlinkResult>;
1037
1073
  /**
1038
1074
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1039
1075
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -1073,6 +1109,16 @@ declare class Auth {
1073
1109
  recoveryCodes: string[];
1074
1110
  }>;
1075
1111
  confirmMfa(code: string): Promise<boolean>;
1112
+ /**
1113
+ * Disable MFA.
1114
+ *
1115
+ * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1116
+ * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1117
+ *
1118
+ * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1119
+ * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1120
+ * surface — the app reported MFA as off while TOTP stayed enrolled.
1121
+ */
1076
1122
  disableMfa(): Promise<void>;
1077
1123
  listMySessions(): Promise<Array<{
1078
1124
  id: string;
@@ -1081,7 +1127,14 @@ declare class Auth {
1081
1127
  lastUsedAt: string | null;
1082
1128
  createdAt: string;
1083
1129
  }>>;
1130
+ /**
1131
+ * Revoke one of the caller's own sessions ("sign out that device").
1132
+ * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1133
+ * that reports success on a failure is the one place a user acts on the claim
1134
+ * and then stops worrying.
1135
+ */
1084
1136
  revokeSession(sessionId: string): Promise<void>;
1137
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1085
1138
  revokeAllSessions(): Promise<void>;
1086
1139
  /**
1087
1140
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -2038,4 +2091,4 @@ declare class SendoraSDK {
2038
2091
  }
2039
2092
  declare const SendoraCloud: SendoraSDK;
2040
2093
 
2041
- export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
2094
+ export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
package/dist/index.d.ts CHANGED
@@ -442,6 +442,19 @@ type LinkedIdentitiesResponse = {
442
442
  identities: LinkedIdentity[];
443
443
  hasPassword: boolean;
444
444
  };
445
+ /**
446
+ * Result of removing one provider from the signed-in account.
447
+ *
448
+ * `removed` can exceed 1: an account may hold more than one row for a provider
449
+ * (distinct provider user ids across projects), and unlinking takes all of them
450
+ * — the floor counts them all as leaving, so reporting only one would be a lie
451
+ * a caller could act on.
452
+ */
453
+ type UnlinkResult = {
454
+ provider: string;
455
+ removed: number;
456
+ remaining: number;
457
+ };
445
458
  /**
446
459
  * Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
447
460
  * string-matching the ~20 raw `code`s the backend and transport can produce.
@@ -451,7 +464,7 @@ type LinkedIdentitiesResponse = {
451
464
  * OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
452
465
  * the stored refresh token exactly as they were.
453
466
  */
454
- type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "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";
455
468
  declare class AuthError extends Error {
456
469
  code: string;
457
470
  /**
@@ -1034,6 +1047,29 @@ declare class Auth {
1034
1047
  * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1035
1048
  */
1036
1049
  listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
1050
+ /**
1051
+ * Remove one linked provider from the signed-in account.
1052
+ *
1053
+ * The write half of `listLinkedIdentities()` — what a "Disconnect" button
1054
+ * next to each entry on a Connected Accounts screen calls.
1055
+ *
1056
+ * ⚠ The server REFUSES to remove the last way into the account, with
1057
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
1058
+ * account with no credentials still exists and still holds the user's data,
1059
+ * but nothing can ever authenticate into it again — so this is a permanent
1060
+ * lockout, not an inconvenience. A password counts as a credential.
1061
+ *
1062
+ * Prefer to prevent the tap rather than explain the refusal: read
1063
+ * `listLinkedIdentities()` and disable Disconnect when
1064
+ * `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
1065
+ * backstop, not the UX.
1066
+ *
1067
+ * Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
1068
+ * provider is not linked to this account. Removing an already-absent
1069
+ * provider is NOT treated as success — a silent no-op would let a buggy
1070
+ * screen report "disconnected" for a credential that is still attached.
1071
+ */
1072
+ unlink(provider: string): Promise<UnlinkResult>;
1037
1073
  /**
1038
1074
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1039
1075
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -1073,6 +1109,16 @@ declare class Auth {
1073
1109
  recoveryCodes: string[];
1074
1110
  }>;
1075
1111
  confirmMfa(code: string): Promise<boolean>;
1112
+ /**
1113
+ * Disable MFA.
1114
+ *
1115
+ * ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
1116
+ * `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
1117
+ *
1118
+ * ⚠ Discarded its response until s58.335, and `request()` never throws on a
1119
+ * non-2xx (it warns and returns the Response), so the refusal had nowhere to
1120
+ * surface — the app reported MFA as off while TOTP stayed enrolled.
1121
+ */
1076
1122
  disableMfa(): Promise<void>;
1077
1123
  listMySessions(): Promise<Array<{
1078
1124
  id: string;
@@ -1081,7 +1127,14 @@ declare class Auth {
1081
1127
  lastUsedAt: string | null;
1082
1128
  createdAt: string;
1083
1129
  }>>;
1130
+ /**
1131
+ * Revoke one of the caller's own sessions ("sign out that device").
1132
+ * ⚠ Discarded its response until s58.335 — a "sign out this device" button
1133
+ * that reports success on a failure is the one place a user acts on the claim
1134
+ * and then stops worrying.
1135
+ */
1084
1136
  revokeSession(sessionId: string): Promise<void>;
1137
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1085
1138
  revokeAllSessions(): Promise<void>;
1086
1139
  /**
1087
1140
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -2038,4 +2091,4 @@ declare class SendoraSDK {
2038
2091
  }
2039
2092
  declare const SendoraCloud: SendoraSDK;
2040
2093
 
2041
- export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
2094
+ export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
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.34.0",
186
+ version: "1.36.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"
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"
223
243
  },
224
244
  devDependencies: {
225
245
  "@types/react": "^19.2.16",
@@ -449,6 +469,14 @@ function classifyAuthErrorKind(code, status) {
449
469
  return "account_locked";
450
470
  case "CREDENTIAL_IN_USE":
451
471
  return "credential_in_use";
472
+ case "LAST_CREDENTIAL":
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";
452
480
  case "NOT_ANONYMOUS":
453
481
  case "FORBIDDEN_NON_ANONYMOUS":
454
482
  return "already_identified";
@@ -1288,6 +1316,56 @@ var Auth = class {
1288
1316
  }
1289
1317
  return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
1290
1318
  }
1319
+ /**
1320
+ * Remove one linked provider from the signed-in account.
1321
+ *
1322
+ * The write half of `listLinkedIdentities()` — what a "Disconnect" button
1323
+ * next to each entry on a Connected Accounts screen calls.
1324
+ *
1325
+ * ⚠ The server REFUSES to remove the last way into the account, with
1326
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
1327
+ * account with no credentials still exists and still holds the user's data,
1328
+ * but nothing can ever authenticate into it again — so this is a permanent
1329
+ * lockout, not an inconvenience. A password counts as a credential.
1330
+ *
1331
+ * Prefer to prevent the tap rather than explain the refusal: read
1332
+ * `listLinkedIdentities()` and disable Disconnect when
1333
+ * `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
1334
+ * backstop, not the UX.
1335
+ *
1336
+ * Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
1337
+ * provider is not linked to this account. Removing an already-absent
1338
+ * provider is NOT treated as success — a silent no-op would let a buggy
1339
+ * screen report "disconnected" for a credential that is still attached.
1340
+ */
1341
+ async unlink(provider) {
1342
+ const token = await this.getAccessToken();
1343
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
1344
+ const res = await del(
1345
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1346
+ `/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
1347
+ { Authorization: `Bearer ${token}` }
1348
+ );
1349
+ if (!res) throw new AuthError("NETWORK_ERROR", "Failed to unlink identity");
1350
+ let parsed;
1351
+ try {
1352
+ parsed = await res.json();
1353
+ } catch {
1354
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
1355
+ }
1356
+ if (!parsed.success || !parsed.data) {
1357
+ throw new AuthError(
1358
+ parsed.error?.code ?? "UNLINK_FAILED",
1359
+ parsed.error?.message ?? "Failed to unlink identity",
1360
+ { status: res.status }
1361
+ );
1362
+ }
1363
+ return {
1364
+ provider: parsed.data.provider ?? provider,
1365
+ removed: Number(parsed.data.removed ?? 0),
1366
+ remaining: Number(parsed.data.remaining ?? 0)
1367
+ };
1368
+ }
1291
1369
  /**
1292
1370
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1293
1371
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -1485,13 +1563,24 @@ var Auth = class {
1485
1563
  const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
1486
1564
  return Boolean(data.confirmed);
1487
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
+ */
1488
1576
  async disableMfa() {
1489
- await post(
1577
+ const res = await post(
1490
1578
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1491
1579
  "/api/v1/auth-service/mfa/disable",
1492
1580
  {},
1493
1581
  this.bearerHeaders()
1494
1582
  );
1583
+ await this.unwrap(res, "MFA_DISABLE_FAILED");
1495
1584
  }
1496
1585
  // ============================================================
1497
1586
  // DEVICE SESSIONS (Bearer-authenticated)
@@ -1506,19 +1595,28 @@ var Auth = class {
1506
1595
  const parsed = await res.json();
1507
1596
  return Array.isArray(parsed.data) ? parsed.data : [];
1508
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
+ */
1509
1604
  async revokeSession(sessionId) {
1510
- await del(
1605
+ const res = await del(
1511
1606
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1512
1607
  `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
1513
1608
  this.bearerHeaders()
1514
1609
  );
1610
+ await this.unwrap(res, "SESSION_REVOKE_FAILED");
1515
1611
  }
1612
+ /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
1516
1613
  async revokeAllSessions() {
1517
- await del(
1614
+ const res = await del(
1518
1615
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1519
1616
  "/api/v1/auth-service/sessions/me",
1520
1617
  this.bearerHeaders()
1521
1618
  );
1619
+ await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
1522
1620
  }
1523
1621
  /**
1524
1622
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
@@ -1684,7 +1782,10 @@ var Auth = class {
1684
1782
  if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1685
1783
  const parsed = await res.json().catch(() => ({}));
1686
1784
  if (!res.ok || !parsed.success) {
1687
- 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
+ });
1688
1789
  }
1689
1790
  return parsed.data;
1690
1791
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.34.0",
3
+ "version": "1.36.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"
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"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.2.16",