@sendoracloud/sdk-react-native 1.24.0 → 1.25.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
@@ -30,8 +30,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AlreadyIdentifiedError: () => AlreadyIdentifiedError,
33
34
  Auth: () => Auth,
34
35
  AuthError: () => AuthError,
36
+ CredentialInUseError: () => CredentialInUseError,
35
37
  EmailAlreadyTakenError: () => EmailAlreadyTakenError,
36
38
  LinkError: () => LinkError,
37
39
  Links: () => Links,
@@ -200,7 +202,7 @@ var Storage = class {
200
202
  // package.json
201
203
  var package_default = {
202
204
  name: "@sendoracloud/sdk-react-native",
203
- version: "1.24.0",
205
+ version: "1.25.0",
204
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`).",
205
207
  type: "module",
206
208
  main: "./dist/index.cjs",
@@ -256,7 +258,7 @@ var package_default = {
256
258
  scripts: {
257
259
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
258
260
  typecheck: "tsc --noEmit",
259
- test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.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"
260
262
  },
261
263
  devDependencies: {
262
264
  "@types/react": "^19.2.16",
@@ -426,6 +428,18 @@ var EmailAlreadyTakenError = class extends Error {
426
428
  this.name = "EmailAlreadyTakenError";
427
429
  }
428
430
  };
431
+ var AlreadyIdentifiedError = class extends Error {
432
+ constructor(message = "Already signed in with an identity; use link*() to add a credential, or sign out first") {
433
+ super(message);
434
+ this.name = "AlreadyIdentifiedError";
435
+ }
436
+ };
437
+ var CredentialInUseError = class extends Error {
438
+ constructor(message = "This credential is already linked to a different account") {
439
+ super(message);
440
+ this.name = "CredentialInUseError";
441
+ }
442
+ };
429
443
  var AuthError = class extends Error {
430
444
  constructor(code, message) {
431
445
  super(message);
@@ -696,10 +710,13 @@ var Auth = class {
696
710
  this.persist(data);
697
711
  return data.user;
698
712
  } catch (err) {
713
+ if (err instanceof AuthError && (err.code === "NOT_ANONYMOUS" || err.code === "FORBIDDEN_NON_ANONYMOUS")) {
714
+ throw new AlreadyIdentifiedError(err.message);
715
+ }
699
716
  if (err instanceof AuthError && (err.code === "CONFLICT" || err.code === "EMAIL_ALREADY_TAKEN")) {
700
717
  throw new EmailAlreadyTakenError(err.message);
701
718
  }
702
- if (err instanceof AuthError && (err.code === "FORBIDDEN_NON_ANONYMOUS" || err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
719
+ if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
703
720
  await this.wipeLocalIdentity();
704
721
  } else {
705
722
  throw err;
@@ -849,6 +866,92 @@ var Auth = class {
849
866
  link
850
867
  );
851
868
  }
869
+ // ============================================================
870
+ // IDENTITY LINKING (ADR-030) — attach a SECOND credential to the
871
+ // CURRENT signed-in account, preserving the same user id (sub).
872
+ // ============================================================
873
+ //
874
+ // Unlike signUp() / loginSocial({link}) — which only preserve the sub from
875
+ // an ANONYMOUS session — these operate on an ALREADY-IDENTIFIED account.
876
+ // No token rotation (the session is unchanged); they refresh the cached user
877
+ // object in place. Require a signed-in user (throws NOT_SIGNED_IN otherwise).
878
+ //
879
+ // Primary use: ONE account across platforms. A Game Center player on iOS
880
+ // links email/Google; on Android they sign in with that same credential and
881
+ // land on the SAME sub, progress intact. Collision (the credential already
882
+ // belongs to another account) → CredentialInUseError (never merges).
883
+ /** Link email + password to the current account (sub preserved). */
884
+ linkEmailPassword(email, password) {
885
+ return this.linkCredential("/api/v1/auth-service/me/link/email", { email, password });
886
+ }
887
+ /** Link an OAuth social identity (verified idToken or code) to the current account. */
888
+ linkSocial(input) {
889
+ return this.linkCredential("/api/v1/auth-service/me/link/social", input);
890
+ }
891
+ /** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
892
+ linkGoogle(input) {
893
+ return this.linkSocial({ provider: "google", ...input });
894
+ }
895
+ /** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
896
+ linkApple(input) {
897
+ return this.linkSocial({ provider: "apple", ...input });
898
+ }
899
+ /**
900
+ * Link an Apple Game Center identity to the current account. No-arg drives the
901
+ * native GameKit fetch (needs `@sendoracloud/sdk-react-native-gaming`); or pass
902
+ * an explicit payload obtained from your own GameKit bridge.
903
+ */
904
+ async linkGameCenter(payload) {
905
+ const body = payload ?? await fetchGameCenterPayloadNative();
906
+ return this.linkCredential("/api/v1/auth-service/me/link/game-center", { ...body });
907
+ }
908
+ /**
909
+ * Link a Google Play Games identity to the current account. No-arg drives the
910
+ * native Play Games fetch; or pass an explicit `{ serverAuthCode }`.
911
+ */
912
+ async linkPlayGames(payload) {
913
+ const body = payload ?? await fetchPlayGamesAuthCodeNative();
914
+ return this.linkCredential("/api/v1/auth-service/me/link/play-games", { ...body });
915
+ }
916
+ /**
917
+ * Shared link executor. POSTs the verified credential with the caller's
918
+ * end-user Bearer token, then refreshes the cached user object IN PLACE (no
919
+ * token rotation — the sub is unchanged). Deliberately NOT wrapped in
920
+ * serialize(): getAccessToken() serializes internally, so re-entering would
921
+ * deadlock — same non-serialized posture as deleteAccount().
922
+ */
923
+ async linkCredential(path, body) {
924
+ const token = await this.getAccessToken();
925
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
926
+ const res = await post(
927
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
928
+ path,
929
+ body,
930
+ { Authorization: `Bearer ${token}` }
931
+ );
932
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
933
+ let parsed;
934
+ try {
935
+ parsed = await res.json();
936
+ } catch {
937
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
938
+ }
939
+ if (!res.ok || !parsed.success || !parsed.data) {
940
+ const code = parsed.error?.code ?? `HTTP_${res.status}`;
941
+ if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
942
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential");
943
+ }
944
+ if (parsed.data.user) this.updateLocalUser(parsed.data.user);
945
+ return parsed.data.user ?? this.user;
946
+ }
947
+ /**
948
+ * Refresh the cached user after a link — the sub is unchanged, so only the
949
+ * user object + its storage copy are updated (no token/identity rotation).
950
+ */
951
+ updateLocalUser(user) {
952
+ this.user = user;
953
+ this.hooks.storage.set(USER_KEY, JSON.stringify(user));
954
+ }
852
955
  /**
853
956
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
854
957
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -3214,8 +3317,10 @@ var SendoraCloud = new SendoraSDK();
3214
3317
  var index_default = SendoraCloud;
3215
3318
  // Annotate the CommonJS export names for ESM import in node:
3216
3319
  0 && (module.exports = {
3320
+ AlreadyIdentifiedError,
3217
3321
  Auth,
3218
3322
  AuthError,
3323
+ CredentialInUseError,
3219
3324
  EmailAlreadyTakenError,
3220
3325
  LinkError,
3221
3326
  Links,
package/dist/index.d.cts CHANGED
@@ -292,6 +292,24 @@ interface GameCenterPayload {
292
292
  declare class EmailAlreadyTakenError extends Error {
293
293
  constructor(message?: string);
294
294
  }
295
+ /**
296
+ * `signUp()` was called on a session that is ALREADY signed in with an
297
+ * identity (not anonymous). Creating a second account would orphan the
298
+ * current one — use `linkEmailPassword()` / `linkGoogle()` / … (ADR-030) to
299
+ * add the credential to THIS account, or sign out first. Distinct from
300
+ * `EmailAlreadyTakenError`: the email isn't the problem, the live session is.
301
+ */
302
+ declare class AlreadyIdentifiedError extends Error {
303
+ constructor(message?: string);
304
+ }
305
+ /**
306
+ * A credential you tried to LINK (ADR-030) is already attached to a DIFFERENT
307
+ * account. Sendora never auto-merges two real accounts — sign in to that
308
+ * account instead, or link a different credential.
309
+ */
310
+ declare class CredentialInUseError extends Error {
311
+ constructor(message?: string);
312
+ }
295
313
  declare class AuthError extends Error {
296
314
  code: string;
297
315
  constructor(code: string, message: string);
@@ -576,6 +594,62 @@ declare class Auth {
576
594
  }, opts?: {
577
595
  link?: boolean;
578
596
  }): Promise<AuthUser>;
597
+ /** Link email + password to the current account (sub preserved). */
598
+ linkEmailPassword(email: string, password: string): Promise<AuthUser>;
599
+ /** Link an OAuth social identity (verified idToken or code) to the current account. */
600
+ linkSocial(input: {
601
+ provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
602
+ code?: string;
603
+ idToken?: string;
604
+ redirectUri?: string;
605
+ codeVerifier?: string;
606
+ appleName?: {
607
+ firstName?: string;
608
+ lastName?: string;
609
+ };
610
+ }): Promise<AuthUser>;
611
+ /** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
612
+ linkGoogle(input: {
613
+ idToken?: string;
614
+ code?: string;
615
+ redirectUri?: string;
616
+ }): Promise<AuthUser>;
617
+ /** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
618
+ linkApple(input: {
619
+ idToken?: string;
620
+ code?: string;
621
+ redirectUri?: string;
622
+ appleName?: {
623
+ firstName?: string;
624
+ lastName?: string;
625
+ };
626
+ }): Promise<AuthUser>;
627
+ /**
628
+ * Link an Apple Game Center identity to the current account. No-arg drives the
629
+ * native GameKit fetch (needs `@sendoracloud/sdk-react-native-gaming`); or pass
630
+ * an explicit payload obtained from your own GameKit bridge.
631
+ */
632
+ linkGameCenter(payload?: GameCenterPayload): Promise<AuthUser>;
633
+ /**
634
+ * Link a Google Play Games identity to the current account. No-arg drives the
635
+ * native Play Games fetch; or pass an explicit `{ serverAuthCode }`.
636
+ */
637
+ linkPlayGames(payload?: {
638
+ serverAuthCode: string;
639
+ }): Promise<AuthUser>;
640
+ /**
641
+ * Shared link executor. POSTs the verified credential with the caller's
642
+ * end-user Bearer token, then refreshes the cached user object IN PLACE (no
643
+ * token rotation — the sub is unchanged). Deliberately NOT wrapped in
644
+ * serialize(): getAccessToken() serializes internally, so re-entering would
645
+ * deadlock — same non-serialized posture as deleteAccount().
646
+ */
647
+ private linkCredential;
648
+ /**
649
+ * Refresh the cached user after a link — the sub is unchanged, so only the
650
+ * user object + its storage copy are updated (no token/identity rotation).
651
+ */
652
+ private updateLocalUser;
579
653
  /**
580
654
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
581
655
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -1551,4 +1625,4 @@ declare class SendoraSDK {
1551
1625
  }
1552
1626
  declare const SendoraCloud: SendoraSDK;
1553
1627
 
1554
- export { type AttachLinkingApiOptions, Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
1628
+ export { AlreadyIdentifiedError, type AttachLinkingApiOptions, Auth, AuthError, type AuthTokens, type AuthUser, CredentialInUseError, EmailAlreadyTakenError, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
package/dist/index.d.ts CHANGED
@@ -292,6 +292,24 @@ interface GameCenterPayload {
292
292
  declare class EmailAlreadyTakenError extends Error {
293
293
  constructor(message?: string);
294
294
  }
295
+ /**
296
+ * `signUp()` was called on a session that is ALREADY signed in with an
297
+ * identity (not anonymous). Creating a second account would orphan the
298
+ * current one — use `linkEmailPassword()` / `linkGoogle()` / … (ADR-030) to
299
+ * add the credential to THIS account, or sign out first. Distinct from
300
+ * `EmailAlreadyTakenError`: the email isn't the problem, the live session is.
301
+ */
302
+ declare class AlreadyIdentifiedError extends Error {
303
+ constructor(message?: string);
304
+ }
305
+ /**
306
+ * A credential you tried to LINK (ADR-030) is already attached to a DIFFERENT
307
+ * account. Sendora never auto-merges two real accounts — sign in to that
308
+ * account instead, or link a different credential.
309
+ */
310
+ declare class CredentialInUseError extends Error {
311
+ constructor(message?: string);
312
+ }
295
313
  declare class AuthError extends Error {
296
314
  code: string;
297
315
  constructor(code: string, message: string);
@@ -576,6 +594,62 @@ declare class Auth {
576
594
  }, opts?: {
577
595
  link?: boolean;
578
596
  }): Promise<AuthUser>;
597
+ /** Link email + password to the current account (sub preserved). */
598
+ linkEmailPassword(email: string, password: string): Promise<AuthUser>;
599
+ /** Link an OAuth social identity (verified idToken or code) to the current account. */
600
+ linkSocial(input: {
601
+ provider: "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
602
+ code?: string;
603
+ idToken?: string;
604
+ redirectUri?: string;
605
+ codeVerifier?: string;
606
+ appleName?: {
607
+ firstName?: string;
608
+ lastName?: string;
609
+ };
610
+ }): Promise<AuthUser>;
611
+ /** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
612
+ linkGoogle(input: {
613
+ idToken?: string;
614
+ code?: string;
615
+ redirectUri?: string;
616
+ }): Promise<AuthUser>;
617
+ /** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
618
+ linkApple(input: {
619
+ idToken?: string;
620
+ code?: string;
621
+ redirectUri?: string;
622
+ appleName?: {
623
+ firstName?: string;
624
+ lastName?: string;
625
+ };
626
+ }): Promise<AuthUser>;
627
+ /**
628
+ * Link an Apple Game Center identity to the current account. No-arg drives the
629
+ * native GameKit fetch (needs `@sendoracloud/sdk-react-native-gaming`); or pass
630
+ * an explicit payload obtained from your own GameKit bridge.
631
+ */
632
+ linkGameCenter(payload?: GameCenterPayload): Promise<AuthUser>;
633
+ /**
634
+ * Link a Google Play Games identity to the current account. No-arg drives the
635
+ * native Play Games fetch; or pass an explicit `{ serverAuthCode }`.
636
+ */
637
+ linkPlayGames(payload?: {
638
+ serverAuthCode: string;
639
+ }): Promise<AuthUser>;
640
+ /**
641
+ * Shared link executor. POSTs the verified credential with the caller's
642
+ * end-user Bearer token, then refreshes the cached user object IN PLACE (no
643
+ * token rotation — the sub is unchanged). Deliberately NOT wrapped in
644
+ * serialize(): getAccessToken() serializes internally, so re-entering would
645
+ * deadlock — same non-serialized posture as deleteAccount().
646
+ */
647
+ private linkCredential;
648
+ /**
649
+ * Refresh the cached user after a link — the sub is unchanged, so only the
650
+ * user object + its storage copy are updated (no token/identity rotation).
651
+ */
652
+ private updateLocalUser;
579
653
  /**
580
654
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
581
655
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -1551,4 +1625,4 @@ declare class SendoraSDK {
1551
1625
  }
1552
1626
  declare const SendoraCloud: SendoraSDK;
1553
1627
 
1554
- export { type AttachLinkingApiOptions, Auth, AuthError, type AuthTokens, type AuthUser, EmailAlreadyTakenError, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
1628
+ export { AlreadyIdentifiedError, type AttachLinkingApiOptions, Auth, AuthError, type AuthTokens, type AuthUser, CredentialInUseError, EmailAlreadyTakenError, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, 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.24.0",
160
+ version: "1.25.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"
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"
217
217
  },
218
218
  devDependencies: {
219
219
  "@types/react": "^19.2.16",
@@ -383,6 +383,18 @@ var EmailAlreadyTakenError = class extends Error {
383
383
  this.name = "EmailAlreadyTakenError";
384
384
  }
385
385
  };
386
+ var AlreadyIdentifiedError = class extends Error {
387
+ constructor(message = "Already signed in with an identity; use link*() to add a credential, or sign out first") {
388
+ super(message);
389
+ this.name = "AlreadyIdentifiedError";
390
+ }
391
+ };
392
+ var CredentialInUseError = class extends Error {
393
+ constructor(message = "This credential is already linked to a different account") {
394
+ super(message);
395
+ this.name = "CredentialInUseError";
396
+ }
397
+ };
386
398
  var AuthError = class extends Error {
387
399
  constructor(code, message) {
388
400
  super(message);
@@ -653,10 +665,13 @@ var Auth = class {
653
665
  this.persist(data);
654
666
  return data.user;
655
667
  } catch (err) {
668
+ if (err instanceof AuthError && (err.code === "NOT_ANONYMOUS" || err.code === "FORBIDDEN_NON_ANONYMOUS")) {
669
+ throw new AlreadyIdentifiedError(err.message);
670
+ }
656
671
  if (err instanceof AuthError && (err.code === "CONFLICT" || err.code === "EMAIL_ALREADY_TAKEN")) {
657
672
  throw new EmailAlreadyTakenError(err.message);
658
673
  }
659
- if (err instanceof AuthError && (err.code === "FORBIDDEN_NON_ANONYMOUS" || err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
674
+ if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
660
675
  await this.wipeLocalIdentity();
661
676
  } else {
662
677
  throw err;
@@ -806,6 +821,92 @@ var Auth = class {
806
821
  link
807
822
  );
808
823
  }
824
+ // ============================================================
825
+ // IDENTITY LINKING (ADR-030) — attach a SECOND credential to the
826
+ // CURRENT signed-in account, preserving the same user id (sub).
827
+ // ============================================================
828
+ //
829
+ // Unlike signUp() / loginSocial({link}) — which only preserve the sub from
830
+ // an ANONYMOUS session — these operate on an ALREADY-IDENTIFIED account.
831
+ // No token rotation (the session is unchanged); they refresh the cached user
832
+ // object in place. Require a signed-in user (throws NOT_SIGNED_IN otherwise).
833
+ //
834
+ // Primary use: ONE account across platforms. A Game Center player on iOS
835
+ // links email/Google; on Android they sign in with that same credential and
836
+ // land on the SAME sub, progress intact. Collision (the credential already
837
+ // belongs to another account) → CredentialInUseError (never merges).
838
+ /** Link email + password to the current account (sub preserved). */
839
+ linkEmailPassword(email, password) {
840
+ return this.linkCredential("/api/v1/auth-service/me/link/email", { email, password });
841
+ }
842
+ /** Link an OAuth social identity (verified idToken or code) to the current account. */
843
+ linkSocial(input) {
844
+ return this.linkCredential("/api/v1/auth-service/me/link/social", input);
845
+ }
846
+ /** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
847
+ linkGoogle(input) {
848
+ return this.linkSocial({ provider: "google", ...input });
849
+ }
850
+ /** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
851
+ linkApple(input) {
852
+ return this.linkSocial({ provider: "apple", ...input });
853
+ }
854
+ /**
855
+ * Link an Apple Game Center identity to the current account. No-arg drives the
856
+ * native GameKit fetch (needs `@sendoracloud/sdk-react-native-gaming`); or pass
857
+ * an explicit payload obtained from your own GameKit bridge.
858
+ */
859
+ async linkGameCenter(payload) {
860
+ const body = payload ?? await fetchGameCenterPayloadNative();
861
+ return this.linkCredential("/api/v1/auth-service/me/link/game-center", { ...body });
862
+ }
863
+ /**
864
+ * Link a Google Play Games identity to the current account. No-arg drives the
865
+ * native Play Games fetch; or pass an explicit `{ serverAuthCode }`.
866
+ */
867
+ async linkPlayGames(payload) {
868
+ const body = payload ?? await fetchPlayGamesAuthCodeNative();
869
+ return this.linkCredential("/api/v1/auth-service/me/link/play-games", { ...body });
870
+ }
871
+ /**
872
+ * Shared link executor. POSTs the verified credential with the caller's
873
+ * end-user Bearer token, then refreshes the cached user object IN PLACE (no
874
+ * token rotation — the sub is unchanged). Deliberately NOT wrapped in
875
+ * serialize(): getAccessToken() serializes internally, so re-entering would
876
+ * deadlock — same non-serialized posture as deleteAccount().
877
+ */
878
+ async linkCredential(path, body) {
879
+ const token = await this.getAccessToken();
880
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
881
+ const res = await post(
882
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
883
+ path,
884
+ body,
885
+ { Authorization: `Bearer ${token}` }
886
+ );
887
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
888
+ let parsed;
889
+ try {
890
+ parsed = await res.json();
891
+ } catch {
892
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
893
+ }
894
+ if (!res.ok || !parsed.success || !parsed.data) {
895
+ const code = parsed.error?.code ?? `HTTP_${res.status}`;
896
+ if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
897
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential");
898
+ }
899
+ if (parsed.data.user) this.updateLocalUser(parsed.data.user);
900
+ return parsed.data.user ?? this.user;
901
+ }
902
+ /**
903
+ * Refresh the cached user after a link — the sub is unchanged, so only the
904
+ * user object + its storage copy are updated (no token/identity rotation).
905
+ */
906
+ updateLocalUser(user) {
907
+ this.user = user;
908
+ this.hooks.storage.set(USER_KEY, JSON.stringify(user));
909
+ }
809
910
  /**
810
911
  * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
811
912
  * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
@@ -3175,8 +3276,10 @@ var SendoraSDK = class {
3175
3276
  var SendoraCloud = new SendoraSDK();
3176
3277
  var index_default = SendoraCloud;
3177
3278
  export {
3279
+ AlreadyIdentifiedError,
3178
3280
  Auth,
3179
3281
  AuthError,
3282
+ CredentialInUseError,
3180
3283
  EmailAlreadyTakenError,
3181
3284
  LinkError,
3182
3285
  Links,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.24.0",
3
+ "version": "1.25.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"
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"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/react": "^19.2.16",