@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 +85 -0
- package/dist/index.cjs +159 -41
- package/dist/index.d.cts +89 -11
- package/dist/index.d.ts +89 -11
- package/dist/index.js +147 -29
- package/package.json +6 -4
- package/plugin/apply.js +73 -0
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
|
|
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.
|
|
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
|
|
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/
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
1473
|
-
Dimensions:
|
|
1474
|
-
NativeModules:
|
|
1475
|
-
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
|
|
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 =
|
|
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 =
|
|
2253
|
+
const os = import_react_native5.Platform?.OS;
|
|
2116
2254
|
if (os !== "ios" && os !== "android") return void 0;
|
|
2117
|
-
const isPad = os === "ios" &&
|
|
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(
|
|
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
|
-
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|
package/plugin/apply.js
ADDED
|
@@ -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
|
+
}
|