@sendoracloud/sdk-react-native 1.29.0 → 1.30.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.30.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",
@@ -971,10 +997,11 @@ var Auth = class {
971
997
  loginSocial(input) {
972
998
  return this.serialize(async () => {
973
999
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
974
- const { link, ...socialInput } = input;
1000
+ const { link, onCredentialInUse, ...socialInput } = input;
975
1001
  const payload = { ...socialInput };
976
1002
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
977
1003
  if (link) payload.linkAnonymous = true;
1004
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
978
1005
  const res = await post(
979
1006
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
980
1007
  "/api/v1/auth-service/login/social",
@@ -988,6 +1015,12 @@ var Auth = class {
988
1015
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
989
1016
  }
990
1017
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1018
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1019
+ throw new CredentialInUseError(parsed.error.message, {
1020
+ ...readCollisionDetails(parsed),
1021
+ status: res.status
1022
+ });
1023
+ }
991
1024
  throw new AuthError(
992
1025
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
993
1026
  parsed.error?.message ?? "Social login failed",
@@ -1023,26 +1056,24 @@ var Auth = class {
1023
1056
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
1024
1057
  const path = "/api/v1/auth-service/login/game-center";
1025
1058
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
1026
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
1059
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
1027
1060
  }
1028
- const link = payloadOrOpts?.link;
1029
1061
  return this.gameIdentitySignIn(
1030
1062
  path,
1031
1063
  async () => await fetchGameCenterPayloadNative(),
1032
- link
1064
+ payloadOrOpts
1033
1065
  );
1034
1066
  }
1035
1067
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
1036
1068
  const path = "/api/v1/auth-service/login/play-games";
1037
1069
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
1038
1070
  const { serverAuthCode } = payloadOrOpts;
1039
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
1071
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
1040
1072
  }
1041
- const link = payloadOrOpts?.link;
1042
1073
  return this.gameIdentitySignIn(
1043
1074
  path,
1044
1075
  async () => await fetchPlayGamesAuthCodeNative(),
1045
- link
1076
+ payloadOrOpts
1046
1077
  );
1047
1078
  }
1048
1079
  // ============================================================
@@ -1117,12 +1148,21 @@ var Auth = class {
1117
1148
  }
1118
1149
  if (!res.ok || !parsed.success || !parsed.data) {
1119
1150
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
1120
- if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
1151
+ if (code === "CREDENTIAL_IN_USE") {
1152
+ throw new CredentialInUseError(parsed.error?.message, {
1153
+ ...readCollisionDetails(parsed),
1154
+ status: res.status
1155
+ });
1156
+ }
1121
1157
  throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1122
1158
  status: res.status,
1123
1159
  retryAfterSeconds: readRetryAfterSeconds(parsed)
1124
1160
  });
1125
1161
  }
1162
+ if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1163
+ this.persist(parsed.data);
1164
+ return parsed.data.user;
1165
+ }
1126
1166
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
1127
1167
  return parsed.data.user ?? this.user;
1128
1168
  }
