@sendoracloud/sdk-react-native 1.29.0 → 1.31.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
@@ -202,7 +202,7 @@ var Storage = class {
202
202
  // package.json
203
203
  var package_default = {
204
204
  name: "@sendoracloud/sdk-react-native",
205
- version: "1.29.0",
205
+ version: "1.31.0",
206
206
  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`).",
207
207
  type: "module",
208
208
  main: "./dist/index.cjs",
@@ -258,7 +258,7 @@ var package_default = {
258
258
  scripts: {
259
259
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
260
260
  typecheck: "tsc --noEmit",
261
- 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"
261
+ 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"
262
262
  },
263
263
  devDependencies: {
264
264
  "@types/react": "^19.2.16",
@@ -435,11 +435,37 @@ var AlreadyIdentifiedError = class extends Error {
435
435
  }
436
436
  };
437
437
  var CredentialInUseError = class extends Error {
438
- constructor(message = "This credential is already linked to a different account") {
438
+ constructor(message = "This credential is already linked to a different account", opts) {
439
439
  super(message);
440
+ /** Always `"CREDENTIAL_IN_USE"`. */
441
+ this.code = "CREDENTIAL_IN_USE";
442
+ /**
443
+ * Always `"credential_in_use"`. Carried so this can be handled in the same
444
+ * `switch (err.kind)` as every other auth failure.
445
+ *
446
+ * NOTE this class deliberately does NOT extend `AuthError` — widening
447
+ * `instanceof AuthError` to include it would silently re-route an upgrading
448
+ * app that checks `AuthError` before this class. The taxonomy fields are
449
+ * duplicated instead.
450
+ */
451
+ this.kind = "credential_in_use";
452
+ /** Always false — a different account owns it; retrying changes nothing. */
453
+ this.retryable = false;
440
454
  this.name = "CredentialInUseError";
455
+ this.provider = opts?.provider;
456
+ this.collision = opts?.collision;
457
+ this.status = opts?.status;
441
458
  }
442
459
  };
460
+ function readCollisionDetails(parsed) {
461
+ const details = parsed?.error?.details;
462
+ if (!details || typeof details !== "object") return {};
463
+ const d = details;
464
+ return {
465
+ provider: typeof d.provider === "string" ? d.provider : void 0,
466
+ collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
467
+ };
468
+ }
443
469
  var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
444
470
  "network",
445
471
  "server",
@@ -671,9 +697,21 @@ var Auth = class {
671
697
  * keyed by the anonymous id — your own users table, an analytics
672
698
  * cache, an audience filter — without needing a webhook receiver.
673
699
  *
674
- * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
675
- * `verifyEmailOtp`, `passkey authentication`, and SSO completion
676
- * (via `consumeSsoFragment`). Returns an unsubscribe function.
700
+ * Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
701
+ * `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
702
+ * `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
703
+ * completion (`consumeSsoFragment`) — because it is fired centrally from
704
+ * `persist()` whenever the response carries `retiredAnonUserId`.
705
+ *
706
+ * ⚠ The two gaming paths are the ones that matter most here, and were
707
+ * missing from this list before 1.31.0. When the presented player identity
708
+ * already belongs to another account, the sign-in ADOPTS that account and
709
+ * the anonymous account on this device is retired — its row is DELETED
710
+ * server-side. This listener is the only client-side signal that happened;
711
+ * the call itself resolves successfully. If you would rather it not happen
712
+ * at all, pass `onCredentialInUse: "reject"`.
713
+ *
714
+ * Returns an unsubscribe function.
677
715
  *
678
716
  * Listeners are best-effort: errors thrown inside them are swallowed
679
717
  * + logged in dev mode. Auth state never depends on listener success.
@@ -971,10 +1009,11 @@ var Auth = class {
971
1009
  loginSocial(input) {
972
1010
  return this.serialize(async () => {
973
1011
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
974
- const { link, ...socialInput } = input;
1012
+ const { link, onCredentialInUse, ...socialInput } = input;
975
1013
  const payload = { ...socialInput };
976
1014
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
977
1015
  if (link) payload.linkAnonymous = true;
1016
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
978
1017
  const res = await post(
979
1018
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
980
1019
  "/api/v1/auth-service/login/social",
@@ -988,6 +1027,12 @@ var Auth = class {
988
1027
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
989
1028
  }
990
1029
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1030
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1031
+ throw new CredentialInUseError(parsed.error.message, {
1032
+ ...readCollisionDetails(parsed),
1033
+ status: res.status
1034
+ });
1035
+ }
991
1036
  throw new AuthError(
992
1037
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
993
1038
  parsed.error?.message ?? "Social login failed",
@@ -1023,26 +1068,24 @@ var Auth = class {
1023
1068
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
1024
1069
  const path = "/api/v1/auth-service/login/game-center";
1025
1070
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
1026
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
1071
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
1027
1072
  }
1028
- const link = payloadOrOpts?.link;
1029
1073
  return this.gameIdentitySignIn(
1030
1074
  path,
1031
1075
  async () => await fetchGameCenterPayloadNative(),
1032
- link
1076
+ payloadOrOpts
1033
1077
  );
1034
1078
  }
1035
1079
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
1036
1080
  const path = "/api/v1/auth-service/login/play-games";
1037
1081
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
1038
1082
  const { serverAuthCode } = payloadOrOpts;
1039
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
1083
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
1040
1084
  }
1041
- const link = payloadOrOpts?.link;
1042
1085
  return this.gameIdentitySignIn(
1043
1086
  path,
1044
1087
  async () => await fetchPlayGamesAuthCodeNative(),
1045
- link
1088
+ payloadOrOpts
1046
1089
  );
1047
1090
  }
1048
1091
  // ============================================================
@@ -1117,12 +1160,21 @@ var Auth = class {
1117
1160
  }
1118
1161
  if (!res.ok || !parsed.success || !parsed.data) {
1119
1162
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
1120
- if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
1163
+ if (code === "CREDENTIAL_IN_USE") {
1164
+ throw new CredentialInUseError(parsed.error?.message, {
1165
+ ...readCollisionDetails(parsed),
1166
+ status: res.status
1167
+ });
1168
+ }
1121
1169
  throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1122
1170
  status: res.status,
1123
1171
  retryAfterSeconds: readRetryAfterSeconds(parsed)
1124
1172
  });
1125
1173
  }
1174
+ if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1175
+ this.persist(parsed.data);
1176
+ return parsed.data.user;
1177
+ }
1126
1178
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
1127
1179
  return parsed.data.user ?? this.user;
1128
1180
  }
@@ -1171,13 +1223,16 @@ var Auth = class {
1171
1223
  * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1172
1224
  * centrally from `retiredAnonUserId`.
1173
1225
  */
1174
- gameIdentitySignIn(path, identityBody, link) {
1226
+ gameIdentitySignIn(path, identityBody, opts) {
1227
+ const link = opts?.link;
1228
+ const onCredentialInUse = opts?.onCredentialInUse;
1175
1229
  return this.serialize(async () => {
1176
1230
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
1177
1231
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1178
1232
  const payload = { ...body };
1179
1233
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1180
1234
  if (link) payload.linkAnonymous = true;
1235
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1181
1236
  const res = await post(
1182
1237
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1183
1238
  path,
@@ -1191,6 +1246,12 @@ var Auth = class {
1191
1246
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1192
1247
  }
1193
1248
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1249
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1250
+ throw new CredentialInUseError(parsed.error.message, {
1251
+ ...readCollisionDetails(parsed),
1252
+ status: res.status
1253
+ });
1254
+ }
1194
1255
  throw new AuthError(
1195
1256
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1196
1257
  parsed.error?.message ?? "Game sign-in failed",
package/dist/index.d.cts CHANGED
@@ -317,8 +317,70 @@ declare class AlreadyIdentifiedError extends Error {
317
317
  * account instead, or link a different credential.
318
318
  */
319
319
  declare class CredentialInUseError extends Error {
320
- constructor(message?: string);
320
+ /** Always `"CREDENTIAL_IN_USE"`. */
321
+ readonly code = "CREDENTIAL_IN_USE";
322
+ /**
323
+ * Always `"credential_in_use"`. Carried so this can be handled in the same
324
+ * `switch (err.kind)` as every other auth failure.
325
+ *
326
+ * NOTE this class deliberately does NOT extend `AuthError` — widening
327
+ * `instanceof AuthError` to include it would silently re-route an upgrading
328
+ * app that checks `AuthError` before this class. The taxonomy fields are
329
+ * duplicated instead.
330
+ */
331
+ readonly kind: "credential_in_use";
332
+ /** Always false — a different account owns it; retrying changes nothing. */
333
+ readonly retryable = false;
334
+ /** The provider whose credential collided, when the server named it. */
335
+ readonly provider?: string;
336
+ /**
337
+ * Which collision this was: `"identity"` = that exact provider identity is
338
+ * already linked to another account; `"email"` = the provider's verified
339
+ * email already belongs to one. Absent on older backends.
340
+ */
341
+ readonly collision?: "identity" | "email";
342
+ readonly status?: number;
343
+ constructor(message?: string, opts?: {
344
+ provider?: string;
345
+ collision?: "identity" | "email";
346
+ status?: number;
347
+ });
321
348
  }
349
+ /**
350
+ * What a sign-in should do when the credential you present already belongs to
351
+ * an account (1.30.0).
352
+ *
353
+ * - `"adopt"` (default — and what every earlier release did unconditionally):
354
+ * sign in to that account. This is the reinstall-recovery path: a Game Center
355
+ * / Play Games player identity survives a reinstall server-side, so the
356
+ * "collision" is usually the SAME person's earlier account. If this device is
357
+ * anonymous, that anonymous account is retired — **its row is deleted** — and
358
+ * `onDeviceTakeover` fires with its id.
359
+ * - `"reject"`: throw `CredentialInUseError` and change nothing at all. Use it
360
+ * when the local anonymous session holds progress you are not willing to
361
+ * trade. Mirrors Firebase `linkWithCredential`
362
+ * (`auth/credential-already-in-use`) and Supabase `linkIdentity`.
363
+ *
364
+ * `"reject"` blocks the switch; it does not merge the two accounts. To offer
365
+ * "use my other account", catch the error and re-call with `"adopt"`, then
366
+ * migrate your own data from `onDeviceTakeover`'s `retiredAnonUserId`.
367
+ */
368
+ type CredentialCollisionPolicy = "adopt" | "reject";
369
+ /** Options for the Game Center / Play Games sign-in methods. */
370
+ type GameSignInOptions = {
371
+ /**
372
+ * ADR-025 link-in-place. When this device is anonymous and the player
373
+ * identity is BRAND NEW, the anonymous account is promoted in place and the
374
+ * user id (sub) is preserved. Has no effect on a collision — see
375
+ * `onCredentialInUse` for that case.
376
+ */
377
+ link?: boolean;
378
+ /**
379
+ * What to do when the player identity already belongs to an account.
380
+ * Default `"adopt"`. See {@link CredentialCollisionPolicy}.
381
+ */
382
+ onCredentialInUse?: CredentialCollisionPolicy;
383
+ };
322
384
  /** One credential linked to an account (ADR-030 read side, s58.270). */
323
385
  type LinkedIdentity = {
324
386
  provider: string;
@@ -521,9 +583,21 @@ declare class Auth {
521
583
  * keyed by the anonymous id — your own users table, an analytics
522
584
  * cache, an audience filter — without needing a webhook receiver.
523
585
  *
524
- * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
525
- * `verifyEmailOtp`, `passkey authentication`, and SSO completion
526
- * (via `consumeSsoFragment`). Returns an unsubscribe function.
586
+ * Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
587
+ * `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
588
+ * `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
589
+ * completion (`consumeSsoFragment`) — because it is fired centrally from
590
+ * `persist()` whenever the response carries `retiredAnonUserId`.
591
+ *
592
+ * ⚠ The two gaming paths are the ones that matter most here, and were
593
+ * missing from this list before 1.31.0. When the presented player identity
594
+ * already belongs to another account, the sign-in ADOPTS that account and
595
+ * the anonymous account on this device is retired — its row is DELETED
596
+ * server-side. This listener is the only client-side signal that happened;
597
+ * the call itself resolves successfully. If you would rather it not happen
598
+ * at all, pass `onCredentialInUse: "reject"`.
599
+ *
600
+ * Returns an unsubscribe function.
527
601
  *
528
602
  * Listeners are best-effort: errors thrown inside them are swallowed
529
603
  * + logged in dev mode. Auth state never depends on listener success.
@@ -690,6 +764,13 @@ declare class Auth {
690
764
  * another account (collision → falls back to takeover/merge).
691
765
  */
692
766
  link?: boolean;
767
+ /**
768
+ * What to do if this social identity (or its verified email) already
769
+ * belongs to an account. Default `"adopt"` = sign in to it, retiring this
770
+ * device's anonymous account. `"reject"` throws `CredentialInUseError` and
771
+ * leaves everything untouched. See {@link CredentialCollisionPolicy}.
772
+ */
773
+ onCredentialInUse?: CredentialCollisionPolicy;
693
774
  }): Promise<AuthUser>;
694
775
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
695
776
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
@@ -723,12 +804,8 @@ declare class Auth {
723
804
  * `link: true` KEEPS the same user id when upgrading an anonymous device
724
805
  * (ADR-025 link-in-place); no effect off-anon or on a collision.
725
806
  */
726
- signInWithGameCenter(opts?: {
727
- link?: boolean;
728
- }): Promise<AuthUser>;
729
- signInWithGameCenter(payload: GameCenterPayload, opts?: {
730
- link?: boolean;
731
- }): Promise<AuthUser>;
807
+ signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
808
+ signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
732
809
  /**
733
810
  * Google Play Games sign-in (email-less, Android-only).
734
811
  *
@@ -744,14 +821,10 @@ declare class Auth {
744
821
  *
745
822
  * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
746
823
  */
747
- signInWithPlayGames(opts?: {
748
- link?: boolean;
749
- }): Promise<AuthUser>;
824
+ signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
750
825
  signInWithPlayGames(payload: {
751
826
  serverAuthCode: string;
752
- }, opts?: {
753
- link?: boolean;
754
- }): Promise<AuthUser>;
827
+ }, opts?: GameSignInOptions): Promise<AuthUser>;
755
828
  /** Link email + password to the current account (sub preserved). */
756
829
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
757
830
  /** Link an OAuth social identity (verified idToken or code) to the current account. */
@@ -1823,4 +1896,4 @@ declare class SendoraSDK {
1823
1896
  }
1824
1897
  declare const SendoraCloud: SendoraSDK;
1825
1898
 
1826
- export { AlreadyIdentifiedError, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, 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 };
1899
+ export { AlreadyIdentifiedError, 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 };
package/dist/index.d.ts CHANGED
@@ -317,8 +317,70 @@ declare class AlreadyIdentifiedError extends Error {
317
317
  * account instead, or link a different credential.
318
318
  */
319
319
  declare class CredentialInUseError extends Error {
320
- constructor(message?: string);
320
+ /** Always `"CREDENTIAL_IN_USE"`. */
321
+ readonly code = "CREDENTIAL_IN_USE";
322
+ /**
323
+ * Always `"credential_in_use"`. Carried so this can be handled in the same
324
+ * `switch (err.kind)` as every other auth failure.
325
+ *
326
+ * NOTE this class deliberately does NOT extend `AuthError` — widening
327
+ * `instanceof AuthError` to include it would silently re-route an upgrading
328
+ * app that checks `AuthError` before this class. The taxonomy fields are
329
+ * duplicated instead.
330
+ */
331
+ readonly kind: "credential_in_use";
332
+ /** Always false — a different account owns it; retrying changes nothing. */
333
+ readonly retryable = false;
334
+ /** The provider whose credential collided, when the server named it. */
335
+ readonly provider?: string;
336
+ /**
337
+ * Which collision this was: `"identity"` = that exact provider identity is
338
+ * already linked to another account; `"email"` = the provider's verified
339
+ * email already belongs to one. Absent on older backends.
340
+ */
341
+ readonly collision?: "identity" | "email";
342
+ readonly status?: number;
343
+ constructor(message?: string, opts?: {
344
+ provider?: string;
345
+ collision?: "identity" | "email";
346
+ status?: number;
347
+ });
321
348
  }
349
+ /**
350
+ * What a sign-in should do when the credential you present already belongs to
351
+ * an account (1.30.0).
352
+ *
353
+ * - `"adopt"` (default — and what every earlier release did unconditionally):
354
+ * sign in to that account. This is the reinstall-recovery path: a Game Center
355
+ * / Play Games player identity survives a reinstall server-side, so the
356
+ * "collision" is usually the SAME person's earlier account. If this device is
357
+ * anonymous, that anonymous account is retired — **its row is deleted** — and
358
+ * `onDeviceTakeover` fires with its id.
359
+ * - `"reject"`: throw `CredentialInUseError` and change nothing at all. Use it
360
+ * when the local anonymous session holds progress you are not willing to
361
+ * trade. Mirrors Firebase `linkWithCredential`
362
+ * (`auth/credential-already-in-use`) and Supabase `linkIdentity`.
363
+ *
364
+ * `"reject"` blocks the switch; it does not merge the two accounts. To offer
365
+ * "use my other account", catch the error and re-call with `"adopt"`, then
366
+ * migrate your own data from `onDeviceTakeover`'s `retiredAnonUserId`.
367
+ */
368
+ type CredentialCollisionPolicy = "adopt" | "reject";
369
+ /** Options for the Game Center / Play Games sign-in methods. */
370
+ type GameSignInOptions = {
371
+ /**
372
+ * ADR-025 link-in-place. When this device is anonymous and the player
373
+ * identity is BRAND NEW, the anonymous account is promoted in place and the
374
+ * user id (sub) is preserved. Has no effect on a collision — see
375
+ * `onCredentialInUse` for that case.
376
+ */
377
+ link?: boolean;
378
+ /**
379
+ * What to do when the player identity already belongs to an account.
380
+ * Default `"adopt"`. See {@link CredentialCollisionPolicy}.
381
+ */
382
+ onCredentialInUse?: CredentialCollisionPolicy;
383
+ };
322
384
  /** One credential linked to an account (ADR-030 read side, s58.270). */
323
385
  type LinkedIdentity = {
324
386
  provider: string;
@@ -521,9 +583,21 @@ declare class Auth {
521
583
  * keyed by the anonymous id — your own users table, an analytics
522
584
  * cache, an audience filter — without needing a webhook receiver.
523
585
  *
524
- * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
525
- * `verifyEmailOtp`, `passkey authentication`, and SSO completion
526
- * (via `consumeSsoFragment`). Returns an unsubscribe function.
586
+ * Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
587
+ * `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
588
+ * `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
589
+ * completion (`consumeSsoFragment`) — because it is fired centrally from
590
+ * `persist()` whenever the response carries `retiredAnonUserId`.
591
+ *
592
+ * ⚠ The two gaming paths are the ones that matter most here, and were
593
+ * missing from this list before 1.31.0. When the presented player identity
594
+ * already belongs to another account, the sign-in ADOPTS that account and
595
+ * the anonymous account on this device is retired — its row is DELETED
596
+ * server-side. This listener is the only client-side signal that happened;
597
+ * the call itself resolves successfully. If you would rather it not happen
598
+ * at all, pass `onCredentialInUse: "reject"`.
599
+ *
600
+ * Returns an unsubscribe function.
527
601
  *
528
602
  * Listeners are best-effort: errors thrown inside them are swallowed
529
603
  * + logged in dev mode. Auth state never depends on listener success.
@@ -690,6 +764,13 @@ declare class Auth {
690
764
  * another account (collision → falls back to takeover/merge).
691
765
  */
692
766
  link?: boolean;
767
+ /**
768
+ * What to do if this social identity (or its verified email) already
769
+ * belongs to an account. Default `"adopt"` = sign in to it, retiring this
770
+ * device's anonymous account. `"reject"` throws `CredentialInUseError` and
771
+ * leaves everything untouched. See {@link CredentialCollisionPolicy}.
772
+ */
773
+ onCredentialInUse?: CredentialCollisionPolicy;
693
774
  }): Promise<AuthUser>;
694
775
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
695
776
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
@@ -723,12 +804,8 @@ declare class Auth {
723
804
  * `link: true` KEEPS the same user id when upgrading an anonymous device
724
805
  * (ADR-025 link-in-place); no effect off-anon or on a collision.
725
806
  */
726
- signInWithGameCenter(opts?: {
727
- link?: boolean;
728
- }): Promise<AuthUser>;
729
- signInWithGameCenter(payload: GameCenterPayload, opts?: {
730
- link?: boolean;
731
- }): Promise<AuthUser>;
807
+ signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
808
+ signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
732
809
  /**
733
810
  * Google Play Games sign-in (email-less, Android-only).
734
811
  *
@@ -744,14 +821,10 @@ declare class Auth {
744
821
  *
745
822
  * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
746
823
  */
747
- signInWithPlayGames(opts?: {
748
- link?: boolean;
749
- }): Promise<AuthUser>;
824
+ signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
750
825
  signInWithPlayGames(payload: {
751
826
  serverAuthCode: string;
752
- }, opts?: {
753
- link?: boolean;
754
- }): Promise<AuthUser>;
827
+ }, opts?: GameSignInOptions): Promise<AuthUser>;
755
828
  /** Link email + password to the current account (sub preserved). */
756
829
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
757
830
  /** Link an OAuth social identity (verified idToken or code) to the current account. */
@@ -1823,4 +1896,4 @@ declare class SendoraSDK {
1823
1896
  }
1824
1897
  declare const SendoraCloud: SendoraSDK;
1825
1898
 
1826
- export { AlreadyIdentifiedError, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, 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 };
1899
+ export { AlreadyIdentifiedError, 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 };
package/dist/index.js CHANGED
@@ -157,7 +157,7 @@ var Storage = class {
157
157
  // package.json
158
158
  var package_default = {
159
159
  name: "@sendoracloud/sdk-react-native",
160
- version: "1.29.0",
160
+ version: "1.31.0",
161
161
  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`).",
162
162
  type: "module",
163
163
  main: "./dist/index.cjs",
@@ -213,7 +213,7 @@ var package_default = {
213
213
  scripts: {
214
214
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
215
215
  typecheck: "tsc --noEmit",
216
- 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"
216
+ 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"
217
217
  },
218
218
  devDependencies: {
219
219
  "@types/react": "^19.2.16",
@@ -390,11 +390,37 @@ var AlreadyIdentifiedError = class extends Error {
390
390
  }
391
391
  };
392
392
  var CredentialInUseError = class extends Error {
393
- constructor(message = "This credential is already linked to a different account") {
393
+ constructor(message = "This credential is already linked to a different account", opts) {
394
394
  super(message);
395
+ /** Always `"CREDENTIAL_IN_USE"`. */
396
+ this.code = "CREDENTIAL_IN_USE";
397
+ /**
398
+ * Always `"credential_in_use"`. Carried so this can be handled in the same
399
+ * `switch (err.kind)` as every other auth failure.
400
+ *
401
+ * NOTE this class deliberately does NOT extend `AuthError` — widening
402
+ * `instanceof AuthError` to include it would silently re-route an upgrading
403
+ * app that checks `AuthError` before this class. The taxonomy fields are
404
+ * duplicated instead.
405
+ */
406
+ this.kind = "credential_in_use";
407
+ /** Always false — a different account owns it; retrying changes nothing. */
408
+ this.retryable = false;
395
409
  this.name = "CredentialInUseError";
410
+ this.provider = opts?.provider;
411
+ this.collision = opts?.collision;
412
+ this.status = opts?.status;
396
413
  }
397
414
  };
415
+ function readCollisionDetails(parsed) {
416
+ const details = parsed?.error?.details;
417
+ if (!details || typeof details !== "object") return {};
418
+ const d = details;
419
+ return {
420
+ provider: typeof d.provider === "string" ? d.provider : void 0,
421
+ collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
422
+ };
423
+ }
398
424
  var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
399
425
  "network",
400
426
  "server",
@@ -626,9 +652,21 @@ var Auth = class {
626
652
  * keyed by the anonymous id — your own users table, an analytics
627
653
  * cache, an audience filter — without needing a webhook receiver.
628
654
  *
629
- * Listener is invoked from `signIn`, `loginSocial`, `verifyMagicLink`,
630
- * `verifyEmailOtp`, `passkey authentication`, and SSO completion
631
- * (via `consumeSsoFragment`). Returns an unsubscribe function.
655
+ * Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
656
+ * `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
657
+ * `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
658
+ * completion (`consumeSsoFragment`) — because it is fired centrally from
659
+ * `persist()` whenever the response carries `retiredAnonUserId`.
660
+ *
661
+ * ⚠ The two gaming paths are the ones that matter most here, and were
662
+ * missing from this list before 1.31.0. When the presented player identity
663
+ * already belongs to another account, the sign-in ADOPTS that account and
664
+ * the anonymous account on this device is retired — its row is DELETED
665
+ * server-side. This listener is the only client-side signal that happened;
666
+ * the call itself resolves successfully. If you would rather it not happen
667
+ * at all, pass `onCredentialInUse: "reject"`.
668
+ *
669
+ * Returns an unsubscribe function.
632
670
  *
633
671
  * Listeners are best-effort: errors thrown inside them are swallowed
634
672
  * + logged in dev mode. Auth state never depends on listener success.
@@ -926,10 +964,11 @@ var Auth = class {
926
964
  loginSocial(input) {
927
965
  return this.serialize(async () => {
928
966
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
929
- const { link, ...socialInput } = input;
967
+ const { link, onCredentialInUse, ...socialInput } = input;
930
968
  const payload = { ...socialInput };
931
969
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
932
970
  if (link) payload.linkAnonymous = true;
971
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
933
972
  const res = await post(
934
973
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
935
974
  "/api/v1/auth-service/login/social",
@@ -943,6 +982,12 @@ var Auth = class {
943
982
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
944
983
  }
945
984
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
985
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
986
+ throw new CredentialInUseError(parsed.error.message, {
987
+ ...readCollisionDetails(parsed),
988
+ status: res.status
989
+ });
990
+ }
946
991
  throw new AuthError(
947
992
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
948
993
  parsed.error?.message ?? "Social login failed",
@@ -978,26 +1023,24 @@ var Auth = class {
978
1023
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
979
1024
  const path = "/api/v1/auth-service/login/game-center";
980
1025
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
981
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
1026
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
982
1027
  }
983
- const link = payloadOrOpts?.link;
984
1028
  return this.gameIdentitySignIn(
985
1029
  path,
986
1030
  async () => await fetchGameCenterPayloadNative(),
987
- link
1031
+ payloadOrOpts
988
1032
  );
989
1033
  }
990
1034
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
991
1035
  const path = "/api/v1/auth-service/login/play-games";
992
1036
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
993
1037
  const { serverAuthCode } = payloadOrOpts;
994
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
1038
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
995
1039
  }
996
- const link = payloadOrOpts?.link;
997
1040
  return this.gameIdentitySignIn(
998
1041
  path,
999
1042
  async () => await fetchPlayGamesAuthCodeNative(),
1000
- link
1043
+ payloadOrOpts
1001
1044
  );
1002
1045
  }
1003
1046
  // ============================================================
@@ -1072,12 +1115,21 @@ var Auth = class {
1072
1115
  }
1073
1116
  if (!res.ok || !parsed.success || !parsed.data) {
1074
1117
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
1075
- if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
1118
+ if (code === "CREDENTIAL_IN_USE") {
1119
+ throw new CredentialInUseError(parsed.error?.message, {
1120
+ ...readCollisionDetails(parsed),
1121
+ status: res.status
1122
+ });
1123
+ }
1076
1124
  throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1077
1125
  status: res.status,
1078
1126
  retryAfterSeconds: readRetryAfterSeconds(parsed)
1079
1127
  });
1080
1128
  }
1129
+ if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1130
+ this.persist(parsed.data);
1131
+ return parsed.data.user;
1132
+ }
1081
1133
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
1082
1134
  return parsed.data.user ?? this.user;
1083
1135
  }
@@ -1126,13 +1178,16 @@ var Auth = class {
1126
1178
  * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1127
1179
  * centrally from `retiredAnonUserId`.
1128
1180
  */
1129
- gameIdentitySignIn(path, identityBody, link) {
1181
+ gameIdentitySignIn(path, identityBody, opts) {
1182
+ const link = opts?.link;
1183
+ const onCredentialInUse = opts?.onCredentialInUse;
1130
1184
  return this.serialize(async () => {
1131
1185
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
1132
1186
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1133
1187
  const payload = { ...body };
1134
1188
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1135
1189
  if (link) payload.linkAnonymous = true;
1190
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1136
1191
  const res = await post(
1137
1192
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1138
1193
  path,
@@ -1146,6 +1201,12 @@ var Auth = class {
1146
1201
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1147
1202
  }
1148
1203
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1204
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1205
+ throw new CredentialInUseError(parsed.error.message, {
1206
+ ...readCollisionDetails(parsed),
1207
+ status: res.status
1208
+ });
1209
+ }
1149
1210
  throw new AuthError(
1150
1211
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1151
1212
  parsed.error?.message ?? "Game sign-in failed",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.29.0",
4
- "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`).",
3
+ "version": "1.31.0",
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",
7
7
  "module": "./dist/index.js",
@@ -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"
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"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.2.16",