@sendoracloud/sdk-react-native 1.20.0 → 1.23.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/app.plugin.js ADDED
@@ -0,0 +1,85 @@
1
+ // Sendora Expo config plugin — auto-wires deep-link associated domains so games
2
+ // (and any RN app) don't hand-edit iOS entitlements / Android manifests.
3
+ //
4
+ // Usage in app.json / app.config.js:
5
+ // { "plugins": [["@sendoracloud/sdk-react-native", { "linkSubdomain": "wordhurdle" }]] }
6
+ // // → applinks:wordhurdle.sendoracloud.app (iOS) + autoVerify App Link (Android)
7
+ // Full host / BYO custom domain (combines with linkSubdomain):
8
+ // [["@sendoracloud/sdk-react-native", { "linkDomains": ["links.wordhurdle.com"] }]]
9
+ //
10
+ // - iOS: adds `applinks:<host>` to com.apple.developer.associated-domains.
11
+ // - Android: adds an `autoVerify` App Links <intent-filter> (https + host) to
12
+ // the main activity.
13
+ //
14
+ // Game Center / Play Games native config is a SEPARATE plugin in the optional
15
+ // companion package `@sendoracloud/sdk-react-native-gaming` (so non-gaming apps
16
+ // don't pull the Play Games dependency).
17
+ //
18
+ // SAFE / backward-compatible: opt-in + build-time only (runs during
19
+ // `expo prebuild`), so it never touches an already-built or already-installed
20
+ // app. Both platforms MERGE + dedupe — an app that already declared its domains
21
+ // (or lists others) keeps them; adopting the plugin re-adds the same entries
22
+ // idempotently. `createRunOncePlugin` guards against double-application.
23
+ //
24
+ // ESM (the package is `type: module`) — requires Expo SDK 50+ (ESM config
25
+ // plugins). The bug-prone transforms live in ./plugin/apply.js (pure,
26
+ // unit-tested); this file just wires them into the Expo mod pipeline.
27
+ import {
28
+ withEntitlementsPlist,
29
+ withAndroidManifest,
30
+ AndroidConfig,
31
+ createRunOncePlugin,
32
+ WarningAggregator,
33
+ } from "@expo/config-plugins";
34
+ import { createRequire } from "node:module";
35
+ import {
36
+ resolveHosts,
37
+ mergeAssociatedDomains,
38
+ findMainActivity,
39
+ addAppLinkIntentFilters,
40
+ } from "./plugin/apply.js";
41
+
42
+ const pkg = createRequire(import.meta.url)("./package.json");
43
+ const ASSOCIATED_DOMAINS_KEY = "com.apple.developer.associated-domains";
44
+
45
+ function withSendoraIosAssociatedDomains(config, hosts) {
46
+ return withEntitlementsPlist(config, (cfg) => {
47
+ cfg.modResults[ASSOCIATED_DOMAINS_KEY] = mergeAssociatedDomains(
48
+ cfg.modResults[ASSOCIATED_DOMAINS_KEY],
49
+ hosts,
50
+ );
51
+ return cfg;
52
+ });
53
+ }
54
+
55
+ function withSendoraAndroidAppLinks(config, hosts) {
56
+ return withAndroidManifest(config, (cfg) => {
57
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
58
+ const main = findMainActivity(app.activity);
59
+ if (!main) {
60
+ WarningAggregator.addWarningAndroid(
61
+ pkg.name,
62
+ "No main activity found — skipped App Links intent filters.",
63
+ );
64
+ return cfg;
65
+ }
66
+ addAppLinkIntentFilters(main, hosts);
67
+ return cfg;
68
+ });
69
+ }
70
+
71
+ const withSendora = (config, props) => {
72
+ const hosts = resolveHosts(props);
73
+ if (hosts.length === 0) {
74
+ WarningAggregator.addWarningIOS(
75
+ pkg.name,
76
+ "No linkSubdomain / linkDomains configured — no associated domains added. Pass { linkSubdomain: '<handle>' } to wire deep links. (Game Center / Play Games config lives in @sendoracloud/sdk-react-native-gaming.)",
77
+ );
78
+ return config;
79
+ }
80
+ config = withSendoraIosAssociatedDomains(config, hosts);
81
+ config = withSendoraAndroidAppLinks(config, hosts);
82
+ return config;
83
+ };
84
+
85
+ export default createRunOncePlugin(withSendora, pkg.name, pkg.version);
package/dist/index.cjs CHANGED
@@ -43,7 +43,7 @@ __export(index_exports, {
43
43
  });
44
44
  module.exports = __toCommonJS(index_exports);
45
45
  var import_react_native_get_random_values = require("react-native-get-random-values");
46
- var import_react_native4 = require("react-native");
46
+ var import_react_native5 = require("react-native");
47
47
 
48
48
  // src/storage.ts
49
49
  var PREFIX = "sendora_";
