@sendoracloud/sdk-react-native 1.19.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.19.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;
@@ -705,8 +775,10 @@ var Auth = class {
705
775
  if (stashed) prevAnonRefreshToken = stashed;
706
776
  }
707
777
  if (this.user !== null) await this.wipeLocalIdentity();
708
- const payload = { ...input };
778
+ const { link, ...socialInput } = input;
779
+ const payload = { ...socialInput };
709
780
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
781
+ if (link) payload.linkAnonymous = true;
710
782
  const res = await post(
711
783
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
712
784
  "/api/v1/auth-service/login/social",
@@ -729,8 +801,8 @@ var Auth = class {
729
801
  return parsed.data.user;
730
802
  });
731
803
  }
732
- signInWithGoogle(code, redirectUri) {
733
- return this.loginSocial({ provider: "google", code, redirectUri });
804
+ signInWithGoogle(code, redirectUri, link) {
805
+ return this.loginSocial({ provider: "google", code, redirectUri, link });
734
806
  }
735
807
  signInWithGitHub(code, redirectUri) {
736
808
  return this.loginSocial({ provider: "github", code, redirectUri });
@@ -750,6 +822,72 @@ var Auth = class {
750
822
  signInWithDiscord(code, redirectUri) {
751
823
  return this.loginSocial({ provider: "discord", code, redirectUri });
752
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
+ }
753
891
  /**
754
892
  * Read the stored anon refresh token if (and only if) the local
755
893
  * subject is currently anonymous. Used by every identified-session
@@ -1229,7 +1367,7 @@ var Auth = class {
1229
1367
  void tick();
1230
1368
  }, TICK_MS);
1231
1369
  try {
1232
- const subscription = import_react_native.AppState?.addEventListener?.("change", (state) => {
1370
+ const subscription = import_react_native2.AppState?.addEventListener?.("change", (state) => {
1233
1371
  if (state === "active") {
1234
1372
  this.resetRefreshState();
1235
1373
  void tick();
@@ -1390,7 +1528,7 @@ function decodeJwtPayload(token) {
1390
1528
  }
1391
1529
 
1392
1530
  // src/links.ts
1393
- var import_react_native2 = require("react-native");
1531
+ var import_react_native3 = require("react-native");
1394
1532
  var LinkError = class extends Error {
1395
1533
  constructor(code, message, statusCode = 0, details) {
1396
1534
  super(message);
@@ -1469,10 +1607,10 @@ function dynRequire(id) {
1469
1607
  }
1470
1608
  function loadRn() {
1471
1609
  return {
1472
- Platform: import_react_native2.Platform,
1473
- Dimensions: import_react_native2.Dimensions,
1474
- NativeModules: import_react_native2.NativeModules,
1475
- 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
1476
1614
  };
1477
1615
  }
1478
1616
  function readTimezone() {
@@ -1968,7 +2106,7 @@ var Links = class {
1968
2106
  };
1969
2107
 
1970
2108
  // src/auto-track.ts
1971
- var import_react_native3 = require("react-native");
2109
+ var import_react_native4 = require("react-native");
1972
2110
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1973
2111
  function resolveFlags(cfg) {
1974
2112
  if (cfg === false) {
@@ -2013,7 +2151,7 @@ function installAutoTrack(args) {
2013
2151
  args.fire("app.opened", { sessionId });
2014
2152
  }
2015
2153
  if (flags.appBackground || flags.engagement) {
2016
- const AppState = import_react_native3.AppState;
2154
+ const AppState = import_react_native4.AppState;
2017
2155
  if (AppState && typeof AppState.addEventListener === "function") {
2018
2156
  const handler = (state) => {
2019
2157
  ensureSession(true);
@@ -2112,13 +2250,13 @@ function byteLength(s) {
2112
2250
  }
2113
2251
  function buildDeviceContext(appVersion, appBuild) {
2114
2252
  try {
2115
- const os = import_react_native4.Platform?.OS;
2253
+ const os = import_react_native5.Platform?.OS;
2116
2254
  if (os !== "ios" && os !== "android") return void 0;
2117
- const isPad = os === "ios" && import_react_native4.Platform.isPad === true;
2255
+ const isPad = os === "ios" && import_react_native5.Platform.isPad === true;
2118
2256
  return {
2119
2257
  type: isPad ? "tablet" : "mobile",
2120
2258
  os: os === "ios" ? "iOS" : "Android",
2121
- osVersion: String(import_react_native4.Platform.Version ?? ""),
2259
+ osVersion: String(import_react_native5.Platform.Version ?? ""),
2122
2260
  model: expoDeviceModel(),
2123
2261
  appVersion: resolveAppVersion(appVersion),
2124
2262
  appBuild: resolveAppBuild(appBuild)
@@ -2128,27 +2266,7 @@ function buildDeviceContext(appVersion, appBuild) {
2128
2266
  }
2129
2267
  }
2130
2268
  function expoModule(name) {
2131
- try {
2132
- const g = globalThis;
2133
- const m = g?.expo?.modules?.[name];
2134
- if (m && typeof m === "object") return m;
2135
- } catch {
2136
- }
2137
- try {
2138
- const g = globalThis;
2139
- const nm = import_react_native4.NativeModules;
2140
- const proxy = g?.expo?.modules?.NativeModulesProxy ?? nm?.NativeUnimoduleProxy;
2141
- const consts = proxy?.modulesConstants?.[name];
2142
- if (consts && typeof consts === "object") return consts;
2143
- } catch {
2144
- }
2145
- try {
2146
- const nm = import_react_native4.NativeModules;
2147
- const m = nm?.[name];
2148
- if (m && typeof m === "object") return m;
2149
- } catch {
2150
- }
2151
- return void 0;
2269
+ return requireOptionalNativeModule(name);
2152
2270
  }
2153
2271
  function expoApplicationVersion() {
2154
2272
  const a = expoModule("ExpoApplication");
package/dist/index.d.cts CHANGED
@@ -230,16 +230,15 @@ declare class Storage {
230
230
  * so a UI double-submit can't mint two anonymous users or interleave
231
231
  * a signIn + signOut.
232
232
  *
233
- * Tokens (including the refresh token) persist in AsyncStorage, not
234
- * the iOS Keychain / Android Keystore. AsyncStorage is unencrypted
235
- * app-sandbox storage: readable by anyone with filesystem/backup
236
- * access to a jailbroken/rooted or unlocked device, but isolated from
237
- * other apps by the OS sandbox. There is NO `secureStorage` adapter
238
- * option today earlier docs claimed a Keychain/SecureStore-backed
239
- * adapter that was never implemented. If you ship a follow-up that
240
- * moves the refresh token to expo-secure-store / react-native-keychain
241
- * (optional peer dep, Metro-resolvable static import never an inline
242
- * require), keep the `auth_refresh_token` storage key for back-compat.
233
+ * Tokens (including the refresh token) persist in AsyncStorage BY
234
+ * DEFAULT unencrypted app-sandbox storage: isolated from other apps
235
+ * by the OS sandbox, but readable with filesystem/backup access to a
236
+ * jailbroken/rooted or unlocked device (OWASP MASVS-L1). Since 1.18.0 an
237
+ * OPTIONAL `secureStorage` adapter (`Sendora.init({ secureStorage })`,
238
+ * wired in index.ts) routes the SECURE_KEYS (refresh/access/user) through
239
+ * an app-supplied Keychain / Keystore adapter, with transparent
240
+ * plaintext→secure migration; omit it and behaviour is unchanged. The
241
+ * `auth_refresh_token` storage key stays frozen for back-compat either way.
243
242
  *
244
243
  * Response payloads are validated non-empty before persisting so a
245
244
  * malformed/MITM'd response can't install an `id = ""` user.
@@ -262,6 +261,22 @@ interface AuthTokens {
262
261
  expiresIn: number;
263
262
  tokenType: string;
264
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
+ }
265
280
  declare class EmailAlreadyTakenError extends Error {
266
281
  constructor(message?: string);
267
282
  }
@@ -478,8 +493,17 @@ declare class Auth {
478
493
  firstName?: string;
479
494
  lastName?: string;
480
495
  };
496
+ /**
497
+ * ADR-025 link-in-place opt-in. When this device is anonymous and you set
498
+ * `link: true`, an anon→social upgrade KEEPS the same user id (sub) — the
499
+ * anon account is promoted in place (like Firebase linkWithCredential)
500
+ * instead of a device-takeover that mints a new id. No effect when the
501
+ * device isn't anonymous, or when the social identity already belongs to
502
+ * another account (collision → falls back to takeover/merge).
503
+ */
504
+ link?: boolean;
481
505
  }): Promise<AuthUser>;
482
- signInWithGoogle(code: string, redirectUri: string): Promise<AuthUser>;
506
+ signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
483
507
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
484
508
  signInWithApple(input: {
485
509
  idToken?: string;
@@ -489,11 +513,65 @@ declare class Auth {
489
513
  firstName?: string;
490
514
  lastName?: string;
491
515
  };
516
+ /** ADR-025: keep the same user id on an anon→Apple upgrade. See `loginSocial`. */
517
+ link?: boolean;
492
518
  }): Promise<AuthUser>;
493
519
  signInWithMicrosoft(code: string, redirectUri: string): Promise<AuthUser>;
494
520
  signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
495
521
  signInWithFacebook(code: string, redirectUri: string): Promise<AuthUser>;
496
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;
497
575
  /**
498
576
  * Read the stored anon refresh token if (and only if) the local
499
577
  * subject is currently anonymous. Used by every identified-session
package/dist/index.d.ts CHANGED
@@ -230,16 +230,15 @@ declare class Storage {
230
230
  * so a UI double-submit can't mint two anonymous users or interleave
231
231
  * a signIn + signOut.
232
232
  *
233
- * Tokens (including the refresh token) persist in AsyncStorage, not
234
- * the iOS Keychain / Android Keystore. AsyncStorage is unencrypted
235
- * app-sandbox storage: readable by anyone with filesystem/backup
236
- * access to a jailbroken/rooted or unlocked device, but isolated from
237
- * other apps by the OS sandbox. There is NO `secureStorage` adapter
238
- * option today earlier docs claimed a Keychain/SecureStore-backed
239
- * adapter that was never implemented. If you ship a follow-up that
240
- * moves the refresh token to expo-secure-store / react-native-keychain
241
- * (optional peer dep, Metro-resolvable static import never an inline
242
- * require), keep the `auth_refresh_token` storage key for back-compat.
233
+ * Tokens (including the refresh token) persist in AsyncStorage BY
234
+ * DEFAULT unencrypted app-sandbox storage: isolated from other apps
235
+ * by the OS sandbox, but readable with filesystem/backup access to a
236
+ * jailbroken/rooted or unlocked device (OWASP MASVS-L1). Since 1.18.0 an
237
+ * OPTIONAL `secureStorage` adapter (`Sendora.init({ secureStorage })`,
238
+ * wired in index.ts) routes the SECURE_KEYS (refresh/access/user) through
239
+ * an app-supplied Keychain / Keystore adapter, with transparent
240
+ * plaintext→secure migration; omit it and behaviour is unchanged. The
241
+ * `auth_refresh_token` storage key stays frozen for back-compat either way.
243
242
  *
244
243
  * Response payloads are validated non-empty before persisting so a
245
244
  * malformed/MITM'd response can't install an `id = ""` user.
@@ -262,6 +261,22 @@ interface AuthTokens {
262
261
  expiresIn: number;
263
262
  tokenType: string;
264
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
+ }
265
280
  declare class EmailAlreadyTakenError extends Error {
266
281
  constructor(message?: string);
267
282
  }
@@ -478,8 +493,17 @@ declare class Auth {
478
493
  firstName?: string;
479
494
  lastName?: string;
480
495
  };
496
+ /**
497
+ * ADR-025 link-in-place opt-in. When this device is anonymous and you set
498
+ * `link: true`, an anon→social upgrade KEEPS the same user id (sub) — the
499
+ * anon account is promoted in place (like Firebase linkWithCredential)
500
+ * instead of a device-takeover that mints a new id. No effect when the
501
+ * device isn't anonymous, or when the social identity already belongs to
502
+ * another account (collision → falls back to takeover/merge).
503
+ */
504
+ link?: boolean;
481
505
  }): Promise<AuthUser>;