@@ -1171,13 +1211,16 @@ var Auth = class {
1171
1211
  * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1172
1212
  * centrally from `retiredAnonUserId`.
1173
1213
  */
1174
- gameIdentitySignIn(path, identityBody, link) {
1214
+ gameIdentitySignIn(path, identityBody, opts) {
1215
+ const link = opts?.link;
1216
+ const onCredentialInUse = opts?.onCredentialInUse;
1175
1217
  return this.serialize(async () => {
1176
1218
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
1177
1219
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1178
1220
  const payload = { ...body };
1179
1221
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1180
1222
  if (link) payload.linkAnonymous = true;
1223
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1181
1224
  const res = await post(
1182
1225
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1183
1226
  path,
@@ -1191,6 +1234,12 @@ var Auth = class {
1191
1234
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1192
1235
  }
1193
1236
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1237
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1238
+ throw new CredentialInUseError(parsed.error.message, {
1239
+ ...readCollisionDetails(parsed),
1240
+ status: res.status
1241
+ });
1242
+ }
1194
1243
  throw new AuthError(
1195
1244
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1196
1245
  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;
@@ -690,6 +752,13 @@ declare class Auth {
690
752
  * another account (collision → falls back to takeover/merge).
691
753
  */
692
754
  link?: boolean;
755
+ /**
756
+ * What to do if this social identity (or its verified email) already
757
+ * belongs to an account. Default `"adopt"` = sign in to it, retiring this
758
+ * device's anonymous account. `"reject"` throws `CredentialInUseError` and
759
+ * leaves everything untouched. See {@link CredentialCollisionPolicy}.
760
+ */
761
+ onCredentialInUse?: CredentialCollisionPolicy;
693
762
  }): Promise<AuthUser>;
694
763
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
695
764
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
@@ -723,12 +792,8 @@ declare class Auth {
723
792
  * `link: true` KEEPS the same user id when upgrading an anonymous device
724
793
  * (ADR-025 link-in-place); no effect off-anon or on a collision.
725
794
  */
726
- signInWithGameCenter(opts?: {
727
- link?: boolean;
728
- }): Promise<AuthUser>;
729
- signInWithGameCenter(payload: GameCenterPayload, opts?: {
730
- link?: boolean;
731
- }): Promise<AuthUser>;
795
+ signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
796
+ signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
732
797
  /**
733
798
  * Google Play Games sign-in (email-less, Android-only).
734
799
  *
@@ -744,14 +809,10 @@ declare class Auth {
744
809
  *
745
810
  * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
746
811
  */
747
- signInWithPlayGames(opts?: {
748
- link?: boolean;
749
- }): Promise<AuthUser>;
812
+ signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
750
813
  signInWithPlayGames(payload: {
751
814
  serverAuthCode: string;
752
- }, opts?: {
753
- link?: boolean;
754
- }): Promise<AuthUser>;
815
+ }, opts?: GameSignInOptions): Promise<AuthUser>;
755
816
  /** Link email + password to the current account (sub preserved). */
756
817
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
757
818
  /** Link an OAuth social identity (verified idToken or code) to the current account. */
@@ -1823,4 +1884,4 @@ declare class SendoraSDK {
1823
1884
  }
1824
1885
  declare const SendoraCloud: SendoraSDK;
1825
1886
 
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 };
1887
+ 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;
@@ -690,6 +752,13 @@ declare class Auth {
690
752
  * another account (collision → falls back to takeover/merge).
691
753
  */
692
754
  link?: boolean;
755
+ /**
756
+ * What to do if this social identity (or its verified email) already
757
+ * belongs to an account. Default `"adopt"` = sign in to it, retiring this
758
+ * device's anonymous account. `"reject"` throws `CredentialInUseError` and
759
+ * leaves everything untouched. See {@link CredentialCollisionPolicy}.
760
+ */
761
+ onCredentialInUse?: CredentialCollisionPolicy;
693
762
  }): Promise<AuthUser>;
694
763
  signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
695
764
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
@@ -723,12 +792,8 @@ declare class Auth {
723
792
  * `link: true` KEEPS the same user id when upgrading an anonymous device
724
793
  * (ADR-025 link-in-place); no effect off-anon or on a collision.
725
794
  */
726
- signInWithGameCenter(opts?: {
727
- link?: boolean;
728
- }): Promise<AuthUser>;
729
- signInWithGameCenter(payload: GameCenterPayload, opts?: {
730
- link?: boolean;
731
- }): Promise<AuthUser>;
795
+ signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
796
+ signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
732
797
  /**
733
798
  * Google Play Games sign-in (email-less, Android-only).
734
799
  *
@@ -744,14 +809,10 @@ declare class Auth {
744
809
  *
745
810
  * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
746
811
  */
747
- signInWithPlayGames(opts?: {
748
- link?: boolean;
749
- }): Promise<AuthUser>;
812
+ signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
750
813
  signInWithPlayGames(payload: {
751
814
  serverAuthCode: string;
752
- }, opts?: {
753
- link?: boolean;
754
- }): Promise<AuthUser>;
815
+ }, opts?: GameSignInOptions): Promise<AuthUser>;
755
816
  /** Link email + password to the current account (sub preserved). */
756
817
  linkEmailPassword(email: string, password: string): Promise<AuthUser>;
757
818
  /** Link an OAuth social identity (verified idToken or code) to the current account. */
@@ -1823,4 +1884,4 @@ declare class SendoraSDK {
1823
1884
  }
1824
1885
  declare const SendoraCloud: SendoraSDK;
1825
1886
 
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 };
1887
+ 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.30.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",
@@ -926,10 +952,11 @@ var Auth = class {
926
952
  loginSocial(input) {
927
953
  return this.serialize(async () => {
928
954
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
929
- const { link, ...socialInput } = input;
955
+ const { link, onCredentialInUse, ...socialInput } = input;
930
956
  const payload = { ...socialInput };
931
957
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
932
958
  if (link) payload.linkAnonymous = true;
959
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
933
960
  const res = await post(
934
961
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
935
962
  "/api/v1/auth-service/login/social",
@@ -943,6 +970,12 @@ var Auth = class {
943
970
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
944
971
  }
945
972
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
973
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
974
+ throw new CredentialInUseError(parsed.error.message, {
975
+ ...readCollisionDetails(parsed),
976
+ status: res.status
977
+ });
978
+ }
946
979
  throw new AuthError(
947
980
  parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
948
981
  parsed.error?.message ?? "Social login failed",
@@ -978,26 +1011,24 @@ var Auth = class {
978
1011
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
979
1012
  const path = "/api/v1/auth-service/login/game-center";
980
1013
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
981
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
1014
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
982
1015
  }
983
- const link = payloadOrOpts?.link;
984
1016
  return this.gameIdentitySignIn(
985
1017
  path,
986
1018
  async () => await fetchGameCenterPayloadNative(),
987
- link
1019
+ payloadOrOpts
988
1020
  );
989
1021
  }
990
1022
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
991
1023
  const path = "/api/v1/auth-service/login/play-games";
992
1024
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
993
1025
  const { serverAuthCode } = payloadOrOpts;
994
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
1026
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
995
1027
  }
996
- const link = payloadOrOpts?.link;
997
1028
  return this.gameIdentitySignIn(
998
1029
  path,
999
1030
  async () => await fetchPlayGamesAuthCodeNative(),
1000
- link
1031
+ payloadOrOpts
1001
1032
  );
1002
1033
  }
1003
1034
  // ============================================================
@@ -1072,12 +1103,21 @@ var Auth = class {
1072
1103
  }
1073
1104
  if (!res.ok || !parsed.success || !parsed.data) {
1074
1105
  const code = parsed.error?.code ?? `HTTP_${res.status}`;
1075
- if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
1106
+ if (code === "CREDENTIAL_IN_USE") {
1107
+ throw new CredentialInUseError(parsed.error?.message, {
1108
+ ...readCollisionDetails(parsed),
1109
+ status: res.status
1110
+ });
1111
+ }
1076
1112
  throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1077
1113
  status: res.status,
1078
1114
  retryAfterSeconds: readRetryAfterSeconds(parsed)
1079
1115
  });
1080
1116
  }