@@ -200,8 +200,8 @@ var Storage = class {
200
200
  // package.json
201
201
  var package_default = {
202
202
  name: "@sendoracloud/sdk-react-native",
203
- version: "1.20.0",
204
- description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
203
+ version: "1.23.0",
204
+ 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
205
  type: "module",
206
206
  main: "./dist/index.cjs",
207
207
  module: "./dist/index.js",
@@ -222,7 +222,9 @@ var package_default = {
222
222
  "dist",
223
223
  "examples",
224
224
  "README.md",
225
- "LICENSE"
225
+ "LICENSE",
226
+ "app.plugin.js",
227
+ "plugin"
226
228
  ],
227
229
  keywords: [
228
230
  "sendora",
@@ -254,7 +256,7 @@ var package_default = {
254
256
  scripts: {
255
257
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
256
258
  typecheck: "tsc --noEmit",
257
- test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts"
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"
258
260
  },
259
261
  devDependencies: {
260
262
  "@types/react": "^19.2.16",
@@ -367,8 +369,34 @@ async function request(opts, method, path, body, extraHeaders, reqOpts) {
367
369
  return res;
368
370
  }
369
371
 
370
- // src/auth.ts
372
+ // src/native.ts
371
373
  var import_react_native = require("react-native");
374
+ function requireOptionalNativeModule(name) {
375
+ try {
376
+ const g = globalThis;
377
+ const m = g?.expo?.modules?.[name];
378
+ if (m && typeof m === "object") return m;
379
+ } catch {
380
+ }
381
+ try {
382
+ const g = globalThis;
383
+ const nm = import_react_native.NativeModules;
384
+ const proxy = g?.expo?.modules?.NativeModulesProxy ?? nm?.NativeUnimoduleProxy;
385
+ const consts = proxy?.modulesConstants?.[name];
386
+ if (consts && typeof consts === "object") return consts;
387
+ } catch {
388
+ }
389
+ try {
390
+ const nm = import_react_native.NativeModules;
391
+ const m = nm?.[name];
392
+ if (m && typeof m === "object") return m;
393
+ } catch {
394
+ }
395
+ return void 0;
396
+ }
397
+
398
+ // src/auth.ts
399
+ var import_react_native2 = require("react-native");
372
400
  var TOKEN_KEY = "auth_access_token";
373
401
  var TOKEN_EXPIRES_KEY = "auth_access_expires";
374
402
  var REFRESH_KEY = "auth_refresh_token";
@@ -403,6 +431,48 @@ var AuthError = class extends Error {
403
431
  this.code = code;
404
432
  }
405
433
  };
434
+ var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
435
+ async function fetchGameCenterPayloadNative() {
436
+ const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
437
+ const fn = mod?.fetchGameCenterIdentity;
438
+ if (typeof fn !== "function") {
439
+ throw new AuthError(
440
+ "GAME_CENTER_UNAVAILABLE",
441
+ "Game Center native module not found. Install @sendoracloud/sdk-react-native-gaming, add its config plugin ({ gameCenter: true }), and run `npx expo prebuild` (or EAS Build) \u2014 Game Center sign-in needs a Dev Client, not Expo Go. Or pass a payload from your own GameKit module."
442
+ );
443
+ }
444
+ const raw = await fn.call(mod);
445
+ return coerceGameCenterPayload(raw);
446
+ }
447
+ function coerceGameCenterPayload(raw) {
448
+ const r = raw ?? {};
449
+ const publicKeyUrl = typeof r.publicKeyUrl === "string" ? r.publicKeyUrl : "";
450
+ const signature = typeof r.signature === "string" ? r.signature : "";
451
+ const salt = typeof r.salt === "string" ? r.salt : "";
452
+ const teamPlayerId = typeof r.teamPlayerId === "string" ? r.teamPlayerId : "";
453
+ const bundleId = typeof r.bundleId === "string" ? r.bundleId : "";
454
+ const timestamp = typeof r.timestamp === "number" ? r.timestamp : Number(r.timestamp);
455
+ if (!publicKeyUrl || !signature || !salt || !teamPlayerId || !bundleId || !Number.isFinite(timestamp)) {
456
+ throw new AuthError("GAME_CENTER_INVALID_PAYLOAD", "Game Center returned an incomplete identity payload.");
457
+ }
458
+ return { publicKeyUrl, signature, salt, timestamp, teamPlayerId, bundleId };
459
+ }
460
+ async function fetchPlayGamesAuthCodeNative() {
461
+ const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
462
+ const fn = mod?.fetchServerAuthCode;
463
+ if (typeof fn !== "function") {
464
+ throw new AuthError(
465
+ "PLAY_GAMES_UNAVAILABLE",
466
+ "Play Games native module not found. Install @sendoracloud/sdk-react-native-gaming, add its config plugin ({ playGamesWebClientId, playGamesProjectId }), and run `npx expo prebuild` (or EAS Build) \u2014 Play Games sign-in needs a Dev Client, not Expo Go. Or pass a serverAuthCode from your own Play Games module."
467
+ );
468
+ }
469
+ const raw = await fn.call(mod);
470
+ const serverAuthCode = typeof raw?.serverAuthCode === "string" ? raw.serverAuthCode : "";
471
+ if (!serverAuthCode) {
472
+ throw new AuthError("PLAY_GAMES_INVALID_PAYLOAD", "Play Games returned no server auth code.");
473
+ }
474
+ return { serverAuthCode };
475
+ }
406
476
  var Auth = class {
407
477
  constructor(hooks) {
408
478
  this.user = null;
@@ -752,6 +822,72 @@ var Auth = class {
752
822
  signInWithDiscord(code, redirectUri) {
753
823
  return this.loginSocial({ provider: "discord", code, redirectUri });
754
824
  }
825
+ signInWithGameCenter(payloadOrOpts, maybeOpts) {
826
+ const path = "/api/v1/auth-service/login/game-center";
827
+ if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
828
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
829
+ }
830
+ const link = payloadOrOpts?.link;
831
+ return this.gameIdentitySignIn(
832
+ path,
833
+ async () => await fetchGameCenterPayloadNative(),
834
+ link
835
+ );
836
+ }
837
+ signInWithPlayGames(payloadOrOpts, maybeOpts) {
838
+ const path = "/api/v1/auth-service/login/play-games";
839
+ if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
840
+ const { serverAuthCode } = payloadOrOpts;
841
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
842
+ }
843
+ const link = payloadOrOpts?.link;
844
+ return this.gameIdentitySignIn(
845
+ path,
846
+ async () => await fetchPlayGamesAuthCodeNative(),
847
+ link
848
+ );
849
+ }
850
+ /**
851
+ * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
852
+ * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
853
+ * identity, forwards it as `prevAnonRefreshToken`, and opts into
854
+ * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
855
+ * centrally from `retiredAnonUserId`.
856
+ */
857
+ gameIdentitySignIn(path, identityBody, link) {
858
+ return this.serialize(async () => {
859
+ const body = typeof identityBody === "function" ? await identityBody() : identityBody;
860
+ let prevAnonRefreshToken;
861
+ if (this.user?.isAnonymous) {
862
+ const stashed = this.hooks.storage.get(REFRESH_KEY);
863
+ if (stashed) prevAnonRefreshToken = stashed;
864
+ }
865
+ if (this.user !== null) await this.wipeLocalIdentity();
866
+ const payload = { ...body };
867
+ if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
868
+ if (link) payload.linkAnonymous = true;
869
+ const res = await post(
870
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
871
+ path,
872
+ payload
873
+ );
874
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
875
+ let parsed;
876
+ try {
877
+ parsed = await res.json();
878
+ } catch {
879
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
880
+ }
881
+ if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
882
+ throw new AuthError(
883
+ parsed.error?.code ?? "GAME_LOGIN_FAILED",
884
+ parsed.error?.message ?? "Game sign-in failed"
885
+ );
886
+ }
887
+ this.persist(parsed.data);
888
+ return parsed.data.user;
889
+ });
890
+ }
755
891
  /**
756
892
  * Read the stored anon refresh token if (and only if) the local
757
893
  * subject is currently anonymous. Used by every identified-session
@@ -1231,7 +1367,7 @@ var Auth = class {
1231
1367
  void tick();
1232
1368
  }, TICK_MS);
1233
1369
  try {
1234
- const subscription = import_react_native.AppState?.addEventListener?.("change", (state) => {
1370
+ const subscription = import_react_native2.AppState?.addEventListener?.("change", (state) => {
1235
1371
  if (state === "active") {
1236
1372
  this.resetRefreshState();
1237
1373
  void tick();
@@ -1392,7 +1528,7 @@ function decodeJwtPayload(token) {
1392
1528
  }
1393
1529
 
1394
1530
  // src/links.ts
1395
- var import_react_native2 = require("react-native");
1531
+ var import_react_native3 = require("react-native");
1396
1532
  var LinkError = class extends Error {
1397
1533
  constructor(code, message, statusCode = 0, details) {
1398
1534
  super(message);
@@ -1471,10 +1607,10 @@ function dynRequire(id) {
1471
1607
  }
1472
1608
  function loadRn() {
1473
1609
  return {
1474
- Platform: import_react_native2.Platform,
1475
- Dimensions: import_react_native2.Dimensions,
1476
- NativeModules: import_react_native2.NativeModules,
1477
- Linking: import_react_native2.Linking
1610
+ Platform: import_react_native3.Platform,
1611
+ Dimensions: import_react_native3.Dimensions,
1612
+ NativeModules: import_react_native3.NativeModules,
1613
+ Linking: import_react_native3.Linking
1478
1614
  };
1479
1615
  }
1480
1616
  function readTimezone() {
@@ -1970,7 +2106,7 @@ var Links = class {
1970
2106
  };
1971
2107
 
1972
2108
  // src/auto-track.ts
1973
- var import_react_native3 = require("react-native");
2109
+ var import_react_native4 = require("react-native");
1974
2110
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1975
2111
  function resolveFlags(cfg) {
1976
2112
  if (cfg === false) {
@@ -2015,7 +2151,7 @@ function installAutoTrack(args) {
2015
2151
  args.fire("app.opened", { sessionId });
2016
2152
  }
2017
2153
  if (flags.appBackground || flags.engagement) {
2018
- const AppState = import_react_native3.AppState;
2154
+ const AppState = import_react_native4.AppState;
2019
2155
  if (AppState && typeof AppState.addEventListener === "function") {
2020
2156
  const handler = (state) => {
2021
2157
  ensureSession(true);
@@ -2114,13 +2250,13 @@ function byteLength(s) {
2114
2250
  }
2115
2251
  function buildDeviceContext(appVersion, appBuild) {
2116
2252
  try {
2117
- const os = import_react_native4.Platform?.OS;
2253
+ const os = import_react_native5.Platform?.OS;
2118
2254
  if (os !== "ios" && os !== "android") return void 0;
2119
- const isPad = os === "ios" && import_react_native4.Platform.isPad === true;
2255
+ const isPad = os === "ios" && import_react_native5.Platform.isPad === true;
2120
2256
  return {
2121
2257
  type: isPad ? "tablet" : "mobile",
2122
2258
  os: os === "ios" ? "iOS" : "Android",
2123
- osVersion: String(import_react_native4.Platform.Version ?? ""),
2259
+ osVersion: String(import_react_native5.Platform.Version ?? ""),
2124
2260
  model: expoDeviceModel(),
2125
2261
  appVersion: resolveAppVersion(appVersion),
2126
2262
  appBuild: resolveAppBuild(appBuild)
@@ -2130,27 +2266,7 @@ function buildDeviceContext(appVersion, appBuild) {
2130
2266
  }
2131
2267
  }
2132
2268
  function expoModule(name) {
2133
- try {
2134
- const g = globalThis;
2135
- const m = g?.expo?.modules?.[name];
2136
- if (m && typeof m === "object") return m;
2137
- } catch {
2138
- }
2139
- try {
2140
- const g = globalThis;
2141
- const nm = import_react_native4.NativeModules;
2142
- const proxy = g?.expo?.modules?.NativeModulesProxy ?? nm?.NativeUnimoduleProxy;
2143
- const consts = proxy?.modulesConstants?.[name];
2144
- if (consts && typeof consts === "object") return consts;
2145
- } catch {
2146
- }
2147
- try {
2148
- const nm = import_react_native4.NativeModules;
2149
- const m = nm?.[name];
2150
- if (m && typeof m === "object") return m;
2151
- } catch {
2152
- }
2153
- return void 0;
2269
+ return requireOptionalNativeModule(name);
2154
2270
  }
2155
2271
  function expoApplicationVersion() {
2156
2272
  const a = expoModule("ExpoApplication");
package/dist/index.d.cts CHANGED
@@ -261,6 +261,22 @@ interface AuthTokens {
261
261
  expiresIn: number;
262
262
  tokenType: string;
263
263
  }
264
+ /**
265
+ * Apple Game Center identity payload — the exact shape the Sendora backend
266
+ * verifies, produced by `GKLocalPlayer.local.fetchItems(forIdentityVerification-
267
+ * Signature:)`. `signature`/`salt` are base64; `timestamp` is GameKit's
268
+ * ms-since-epoch. The bundled native module returns this from
269
+ * `fetchGameCenterIdentity()`; you can also build it from your own GameKit
270
+ * module and pass it to `signInWithGameCenter(payload)`.
271
+ */
272
+ interface GameCenterPayload {
273
+ publicKeyUrl: string;
274
+ signature: string;
275
+ salt: string;
276
+ timestamp: number;
277
+ teamPlayerId: string;
278
+ bundleId: string;
279
+ }
264
280
  declare class EmailAlreadyTakenError extends Error {
265
281
  constructor(message?: string);
266
282
  }
@@ -504,6 +520,58 @@ declare class Auth {
504
520
  signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
505
521
  signInWithFacebook(code: string, redirectUri: string): Promise<AuthUser>;
506
522
  signInWithDiscord(code: string, redirectUri: string): Promise<AuthUser>;
523
+ /**
524
+ * Apple Game Center sign-in (email-less, iOS-only).
525
+ *
526
+ * **No-arg (1.23.0, recommended):** `signInWithGameCenter()` drives Game
527
+ * Center natively via the `@sendoracloud/sdk-react-native-gaming` companion
528
+ * (`fetchItems(forIdentityVerificationSignature:)`, base64 signature/salt,
529
+ * `teamPlayerID`, bundle id) and posts the result. Needs that companion + its
530
+ * config plugin `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
531
+ *
532
+ * **Explicit:** `signInWithGameCenter(payload)` posts a payload you obtained
533
+ * from your OWN GameKit module (works anywhere, incl. bare RN).
534
+ *
535
+ * `link: true` KEEPS the same user id when upgrading an anonymous device
536
+ * (ADR-025 link-in-place); no effect off-anon or on a collision.
537
+ */
538
+ signInWithGameCenter(opts?: {
539
+ link?: boolean;
540
+ }): Promise<AuthUser>;
541
+ signInWithGameCenter(payload: GameCenterPayload, opts?: {
542
+ link?: boolean;
543
+ }): Promise<AuthUser>;
544
+ /**
545
+ * Google Play Games sign-in (email-less, Android-only).
546
+ *
547
+ * **No-arg (1.23.0, recommended):** `signInWithPlayGames()` drives Play Games
548
+ * natively via the `@sendoracloud/sdk-react-native-gaming` companion
549
+ * (`requestServerSideAccess(webClientId, false)`, web client id read from the
550
+ * config-plugin manifest meta-data) and posts the `serverAuthCode`. Needs that
551
+ * companion + its plugin `{ playGamesWebClientId, playGamesProjectId }` + a Dev
552
+ * Client — NOT Expo Go.
553
+ *
554
+ * **Explicit:** `signInWithPlayGames({ serverAuthCode })` posts a code you
555
+ * obtained from your OWN Play Games module.
556
+ *
557
+ * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
558
+ */
559
+ signInWithPlayGames(opts?: {
560
+ link?: boolean;
561
+ }): Promise<AuthUser>;
562
+ signInWithPlayGames(payload: {
563
+ serverAuthCode: string;
564
+ }, opts?: {
565
+ link?: boolean;
566
+ }): Promise<AuthUser>;
567
+ /**
568
+ * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
569
+ * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
570
+ * identity, forwards it as `prevAnonRefreshToken`, and opts into
571
+ * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
572
+ * centrally from `retiredAnonUserId`.
573
+ */
574
+ private gameIdentitySignIn;
507
575
  /**
508
576
  * Read the stored anon refresh token if (and only if) the local
509
577
  * subject is currently anonymous. Used by every identified-session
package/dist/index.d.ts CHANGED
@@ -261,6 +261,22 @@ interface AuthTokens {
261
261
  expiresIn: number;
262
262
  tokenType: string;
263
263
  }
264
+ /**
265
+ * Apple Game Center identity payload — the exact shape the Sendora backend
266
+ * verifies, produced by `GKLocalPlayer.local.fetchItems(forIdentityVerification-
267
+ * Signature:)`. `signature`/`salt` are base64; `timestamp` is GameKit's
268
+ * ms-since-epoch. The bundled native module returns this from
269
+ * `fetchGameCenterIdentity()`; you can also build it from your own GameKit
270
+ * module and pass it to `signInWithGameCenter(payload)`.
271
+ */
272
+ interface GameCenterPayload {
273
+ publicKeyUrl: string;
274
+ signature: string;
275
+ salt: string;
276
+ timestamp: number;
277
+ teamPlayerId: string;
278
+ bundleId: string;
279
+ }
264
280
  declare class EmailAlreadyTakenError extends Error {
265
281
  constructor(message?: string);
266
282
  }
@@ -504,6 +520,58 @@ declare class Auth {
504
520
  signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
505
521
  signInWithFacebook(code: string, redirectUri: string): Promise<AuthUser>;
506
522
  signInWithDiscord(code: string, redirectUri: string): Promise<AuthUser>;
523
+ /**
524
+ * Apple Game Center sign-in (email-less, iOS-only).
525
+ *
526
+ * **No-arg (1.23.0, recommended):** `signInWithGameCenter()` drives Game
527
+ * Center natively via the `@sendoracloud/sdk-react-native-gaming` companion
528
+ * (`fetchItems(forIdentityVerificationSignature:)`, base64 signature/salt,
529
+ * `teamPlayerID`, bundle id) and posts the result. Needs that companion + its
530
+ * config plugin `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
531
+ *
532
+ * **Explicit:** `signInWithGameCenter(payload)` posts a payload you obtained
533
+ * from your OWN GameKit module (works anywhere, incl. bare RN).
534
+ *
535
+ * `link: true` KEEPS the same user id when upgrading an anonymous device
536
+ * (ADR-025 link-in-place); no effect off-anon or on a collision.
537
+ */
538
+ signInWithGameCenter(opts?: {
539
+ link?: boolean;
540
+ }): Promise<AuthUser>;
541
+ signInWithGameCenter(payload: GameCenterPayload, opts?: {
542
+ link?: boolean;
543
+ }): Promise<AuthUser>;
544
+ /**
545
+ * Google Play Games sign-in (email-less, Android-only).
546
+ *
547
+ * **No-arg (1.23.0, recommended):** `signInWithPlayGames()` drives Play Games
548
+ * natively via the `@sendoracloud/sdk-react-native-gaming` companion
549
+ * (`requestServerSideAccess(webClientId, false)`, web client id read from the
550
+ * config-plugin manifest meta-data) and posts the `serverAuthCode`. Needs that
551
+ * companion + its plugin `{ playGamesWebClientId, playGamesProjectId }` + a Dev
552
+ * Client — NOT Expo Go.
553
+ *
554
+ * **Explicit:** `signInWithPlayGames({ serverAuthCode })` posts a code you
555
+ * obtained from your OWN Play Games module.
556
+ *
557
+ * `link: true` = ADR-025 link-in-place (same user id on an anon upgrade).
558
+ */
559
+ signInWithPlayGames(opts?: {
560
+ link?: boolean;
561
+ }): Promise<AuthUser>;
562
+ signInWithPlayGames(payload: {
563
+ serverAuthCode: string;
564
+ }, opts?: {
565
+ link?: boolean;
566
+ }): Promise<AuthUser>;
567
+ /**
568
+ * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
569
+ * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
570
+ * identity, forwards it as `prevAnonRefreshToken`, and opts into
571
+ * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
572
+ * centrally from `retiredAnonUserId`.
573
+ */
574
+ private gameIdentitySignIn;
507
575
  /**
508
576
  * Read the stored anon refresh token if (and only if) the local
509
577
  * subject is currently anonymous. Used by every identified-session
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import "react-native-get-random-values";
3
- import { Platform, NativeModules } from "react-native";
3
+ import { Platform } from "react-native";
4
4
 
5
5
  // src/storage.ts
6
6
  var PREFIX = "sendora_";
@@ -157,8 +157,8 @@ var Storage = class {
157
157
  // package.json
158
158
  var package_default = {
159
159
  name: "@sendoracloud/sdk-react-native",
160
- version: "1.20.0",
161
- description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
160
+ version: "1.23.0",
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",
164
164
  module: "./dist/index.js",
@@ -179,7 +179,9 @@ var package_default = {
179
179
  "dist",
180
180
  "examples",
181
181
  "README.md",
182
- "LICENSE"
182
+ "LICENSE",
183
+ "app.plugin.js",
184
+ "plugin"
183
185
  ],
184
186
  keywords: [
185
187
  "sendora",
@@ -211,7 +213,7 @@ var package_default = {
211
213
  scripts: {
212
214
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
213
215
  typecheck: "tsc --noEmit",
214
- test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.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"
215
217
  },
216
218
  devDependencies: {
217
219
  "@types/react": "^19.2.16",
@@ -324,6 +326,32 @@ async function request(opts, method, path, body, extraHeaders, reqOpts) {
324
326
  return res;
325
327
  }
326
328
 
329
+ // src/native.ts
330
+ import { NativeModules } from "react-native";
331
+ function requireOptionalNativeModule(name) {
332
+ try {
333
+ const g = globalThis;
334
+ const m = g?.expo?.modules?.[name];
335
+ if (m && typeof m === "object") return m;
336
+ } catch {
337
+ }
338
+ try {
339
+ const g = globalThis;
340
+ const nm = NativeModules;
341
+ const proxy = g?.expo?.modules?.NativeModulesProxy ?? nm?.NativeUnimoduleProxy;
342
+ const consts = proxy?.modulesConstants?.[name];
343
+ if (consts && typeof consts === "object") return consts;
344
+ } catch {
345
+ }
346
+ try {
347
+ const nm = NativeModules;
348
+ const m = nm?.[name];
349
+ if (m && typeof m === "object") return m;
350
+ } catch {
351
+ }
352
+ return void 0;
353
+ }
354
+
327
355
  // src/auth.ts
328
356
  import { AppState as RnAppState } from "react-native";
329
357
  var TOKEN_KEY = "auth_access_token";
@@ -360,6 +388,48 @@ var AuthError = class extends Error {
360
388
  this.code = code;
361
389
  }
362
390
  };
391
+ var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
392
+ async function fetchGameCenterPayloadNative() {
393
+ const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
394
+ const fn = mod?.fetchGameCenterIdentity;
395
+ if (typeof fn !== "function") {
396
+ throw new AuthError(
397
+ "GAME_CENTER_UNAVAILABLE",
398
+ "Game Center native module not found. Install @sendoracloud/sdk-react-native-gaming, add its config plugin ({ gameCenter: true }), and run `npx expo prebuild` (or EAS Build) \u2014 Game Center sign-in needs a Dev Client, not Expo Go. Or pass a payload from your own GameKit module."
399
+ );
400
+ }
401
+ const raw = await fn.call(mod);
402
+ return coerceGameCenterPayload(raw);
403
+ }
404
+ function coerceGameCenterPayload(raw) {
405
+ const r = raw ?? {};
406
+ const publicKeyUrl = typeof r.publicKeyUrl === "string" ? r.publicKeyUrl : "";
407
+ const signature = typeof r.signature === "string" ? r.signature : "";
408
+ const salt = typeof r.salt === "string" ? r.salt : "";
409
+ const teamPlayerId = typeof r.teamPlayerId === "string" ? r.teamPlayerId : "";
410
+ const bundleId = typeof r.bundleId === "string" ? r.bundleId : "";
411
+ const timestamp = typeof r.timestamp === "number" ? r.timestamp : Number(r.timestamp);
412
+ if (!publicKeyUrl || !signature || !salt || !teamPlayerId || !bundleId || !Number.isFinite(timestamp)) {
413
+ throw new AuthError("GAME_CENTER_INVALID_PAYLOAD", "Game Center returned an incomplete identity payload.");
414
+ }
415
+ return { publicKeyUrl, signature, salt, timestamp, teamPlayerId, bundleId };
416
+ }
417
+ async function fetchPlayGamesAuthCodeNative() {
418
+ const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
419
+ const fn = mod?.fetchServerAuthCode;
420
+ if (typeof fn !== "function") {
421
+ throw new AuthError(
422
+ "PLAY_GAMES_UNAVAILABLE",
423
+ "Play Games native module not found. Install @sendoracloud/sdk-react-native-gaming, add its config plugin ({ playGamesWebClientId, playGamesProjectId }), and run `npx expo prebuild` (or EAS Build) \u2014 Play Games sign-in needs a Dev Client, not Expo Go. Or pass a serverAuthCode from your own Play Games module."
424
+ );
425
+ }
426
+ const raw = await fn.call(mod);
427
+ const serverAuthCode = typeof raw?.serverAuthCode === "string" ? raw.serverAuthCode : "";
428
+ if (!serverAuthCode) {
429
+ throw new AuthError("PLAY_GAMES_INVALID_PAYLOAD", "Play Games returned no server auth code.");
430
+ }
431
+ return { serverAuthCode };
432
+ }
363
433
  var Auth = class {
364
434
  constructor(hooks) {
365
435
  this.user = null;
@@ -709,6 +779,72 @@ var Auth = class {
709
779
  signInWithDiscord(code, redirectUri) {
710
780
  return this.loginSocial({ provider: "discord", code, redirectUri });
711
781
  }
782
+ signInWithGameCenter(payloadOrOpts, maybeOpts) {
783
+ const path = "/api/v1/auth-service/login/game-center";
784
+ if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
785
+ return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts?.link);
786
+ }
787
+ const link = payloadOrOpts?.link;
788
+ return this.gameIdentitySignIn(
789
+ path,
790
+ async () => await fetchGameCenterPayloadNative(),
791
+ link
792
+ );
793
+ }
794
+ signInWithPlayGames(payloadOrOpts, maybeOpts) {
795
+ const path = "/api/v1/auth-service/login/play-games";
796
+ if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
797
+ const { serverAuthCode } = payloadOrOpts;
798
+ return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts?.link);
799
+ }
800
+ const link = payloadOrOpts?.link;
801
+ return this.gameIdentitySignIn(
802
+ path,
803
+ async () => await fetchPlayGamesAuthCodeNative(),
804
+ link
805
+ );
806
+ }
807
+ /**
808
+ * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
809
+ * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
810
+ * identity, forwards it as `prevAnonRefreshToken`, and opts into
811
+ * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
812
+ * centrally from `retiredAnonUserId`.
813
+ */
814
+ gameIdentitySignIn(path, identityBody, link) {
815
+ return this.serialize(async () => {
816
+ const body = typeof identityBody === "function" ? await identityBody() : identityBody;
817
+ let prevAnonRefreshToken;
818
+ if (this.user?.isAnonymous) {
819
+ const stashed = this.hooks.storage.get(REFRESH_KEY);
820
+ if (stashed) prevAnonRefreshToken = stashed;
821
+ }
822
+ if (this.user !== null) await this.wipeLocalIdentity();
823
+ const payload = { ...body };
824
+ if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
825
+ if (link) payload.linkAnonymous = true;
826
+ const res = await post(
827
+ { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
828
+ path,
829
+ payload
830
+ );
831
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
832
+ let parsed;
833
+ try {
834
+ parsed = await res.json();
835
+ } catch {
836
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
837
+ }
838
+ if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
839
+ throw new AuthError(
840
+ parsed.error?.code ?? "GAME_LOGIN_FAILED",
841
+ parsed.error?.message ?? "Game sign-in failed"
842
+ );
843
+ }
844
+ this.persist(parsed.data);
845
+ return parsed.data.user;
846
+ });
847
+ }
712
848
  /**
713
849
  * Read the stored anon refresh token if (and only if) the local
714
850
  * subject is currently anonymous. Used by every identified-session
@@ -2092,27 +2228,7 @@ function buildDeviceContext(appVersion, appBuild) {
2092
2228
  }
2093
2229
  }
2094
2230
  function expoModule(name) {
2095
- try {
2096
- const g = globalThis;
2097
- const m = g?.expo?.modules?.[name];
2098
- if (m && typeof m === "object") return m;
2099
- } catch {
2100
- }
2101
- try {
2102
- const g = globalThis;
2103
- const nm = NativeModules;
2104
- const proxy = g?.expo?.modules?.NativeModulesProxy ?? nm?.NativeUnimoduleProxy;
2105
- const consts = proxy?.modulesConstants?.[name];
2106
- if (consts && typeof consts === "object") return consts;
2107
- } catch {
2108
- }
2109
- try {
2110
- const nm = NativeModules;
2111
- const m = nm?.[name];
2112
- if (m && typeof m === "object") return m;
2113
- } catch {
2114
- }
2115
- return void 0;
2231
+ return requireOptionalNativeModule(name);
2116
2232
  }
2117
2233
  function expoApplicationVersion() {
2118
2234
  const a = expoModule("ExpoApplication");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.20.0",
4
- "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
3
+ "version": "1.23.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",
@@ -22,7 +22,9 @@
22
22
  "dist",
23
23
  "examples",
24
24
  "README.md",
25
- "LICENSE"
25
+ "LICENSE",
26
+ "app.plugin.js",
27
+ "plugin"
26
28
  ],
27
29
  "keywords": [
28
30
  "sendora",
@@ -54,7 +56,7 @@
54
56
  "scripts": {
55
57
  "build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
56
58
  "typecheck": "tsc --noEmit",
57
- "test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.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"
58
60
  },
59
61
  "devDependencies": {
60
62
  "@types/react": "^19.2.16",
@@ -0,0 +1,73 @@
1
+ // Pure, dependency-free helpers for the Sendora Expo config plugin
2
+ // (../app.plugin.js). Extracted so the bug-prone logic (host resolution,
3
+ // merge/dedupe, no-clobber) is unit-testable WITHOUT `@expo/config-plugins`,
4
+ // which only exists in the consumer's Expo prebuild environment.
5
+ //
6
+ // ESM — the package is `type: module`.
7
+
8
+ /** Resolve the deduped host list from the plugin props. */
9
+ export function resolveHosts(props) {
10
+ const hosts = new Set();
11
+ if (props && typeof props.linkSubdomain === "string" && props.linkSubdomain.trim()) {
12
+ hosts.add(`${props.linkSubdomain.trim()}.sendoracloud.app`);
13
+ }
14
+ if (props && Array.isArray(props.linkDomains)) {
15
+ for (const d of props.linkDomains) {
16
+ if (typeof d === "string" && d.trim()) hosts.add(d.trim());
17
+ }
18
+ }
19
+ return Array.from(hosts);
20
+ }
21
+
22
+ /**
23
+ * Merge `applinks:<host>` entries into an existing associated-domains array,
24
+ * preserving anything already there (no clobber) and deduping.
25
+ */
26
+ export function mergeAssociatedDomains(existing, hosts) {
27
+ const base = Array.isArray(existing) ? existing : [];
28
+ const applinks = hosts.map((h) => `applinks:${h}`);
29
+ return Array.from(new Set([...base, ...applinks]));
30
+ }
31
+
32
+ /** Pick the launcher/main activity from an AndroidManifest application node. */
33
+ export function findMainActivity(activities) {
34
+ const list = Array.isArray(activities) ? activities : [];
35
+ return (
36
+ list.find(
37
+ (a) =>
38
+ (a.$ && a.$["android:name"] === ".MainActivity") ||
39
+ (a["intent-filter"] || []).some((f) =>
40
+ (f.action || []).some(
41
+ (ac) => ac.$ && ac.$["android:name"] === "android.intent.action.MAIN",
42
+ ),
43
+ ),
44
+ ) ||
45
+ list[0] ||
46
+ null
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Add an autoVerify App Links `<intent-filter>` (https + host) to the activity
52
+ * for each host, skipping hosts already present. Mutates + returns the activity.
53
+ */
54
+ export function addAppLinkIntentFilters(mainActivity, hosts) {
55
+ if (!mainActivity) return mainActivity;
56
+ mainActivity["intent-filter"] = mainActivity["intent-filter"] || [];
57
+ for (const host of hosts) {
58
+ const already = mainActivity["intent-filter"].some((f) =>
59
+ (f.data || []).some((d) => d.$ && d.$["android:host"] === host),
60
+ );
61
+ if (already) continue;
62
+ mainActivity["intent-filter"].push({
63
+ $: { "android:autoVerify": "true" },
64
+ action: [{ $: { "android:name": "android.intent.action.VIEW" } }],
65
+ category: [
66
+ { $: { "android:name": "android.intent.category.DEFAULT" } },
67
+ { $: { "android:name": "android.intent.category.BROWSABLE" } },
68
+ ],
69
+ data: [{ $: { "android:scheme": "https", "android:host": host } }],
70
+ });
71
+ }
72
+ return mainActivity;
73
+ }