482
- signInWithGoogle(code: string, redirectUri: string): Promise<AuthUser>;
506
+ signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
483
507
  signInWithGitHub(code: string, redirectUri: string): Promise<AuthUser>;
484
508
  signInWithApple(input: {
485
509
  idToken?: string;
@@ -489,11 +513,65 @@ declare class Auth {
489
513
  firstName?: string;
490
514
  lastName?: string;
491
515
  };
516
+ /** ADR-025: keep the same user id on an anon→Apple upgrade. See `loginSocial`. */
517
+ link?: boolean;
492
518
  }): Promise<AuthUser>;
493
519
  signInWithMicrosoft(code: string, redirectUri: string): Promise<AuthUser>;
494
520
  signInWithLinkedIn(code: string, redirectUri: string): Promise<AuthUser>;
495
521
  signInWithFacebook(code: string, redirectUri: string): Promise<AuthUser>;
496
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;
497
575
  /**
498
576
  * Read the stored anon refresh token if (and only if) the local
499
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.19.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;
@@ -662,8 +732,10 @@ var Auth = class {
662
732
  if (stashed) prevAnonRefreshToken = stashed;
663
733
  }
664
734
  if (this.user !== null) await this.wipeLocalIdentity();
665
- const payload = { ...input };
735
+ const { link, ...socialInput } = input;
736
+ const payload = { ...socialInput };
666
737
  if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
738
+ if (link) payload.linkAnonymous = true;
667
739
  const res = await post(
668
740
  { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
669
741
  "/api/v1/auth-service/login/social",
@@ -686,8 +758,8 @@ var Auth = class {
686
758
  return parsed.data.user;
687
759
  });
688
760
  }
689
- signInWithGoogle(code, redirectUri) {
690
- return this.loginSocial({ provider: "google", code, redirectUri });
761
+ signInWithGoogle(code, redirectUri, link) {
762
+ return this.loginSocial({ provider: "google", code, redirectUri, link });
691
763
  }
692
764
  signInWithGitHub(code, redirectUri) {
693
765
  return this.loginSocial({ provider: "github", code, redirectUri });
@@ -707,6 +779,72 @@ var Auth = class {
707
779
  signInWithDiscord(code, redirectUri) {
708
780
  return this.loginSocial({ provider: "discord", code, redirectUri });
709
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
+ }
710
848
  /**
711
849
  * Read the stored anon refresh token if (and only if) the local
712
850
  * subject is currently anonymous. Used by every identified-session
@@ -2090,27 +2228,7 @@ function buildDeviceContext(appVersion, appBuild) {
2090
2228
  }
2091
2229
  }
2092
2230
  function expoModule(name) {
2093
- try {
2094
- const g = globalThis;
2095
- const m = g?.expo?.modules?.[name];
2096
- if (m && typeof m === "object") return m;
2097
- } catch {
2098
- }
2099
- try {
2100
- const g = globalThis;
2101
- const nm = NativeModules;
2102
- const proxy = g?.expo?.modules?.NativeModulesProxy ?? nm?.NativeUnimoduleProxy;
2103
- const consts = proxy?.modulesConstants?.[name];
2104
- if (consts && typeof consts === "object") return consts;
2105
- } catch {
2106
- }
2107
- try {
2108
- const nm = NativeModules;
2109
- const m = nm?.[name];
2110
- if (m && typeof m === "object") return m;
2111
- } catch {
2112
- }
2113
- return void 0;
2231
+ return requireOptionalNativeModule(name);
2114
2232
  }
2115
2233
  function expoApplicationVersion() {
2116
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.19.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
+ }