1117
+ if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1118
+ this.persist(parsed.data);
1119
+ return parsed.data.user;
1120
+ }
1081
1121
  if (parsed.data.user) this.updateLocalUser(parsed.data.user);
1082
1122
  return parsed.data.user ?? this.user;
1083
1123
  }
@@ -1126,13 +1166,16 @@ var Auth = class {
1126
1166
  * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1127
1167
  * centrally from `retiredAnonUserId`.
1128
1168
  */
1129
- gameIdentitySignIn(path, identityBody, link) {
1169
+ gameIdentitySignIn(path, identityBody, opts) {
1170
+ const link = opts?.link;
1171
+ const onCredentialInUse = opts?.onCredentialInUse;
1130
1172
  return this.serialize(async () => {
1131
1173
  const body = typeof identityBody === "function" ? await identityBody() : identityBody;
1132
1174
  const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1133
1175
  const payload = { ...body };
1134
1176
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1135
1177
  if (link) payload.linkAnonymous = true;
1178
+ if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1136
1179
  const res = await post(
1137
1180
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1138
1181
  path,
@@ -1146,6 +1189,12 @@ var Auth = class {
1146
1189
  throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1147
1190
  }
1148
1191
  if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1192
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1193
+ throw new CredentialInUseError(parsed.error.message, {
1194
+ ...readCollisionDetails(parsed),
1195
+ status: res.status
1196
+ });
1197
+ }
1149
1198
  throw new AuthError(
1150
1199
  parsed.error?.code ?? "GAME_LOGIN_FAILED",
1151
1200
  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.30.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",