@sendoracloud/sdk-react-native 1.35.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +78 -29
- package/dist/index.d.cts +76 -17
- package/dist/index.d.ts +76 -17
- package/dist/index.js +78 -29
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -65,6 +65,12 @@ async function getAsyncStorage() {
|
|
|
65
65
|
})();
|
|
66
66
|
return asyncStoragePromise;
|
|
67
67
|
}
|
|
68
|
+
var warned = /* @__PURE__ */ new Set();
|
|
69
|
+
function warnOnce(key, message) {
|
|
70
|
+
if (warned.has(key)) return;
|
|
71
|
+
warned.add(key);
|
|
72
|
+
console.warn(message);
|
|
73
|
+
}
|
|
68
74
|
var Storage = class {
|
|
69
75
|
constructor(opts = {}) {
|
|
70
76
|
this.cache = /* @__PURE__ */ new Map();
|
|
@@ -73,6 +79,12 @@ var Storage = class {
|
|
|
73
79
|
this.secureKeys = new Set(opts.secureKeys ?? []);
|
|
74
80
|
this.prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
75
81
|
this.securePrefix = opts.prefix ?? "";
|
|
82
|
+
if (this.secure === null && this.secureKeys.size > 0) {
|
|
83
|
+
warnOnce(
|
|
84
|
+
"no-secure-adapter",
|
|
85
|
+
"[sendora] No `secureStorage` adapter supplied \u2014 auth tokens (refresh token, access token, cached user) will be stored in PLAIN AsyncStorage, which is included in iOS/Android device backups. Pass `secureStorage` in init() with a Keychain/Keystore-backed adapter (e.g. expo-secure-store). The session still persists either way; this is about where the token rests."
|
|
86
|
+
);
|
|
87
|
+
}
|
|
76
88
|
}
|
|
77
89
|
/** The adapter-facing name for a key. Bare by default; see `prefix`. */
|
|
78
90
|
secureName(key) {
|
|
@@ -126,6 +138,10 @@ var Storage = class {
|
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
140
|
} catch {
|
|
141
|
+
warnOnce(
|
|
142
|
+
"secure-read-degraded",
|
|
143
|
+
"[sendora] The `secureStorage` adapter failed on read; falling back to plain AsyncStorage for this session. Tokens are preserved but no longer encrypted at rest. Check your adapter."
|
|
144
|
+
);
|
|
129
145
|
if (store) {
|
|
130
146
|
try {
|
|
131
147
|
const legacy = await store.getItem(this.prefix + k);
|
|
@@ -142,6 +158,10 @@ var Storage = class {
|
|
|
142
158
|
this.cache.set(key, value);
|
|
143
159
|
if (this.isSecure(key)) {
|
|
144
160
|
void this.secure.setItem(this.secureName(key), value).catch(() => {
|
|
161
|
+
warnOnce(
|
|
162
|
+
"secure-write-degraded",
|
|
163
|
+
"[sendora] The `secureStorage` adapter failed on write; this value fell back to plain AsyncStorage. The session is preserved but this token is no longer encrypted at rest. Check your adapter."
|
|
164
|
+
);
|
|
145
165
|
void getAsyncStorage().then((store) => {
|
|
146
166
|
if (store) store.setItem(this.prefix + key, value).catch(() => {
|
|
147
167
|
});
|
|
@@ -208,7 +228,7 @@ var Storage = class {
|
|
|
208
228
|
// package.json
|
|
209
229
|
var package_default = {
|
|
210
230
|
name: "@sendoracloud/sdk-react-native",
|
|
211
|
-
version: "1.
|
|
231
|
+
version: "1.38.0",
|
|
212
232
|
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`).",
|
|
213
233
|
type: "module",
|
|
214
234
|
main: "./dist/index.cjs",
|
|
@@ -264,7 +284,7 @@ var package_default = {
|
|
|
264
284
|
scripts: {
|
|
265
285
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
266
286
|
typecheck: "tsc --noEmit",
|
|
267
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
287
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts"
|
|
268
288
|
},
|
|
269
289
|
devDependencies: {
|
|
270
290
|
"@types/react": "^19.2.16",
|
|
@@ -496,6 +516,12 @@ function classifyAuthErrorKind(code, status) {
|
|
|
496
516
|
return "credential_in_use";
|
|
497
517
|
case "LAST_CREDENTIAL":
|
|
498
518
|
return "last_credential";
|
|
519
|
+
// ⚠ MUST be a code case, not a status one. The tail of this function is
|
|
520
|
+
// `if (status >= 400) return "invalid_credential"`, which would otherwise
|
|
521
|
+
// claim the 403 and report a step-up refusal as a bad credential —
|
|
522
|
+
// documented as "needs NEW input", which is the opposite of the truth.
|
|
523
|
+
case "RECENT_AUTH_REQUIRED":
|
|
524
|
+
return "recent_auth_required";
|
|
499
525
|
case "NOT_ANONYMOUS":
|
|
500
526
|
case "FORBIDDEN_NON_ANONYMOUS":
|
|
501
527
|
return "already_identified";
|
|
@@ -1582,13 +1608,24 @@ var Auth = class {
|
|
|
1582
1608
|
const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
|
|
1583
1609
|
return Boolean(data.confirmed);
|
|
1584
1610
|
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Disable MFA.
|
|
1613
|
+
*
|
|
1614
|
+
* ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
|
|
1615
|
+
* `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
|
|
1616
|
+
*
|
|
1617
|
+
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1618
|
+
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1619
|
+
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1620
|
+
*/
|
|
1585
1621
|
async disableMfa() {
|
|
1586
|
-
await post(
|
|
1622
|
+
const res = await post(
|
|
1587
1623
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1588
1624
|
"/api/v1/auth-service/mfa/disable",
|
|
1589
1625
|
{},
|
|
1590
1626
|
this.bearerHeaders()
|
|
1591
1627
|
);
|
|
1628
|
+
await this.unwrap(res, "MFA_DISABLE_FAILED");
|
|
1592
1629
|
}
|
|
1593
1630
|
// ============================================================
|
|
1594
1631
|
// DEVICE SESSIONS (Bearer-authenticated)
|
|
@@ -1603,19 +1640,28 @@ var Auth = class {
|
|
|
1603
1640
|
const parsed = await res.json();
|
|
1604
1641
|
return Array.isArray(parsed.data) ? parsed.data : [];
|
|
1605
1642
|
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1645
|
+
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1646
|
+
* that reports success on a failure is the one place a user acts on the claim
|
|
1647
|
+
* and then stops worrying.
|
|
1648
|
+
*/
|
|
1606
1649
|
async revokeSession(sessionId) {
|
|
1607
|
-
await del(
|
|
1650
|
+
const res = await del(
|
|
1608
1651
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1609
1652
|
`/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
|
|
1610
1653
|
this.bearerHeaders()
|
|
1611
1654
|
);
|
|
1655
|
+
await this.unwrap(res, "SESSION_REVOKE_FAILED");
|
|
1612
1656
|
}
|
|
1657
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1613
1658
|
async revokeAllSessions() {
|
|
1614
|
-
await del(
|
|
1659
|
+
const res = await del(
|
|
1615
1660
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1616
1661
|
"/api/v1/auth-service/sessions/me",
|
|
1617
1662
|
this.bearerHeaders()
|
|
1618
1663
|
);
|
|
1664
|
+
await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
|
|
1619
1665
|
}
|
|
1620
1666
|
/**
|
|
1621
1667
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -1781,7 +1827,10 @@ var Auth = class {
|
|
|
1781
1827
|
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1782
1828
|
const parsed = await res.json().catch(() => ({}));
|
|
1783
1829
|
if (!res.ok || !parsed.success) {
|
|
1784
|
-
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}
|
|
1830
|
+
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
|
|
1831
|
+
status: res.status,
|
|
1832
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1833
|
+
});
|
|
1785
1834
|
}
|
|
1786
1835
|
return parsed.data;
|
|
1787
1836
|
}
|
|
@@ -2107,7 +2156,7 @@ var Auth = class {
|
|
|
2107
2156
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
2108
2157
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
2109
2158
|
this.stopProactiveRefreshCron();
|
|
2110
|
-
await this.hooks.onAnonymousWipe();
|
|
2159
|
+
await this.hooks.onAnonymousWipe(reason);
|
|
2111
2160
|
if (reason !== "replaced") {
|
|
2112
2161
|
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
2113
2162
|
}
|
|
@@ -2578,33 +2627,31 @@ var Links = class {
|
|
|
2578
2627
|
}
|
|
2579
2628
|
}
|
|
2580
2629
|
/**
|
|
2581
|
-
* Cold-launch deferred deep
|
|
2582
|
-
*
|
|
2583
|
-
*
|
|
2584
|
-
*
|
|
2585
|
-
*
|
|
2630
|
+
* Cold-launch deferred deep-link match.
|
|
2631
|
+
*
|
|
2632
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
2633
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
2634
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
2635
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
2636
|
+
* exactly that. Signal resolution now:
|
|
2637
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
2638
|
+
* preferred and compliant path.
|
|
2639
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
2640
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
2641
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
2642
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
2643
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
2586
2644
|
*
|
|
2587
2645
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
2588
2646
|
*/
|
|
2589
2647
|
async matchDeferred(input = {}) {
|
|
2590
|
-
|
|
2648
|
+
const fingerprintHash = input.fingerprintHash;
|
|
2591
2649
|
let installReferrer = input.installReferrer;
|
|
2592
2650
|
if (!fingerprintHash && !installReferrer) {
|
|
2593
2651
|
installReferrer = await getPlayInstallReferrer() ?? void 0;
|
|
2594
|
-
if (!installReferrer) {
|
|
2595
|
-
try {
|
|
2596
|
-
fingerprintHash = await computeDeviceFingerprint();
|
|
2597
|
-
} catch (err) {
|
|
2598
|
-
if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
|
|
2599
|
-
}
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
if (!fingerprintHash && !installReferrer) {
|
|
2603
|
-
throw new LinkError(
|
|
2604
|
-
"INVALID_INPUT",
|
|
2605
|
-
"links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
|
|
2606
|
-
);
|
|
2607
2652
|
}
|
|
2653
|
+
const canMatch = !!fingerprintHash || !!installReferrer || input.allowProbabilisticMatch === true;
|
|
2654
|
+
if (!canMatch) return null;
|
|
2608
2655
|
const body = {};
|
|
2609
2656
|
if (fingerprintHash) body.fingerprintHash = fingerprintHash;
|
|
2610
2657
|
if (installReferrer) body.installReferrer = installReferrer;
|
|
@@ -3052,9 +3099,11 @@ var SendoraSDK = class {
|
|
|
3052
3099
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
3053
3100
|
else this.storage.remove(USER_ID_KEY);
|
|
3054
3101
|
},
|
|
3055
|
-
onAnonymousWipe: async () => {
|
|
3056
|
-
|
|
3057
|
-
|
|
3102
|
+
onAnonymousWipe: async (reason) => {
|
|
3103
|
+
if (reason === "user" || reason === "account_deleted") {
|
|
3104
|
+
this.anonId = uuid();
|
|
3105
|
+
this.storage.set(ANON_KEY, this.anonId);
|
|
3106
|
+
}
|
|
3058
3107
|
this.userId = null;
|
|
3059
3108
|
this.storage.remove(USER_ID_KEY);
|
|
3060
3109
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -464,7 +464,7 @@ type UnlinkResult = {
|
|
|
464
464
|
* OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
|
|
465
465
|
* the stored refresh token exactly as they were.
|
|
466
466
|
*/
|
|
467
|
-
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
467
|
+
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
468
468
|
declare class AuthError extends Error {
|
|
469
469
|
code: string;
|
|
470
470
|
/**
|
|
@@ -497,12 +497,15 @@ interface AuthHooks {
|
|
|
497
497
|
* identity (analytics userId, push token registration, etc). */
|
|
498
498
|
onIdentityChange: (userId: string | null) => void;
|
|
499
499
|
/**
|
|
500
|
-
* Called
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
500
|
+
* Called from `wipeLocalIdentity` whenever the local session is cleared. The
|
|
501
|
+
* `reason` lets the parent decide whether to rotate the anonymous analytics id:
|
|
502
|
+
* it rotates ONLY at a person-boundary ("user" = explicit signOut,
|
|
503
|
+
* "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
|
|
504
|
+
* involuntary session death ("session_expired") — those are the same person, and
|
|
505
|
+
* the anon id must survive so pre-auth analytics link to them (Firebase-FID
|
|
506
|
+
* semantics; the industry norm).
|
|
504
507
|
*/
|
|
505
|
-
onAnonymousWipe: () => Promise<void>;
|
|
508
|
+
onAnonymousWipe: (reason: WipeReason) => Promise<void>;
|
|
506
509
|
apiUrl: string;
|
|
507
510
|
publicKey: string;
|
|
508
511
|
debug: boolean;
|
|
@@ -565,6 +568,12 @@ type AuthStateChange = {
|
|
|
565
568
|
user: AuthUser | null;
|
|
566
569
|
};
|
|
567
570
|
type AuthStateListener = (change: AuthStateChange) => void;
|
|
571
|
+
/**
|
|
572
|
+
* Internal marker for a wipe that is immediately followed by a `persist()` of
|
|
573
|
+
* a NEW session (the sign-in success path). Not a sign-out — emitting one
|
|
574
|
+
* would make every successful sign-in look like a logout to subscribers.
|
|
575
|
+
*/
|
|
576
|
+
type WipeReason = AuthSignedOutReason | "replaced";
|
|
568
577
|
declare class Auth {
|
|
569
578
|
private hooks;
|
|
570
579
|
private user;
|
|
@@ -1109,6 +1118,16 @@ declare class Auth {
|
|
|
1109
1118
|
recoveryCodes: string[];
|
|
1110
1119
|
}>;
|
|
1111
1120
|
confirmMfa(code: string): Promise<boolean>;
|
|
1121
|
+
/**
|
|
1122
|
+
* Disable MFA.
|
|
1123
|
+
*
|
|
1124
|
+
* ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
|
|
1125
|
+
* `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
|
|
1126
|
+
*
|
|
1127
|
+
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1128
|
+
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1129
|
+
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1130
|
+
*/
|
|
1112
1131
|
disableMfa(): Promise<void>;
|
|
1113
1132
|
listMySessions(): Promise<Array<{
|
|
1114
1133
|
id: string;
|
|
@@ -1117,7 +1136,14 @@ declare class Auth {
|
|
|
1117
1136
|
lastUsedAt: string | null;
|
|
1118
1137
|
createdAt: string;
|
|
1119
1138
|
}>>;
|
|
1139
|
+
/**
|
|
1140
|
+
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1141
|
+
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1142
|
+
* that reports success on a failure is the one place a user acts on the claim
|
|
1143
|
+
* and then stops worrying.
|
|
1144
|
+
*/
|
|
1120
1145
|
revokeSession(sessionId: string): Promise<void>;
|
|
1146
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1121
1147
|
revokeAllSessions(): Promise<void>;
|
|
1122
1148
|
/**
|
|
1123
1149
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -1383,11 +1409,33 @@ interface LinkOpenedEvent<T extends LinkData = LinkData> {
|
|
|
1383
1409
|
}
|
|
1384
1410
|
type LinkOpenedHandler<T extends LinkData = LinkData> = (event: LinkOpenedEvent<T>) => void;
|
|
1385
1411
|
interface LinkMatchInput {
|
|
1386
|
-
/**
|
|
1412
|
+
/**
|
|
1413
|
+
* Hex-encoded SHA-256 device fingerprint.
|
|
1414
|
+
*
|
|
1415
|
+
* ⚠ **Do not supply this on iOS.** Apple prohibits deriving a device identifier
|
|
1416
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), and it
|
|
1417
|
+
* does not care which side of the network computed it — passing a hash you built
|
|
1418
|
+
* on-device is the same practice. The SDK no longer computes one for you (it used
|
|
1419
|
+
* to, implicitly, on iOS). Left here for the rare Android/self-hosted caller that
|
|
1420
|
+
* has a legitimate deterministic hash; on iOS, omit it.
|
|
1421
|
+
*/
|
|
1387
1422
|
fingerprintHash?: string;
|
|
1388
1423
|
/** Raw Play Install Referrer. Sendora probes
|
|
1389
1424
|
* `react-native-play-install-referrer` automatically when this is omitted. */
|
|
1390
1425
|
installReferrer?: string;
|
|
1426
|
+
/**
|
|
1427
|
+
* Opt in to probabilistic matching on iOS.
|
|
1428
|
+
*
|
|
1429
|
+
* iOS has no deterministic deferred-install signal (no install referrer), and the
|
|
1430
|
+
* SDK will not fingerprint the device. The only remaining matcher is the server's
|
|
1431
|
+
* coarse IP + timestamp window — the same class of practice Apple scrutinises, and
|
|
1432
|
+
* it collides across a shared NAT / iCloud Private Relay. So it is **off by
|
|
1433
|
+
* default**: without this flag, `matchDeferred()` on iOS matches nothing and
|
|
1434
|
+
* returns `null` (route the user through your normal onboarding UX). Set it only
|
|
1435
|
+
* if you accept the accuracy and compliance trade-off. No effect on Android, where
|
|
1436
|
+
* the Play Install Referrer is deterministic.
|
|
1437
|
+
*/
|
|
1438
|
+
allowProbabilisticMatch?: boolean;
|
|
1391
1439
|
iosBundleId?: string;
|
|
1392
1440
|
androidPackageName?: string;
|
|
1393
1441
|
}
|
|
@@ -1420,12 +1468,14 @@ interface LinkStats {
|
|
|
1420
1468
|
*/
|
|
1421
1469
|
declare function extractShortcodeFromUrl(url: string, linkDomain?: string): string | null;
|
|
1422
1470
|
/**
|
|
1423
|
-
* Canonical device-fingerprint recipe
|
|
1424
|
-
*
|
|
1425
|
-
* install completing on another won't accidentally match.
|
|
1471
|
+
* Canonical device-fingerprint recipe (`${platform}|${screenW}x${screenH}|${timezone}|${locale}`
|
|
1472
|
+
* → lowercase hex SHA-256), identical across the RN / iOS / Android SDKs.
|
|
1426
1473
|
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1474
|
+
* @deprecated ⚠ Do not use on iOS. Apple prohibits deriving a device identifier
|
|
1475
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), regardless
|
|
1476
|
+
* of ATT — and this is exactly that. `matchDeferred()` no longer calls it
|
|
1477
|
+
* implicitly. It is retained only for an Android/self-hosted caller with a
|
|
1478
|
+
* legitimate deterministic use, and is slated for removal in a future major.
|
|
1429
1479
|
*/
|
|
1430
1480
|
declare function computeDeviceFingerprint(): Promise<string>;
|
|
1431
1481
|
/** Probe optional `react-native-play-install-referrer` peer. Returns the
|
|
@@ -1494,11 +1544,20 @@ declare class Links {
|
|
|
1494
1544
|
*/
|
|
1495
1545
|
handleUniversalLink(url: string): Promise<boolean>;
|
|
1496
1546
|
/**
|
|
1497
|
-
* Cold-launch deferred deep
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1547
|
+
* Cold-launch deferred deep-link match.
|
|
1548
|
+
*
|
|
1549
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
1550
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
1551
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
1552
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
1553
|
+
* exactly that. Signal resolution now:
|
|
1554
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
1555
|
+
* preferred and compliant path.
|
|
1556
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
1557
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
1558
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
1559
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
1560
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
1502
1561
|
*
|
|
1503
1562
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
1504
1563
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -464,7 +464,7 @@ type UnlinkResult = {
|
|
|
464
464
|
* OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
|
|
465
465
|
* the stored refresh token exactly as they were.
|
|
466
466
|
*/
|
|
467
|
-
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
467
|
+
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
468
468
|
declare class AuthError extends Error {
|
|
469
469
|
code: string;
|
|
470
470
|
/**
|
|
@@ -497,12 +497,15 @@ interface AuthHooks {
|
|
|
497
497
|
* identity (analytics userId, push token registration, etc). */
|
|
498
498
|
onIdentityChange: (userId: string | null) => void;
|
|
499
499
|
/**
|
|
500
|
-
* Called
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
500
|
+
* Called from `wipeLocalIdentity` whenever the local session is cleared. The
|
|
501
|
+
* `reason` lets the parent decide whether to rotate the anonymous analytics id:
|
|
502
|
+
* it rotates ONLY at a person-boundary ("user" = explicit signOut,
|
|
503
|
+
* "account_deleted"), never on a successful sign-in/upgrade ("replaced") or an
|
|
504
|
+
* involuntary session death ("session_expired") — those are the same person, and
|
|
505
|
+
* the anon id must survive so pre-auth analytics link to them (Firebase-FID
|
|
506
|
+
* semantics; the industry norm).
|
|
504
507
|
*/
|
|
505
|
-
onAnonymousWipe: () => Promise<void>;
|
|
508
|
+
onAnonymousWipe: (reason: WipeReason) => Promise<void>;
|
|
506
509
|
apiUrl: string;
|
|
507
510
|
publicKey: string;
|
|
508
511
|
debug: boolean;
|
|
@@ -565,6 +568,12 @@ type AuthStateChange = {
|
|
|
565
568
|
user: AuthUser | null;
|
|
566
569
|
};
|
|
567
570
|
type AuthStateListener = (change: AuthStateChange) => void;
|
|
571
|
+
/**
|
|
572
|
+
* Internal marker for a wipe that is immediately followed by a `persist()` of
|
|
573
|
+
* a NEW session (the sign-in success path). Not a sign-out — emitting one
|
|
574
|
+
* would make every successful sign-in look like a logout to subscribers.
|
|
575
|
+
*/
|
|
576
|
+
type WipeReason = AuthSignedOutReason | "replaced";
|
|
568
577
|
declare class Auth {
|
|
569
578
|
private hooks;
|
|
570
579
|
private user;
|
|
@@ -1109,6 +1118,16 @@ declare class Auth {
|
|
|
1109
1118
|
recoveryCodes: string[];
|
|
1110
1119
|
}>;
|
|
1111
1120
|
confirmMfa(code: string): Promise<boolean>;
|
|
1121
|
+
/**
|
|
1122
|
+
* Disable MFA.
|
|
1123
|
+
*
|
|
1124
|
+
* ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
|
|
1125
|
+
* `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
|
|
1126
|
+
*
|
|
1127
|
+
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1128
|
+
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1129
|
+
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1130
|
+
*/
|
|
1112
1131
|
disableMfa(): Promise<void>;
|
|
1113
1132
|
listMySessions(): Promise<Array<{
|
|
1114
1133
|
id: string;
|
|
@@ -1117,7 +1136,14 @@ declare class Auth {
|
|
|
1117
1136
|
lastUsedAt: string | null;
|
|
1118
1137
|
createdAt: string;
|
|
1119
1138
|
}>>;
|
|
1139
|
+
/**
|
|
1140
|
+
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1141
|
+
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1142
|
+
* that reports success on a failure is the one place a user acts on the claim
|
|
1143
|
+
* and then stops worrying.
|
|
1144
|
+
*/
|
|
1120
1145
|
revokeSession(sessionId: string): Promise<void>;
|
|
1146
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1121
1147
|
revokeAllSessions(): Promise<void>;
|
|
1122
1148
|
/**
|
|
1123
1149
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -1383,11 +1409,33 @@ interface LinkOpenedEvent<T extends LinkData = LinkData> {
|
|
|
1383
1409
|
}
|
|
1384
1410
|
type LinkOpenedHandler<T extends LinkData = LinkData> = (event: LinkOpenedEvent<T>) => void;
|
|
1385
1411
|
interface LinkMatchInput {
|
|
1386
|
-
/**
|
|
1412
|
+
/**
|
|
1413
|
+
* Hex-encoded SHA-256 device fingerprint.
|
|
1414
|
+
*
|
|
1415
|
+
* ⚠ **Do not supply this on iOS.** Apple prohibits deriving a device identifier
|
|
1416
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), and it
|
|
1417
|
+
* does not care which side of the network computed it — passing a hash you built
|
|
1418
|
+
* on-device is the same practice. The SDK no longer computes one for you (it used
|
|
1419
|
+
* to, implicitly, on iOS). Left here for the rare Android/self-hosted caller that
|
|
1420
|
+
* has a legitimate deterministic hash; on iOS, omit it.
|
|
1421
|
+
*/
|
|
1387
1422
|
fingerprintHash?: string;
|
|
1388
1423
|
/** Raw Play Install Referrer. Sendora probes
|
|
1389
1424
|
* `react-native-play-install-referrer` automatically when this is omitted. */
|
|
1390
1425
|
installReferrer?: string;
|
|
1426
|
+
/**
|
|
1427
|
+
* Opt in to probabilistic matching on iOS.
|
|
1428
|
+
*
|
|
1429
|
+
* iOS has no deterministic deferred-install signal (no install referrer), and the
|
|
1430
|
+
* SDK will not fingerprint the device. The only remaining matcher is the server's
|
|
1431
|
+
* coarse IP + timestamp window — the same class of practice Apple scrutinises, and
|
|
1432
|
+
* it collides across a shared NAT / iCloud Private Relay. So it is **off by
|
|
1433
|
+
* default**: without this flag, `matchDeferred()` on iOS matches nothing and
|
|
1434
|
+
* returns `null` (route the user through your normal onboarding UX). Set it only
|
|
1435
|
+
* if you accept the accuracy and compliance trade-off. No effect on Android, where
|
|
1436
|
+
* the Play Install Referrer is deterministic.
|
|
1437
|
+
*/
|
|
1438
|
+
allowProbabilisticMatch?: boolean;
|
|
1391
1439
|
iosBundleId?: string;
|
|
1392
1440
|
androidPackageName?: string;
|
|
1393
1441
|
}
|
|
@@ -1420,12 +1468,14 @@ interface LinkStats {
|
|
|
1420
1468
|
*/
|
|
1421
1469
|
declare function extractShortcodeFromUrl(url: string, linkDomain?: string): string | null;
|
|
1422
1470
|
/**
|
|
1423
|
-
* Canonical device-fingerprint recipe
|
|
1424
|
-
*
|
|
1425
|
-
* install completing on another won't accidentally match.
|
|
1471
|
+
* Canonical device-fingerprint recipe (`${platform}|${screenW}x${screenH}|${timezone}|${locale}`
|
|
1472
|
+
* → lowercase hex SHA-256), identical across the RN / iOS / Android SDKs.
|
|
1426
1473
|
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1474
|
+
* @deprecated ⚠ Do not use on iOS. Apple prohibits deriving a device identifier
|
|
1475
|
+
* from device signals for attribution (App Store 5.1.2 / DPLA §3.3.9), regardless
|
|
1476
|
+
* of ATT — and this is exactly that. `matchDeferred()` no longer calls it
|
|
1477
|
+
* implicitly. It is retained only for an Android/self-hosted caller with a
|
|
1478
|
+
* legitimate deterministic use, and is slated for removal in a future major.
|
|
1429
1479
|
*/
|
|
1430
1480
|
declare function computeDeviceFingerprint(): Promise<string>;
|
|
1431
1481
|
/** Probe optional `react-native-play-install-referrer` peer. Returns the
|
|
@@ -1494,11 +1544,20 @@ declare class Links {
|
|
|
1494
1544
|
*/
|
|
1495
1545
|
handleUniversalLink(url: string): Promise<boolean>;
|
|
1496
1546
|
/**
|
|
1497
|
-
* Cold-launch deferred deep
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1547
|
+
* Cold-launch deferred deep-link match.
|
|
1548
|
+
*
|
|
1549
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
1550
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
1551
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
1552
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
1553
|
+
* exactly that. Signal resolution now:
|
|
1554
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
1555
|
+
* preferred and compliant path.
|
|
1556
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
1557
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
1558
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
1559
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
1560
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
1502
1561
|
*
|
|
1503
1562
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
1504
1563
|
*/
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,12 @@ async function getAsyncStorage() {
|
|
|
20
20
|
})();
|
|
21
21
|
return asyncStoragePromise;
|
|
22
22
|
}
|
|
23
|
+
var warned = /* @__PURE__ */ new Set();
|
|
24
|
+
function warnOnce(key, message) {
|
|
25
|
+
if (warned.has(key)) return;
|
|
26
|
+
warned.add(key);
|
|
27
|
+
console.warn(message);
|
|
28
|
+
}
|
|
23
29
|
var Storage = class {
|
|
24
30
|
constructor(opts = {}) {
|
|
25
31
|
this.cache = /* @__PURE__ */ new Map();
|
|
@@ -28,6 +34,12 @@ var Storage = class {
|
|
|
28
34
|
this.secureKeys = new Set(opts.secureKeys ?? []);
|
|
29
35
|
this.prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
30
36
|
this.securePrefix = opts.prefix ?? "";
|
|
37
|
+
if (this.secure === null && this.secureKeys.size > 0) {
|
|
38
|
+
warnOnce(
|
|
39
|
+
"no-secure-adapter",
|
|
40
|
+
"[sendora] No `secureStorage` adapter supplied \u2014 auth tokens (refresh token, access token, cached user) will be stored in PLAIN AsyncStorage, which is included in iOS/Android device backups. Pass `secureStorage` in init() with a Keychain/Keystore-backed adapter (e.g. expo-secure-store). The session still persists either way; this is about where the token rests."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
31
43
|
}
|
|
32
44
|
/** The adapter-facing name for a key. Bare by default; see `prefix`. */
|
|
33
45
|
secureName(key) {
|
|
@@ -81,6 +93,10 @@ var Storage = class {
|
|
|
81
93
|
}
|
|
82
94
|
}
|
|
83
95
|
} catch {
|
|
96
|
+
warnOnce(
|
|
97
|
+
"secure-read-degraded",
|
|
98
|
+
"[sendora] The `secureStorage` adapter failed on read; falling back to plain AsyncStorage for this session. Tokens are preserved but no longer encrypted at rest. Check your adapter."
|
|
99
|
+
);
|
|
84
100
|
if (store) {
|
|
85
101
|
try {
|
|
86
102
|
const legacy = await store.getItem(this.prefix + k);
|
|
@@ -97,6 +113,10 @@ var Storage = class {
|
|
|
97
113
|
this.cache.set(key, value);
|
|
98
114
|
if (this.isSecure(key)) {
|
|
99
115
|
void this.secure.setItem(this.secureName(key), value).catch(() => {
|
|
116
|
+
warnOnce(
|
|
117
|
+
"secure-write-degraded",
|
|
118
|
+
"[sendora] The `secureStorage` adapter failed on write; this value fell back to plain AsyncStorage. The session is preserved but this token is no longer encrypted at rest. Check your adapter."
|
|
119
|
+
);
|
|
100
120
|
void getAsyncStorage().then((store) => {
|
|
101
121
|
if (store) store.setItem(this.prefix + key, value).catch(() => {
|
|
102
122
|
});
|
|
@@ -163,7 +183,7 @@ var Storage = class {
|
|
|
163
183
|
// package.json
|
|
164
184
|
var package_default = {
|
|
165
185
|
name: "@sendoracloud/sdk-react-native",
|
|
166
|
-
version: "1.
|
|
186
|
+
version: "1.38.0",
|
|
167
187
|
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`).",
|
|
168
188
|
type: "module",
|
|
169
189
|
main: "./dist/index.cjs",
|
|
@@ -219,7 +239,7 @@ var package_default = {
|
|
|
219
239
|
scripts: {
|
|
220
240
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
221
241
|
typecheck: "tsc --noEmit",
|
|
222
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
242
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts"
|
|
223
243
|
},
|
|
224
244
|
devDependencies: {
|
|
225
245
|
"@types/react": "^19.2.16",
|
|
@@ -451,6 +471,12 @@ function classifyAuthErrorKind(code, status) {
|
|
|
451
471
|
return "credential_in_use";
|
|
452
472
|
case "LAST_CREDENTIAL":
|
|
453
473
|
return "last_credential";
|
|
474
|
+
// ⚠ MUST be a code case, not a status one. The tail of this function is
|
|
475
|
+
// `if (status >= 400) return "invalid_credential"`, which would otherwise
|
|
476
|
+
// claim the 403 and report a step-up refusal as a bad credential —
|
|
477
|
+
// documented as "needs NEW input", which is the opposite of the truth.
|
|
478
|
+
case "RECENT_AUTH_REQUIRED":
|
|
479
|
+
return "recent_auth_required";
|
|
454
480
|
case "NOT_ANONYMOUS":
|
|
455
481
|
case "FORBIDDEN_NON_ANONYMOUS":
|
|
456
482
|
return "already_identified";
|
|
@@ -1537,13 +1563,24 @@ var Auth = class {
|
|
|
1537
1563
|
const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
|
|
1538
1564
|
return Boolean(data.confirmed);
|
|
1539
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Disable MFA.
|
|
1568
|
+
*
|
|
1569
|
+
* ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
|
|
1570
|
+
* `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
|
|
1571
|
+
*
|
|
1572
|
+
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1573
|
+
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1574
|
+
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1575
|
+
*/
|
|
1540
1576
|
async disableMfa() {
|
|
1541
|
-
await post(
|
|
1577
|
+
const res = await post(
|
|
1542
1578
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1543
1579
|
"/api/v1/auth-service/mfa/disable",
|
|
1544
1580
|
{},
|
|
1545
1581
|
this.bearerHeaders()
|
|
1546
1582
|
);
|
|
1583
|
+
await this.unwrap(res, "MFA_DISABLE_FAILED");
|
|
1547
1584
|
}
|
|
1548
1585
|
// ============================================================
|
|
1549
1586
|
// DEVICE SESSIONS (Bearer-authenticated)
|
|
@@ -1558,19 +1595,28 @@ var Auth = class {
|
|
|
1558
1595
|
const parsed = await res.json();
|
|
1559
1596
|
return Array.isArray(parsed.data) ? parsed.data : [];
|
|
1560
1597
|
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1600
|
+
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1601
|
+
* that reports success on a failure is the one place a user acts on the claim
|
|
1602
|
+
* and then stops worrying.
|
|
1603
|
+
*/
|
|
1561
1604
|
async revokeSession(sessionId) {
|
|
1562
|
-
await del(
|
|
1605
|
+
const res = await del(
|
|
1563
1606
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1564
1607
|
`/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
|
|
1565
1608
|
this.bearerHeaders()
|
|
1566
1609
|
);
|
|
1610
|
+
await this.unwrap(res, "SESSION_REVOKE_FAILED");
|
|
1567
1611
|
}
|
|
1612
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1568
1613
|
async revokeAllSessions() {
|
|
1569
|
-
await del(
|
|
1614
|
+
const res = await del(
|
|
1570
1615
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1571
1616
|
"/api/v1/auth-service/sessions/me",
|
|
1572
1617
|
this.bearerHeaders()
|
|
1573
1618
|
);
|
|
1619
|
+
await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
|
|
1574
1620
|
}
|
|
1575
1621
|
/**
|
|
1576
1622
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -1736,7 +1782,10 @@ var Auth = class {
|
|
|
1736
1782
|
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1737
1783
|
const parsed = await res.json().catch(() => ({}));
|
|
1738
1784
|
if (!res.ok || !parsed.success) {
|
|
1739
|
-
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}
|
|
1785
|
+
throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
|
|
1786
|
+
status: res.status,
|
|
1787
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1788
|
+
});
|
|
1740
1789
|
}
|
|
1741
1790
|
return parsed.data;
|
|
1742
1791
|
}
|
|
@@ -2062,7 +2111,7 @@ var Auth = class {
|
|
|
2062
2111
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
2063
2112
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
2064
2113
|
this.stopProactiveRefreshCron();
|
|
2065
|
-
await this.hooks.onAnonymousWipe();
|
|
2114
|
+
await this.hooks.onAnonymousWipe(reason);
|
|
2066
2115
|
if (reason !== "replaced") {
|
|
2067
2116
|
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
2068
2117
|
}
|
|
@@ -2538,33 +2587,31 @@ var Links = class {
|
|
|
2538
2587
|
}
|
|
2539
2588
|
}
|
|
2540
2589
|
/**
|
|
2541
|
-
* Cold-launch deferred deep
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
*
|
|
2545
|
-
*
|
|
2590
|
+
* Cold-launch deferred deep-link match.
|
|
2591
|
+
*
|
|
2592
|
+
* ⚠ NO IMPLICIT FINGERPRINTING (compliance). This used to compute a device
|
|
2593
|
+
* fingerprint on iOS when you passed no signal; it no longer does — Apple
|
|
2594
|
+
* prohibits deriving a device identifier from device signals for attribution
|
|
2595
|
+
* (App Store 5.1.2 / DPLA §3.3.9), regardless of ATT, and doing it on-device is
|
|
2596
|
+
* exactly that. Signal resolution now:
|
|
2597
|
+
* - Android: probes `react-native-play-install-referrer` — deterministic, the
|
|
2598
|
+
* preferred and compliant path.
|
|
2599
|
+
* - iOS: no deterministic signal exists (the App Store has no install referrer).
|
|
2600
|
+
* With `allowProbabilisticMatch: true` the server matches on a coarse
|
|
2601
|
+
* IP + time window (OFF by default — see `LinkMatchInput`). Otherwise this
|
|
2602
|
+
* returns `null` and you route the user through your normal onboarding UX —
|
|
2603
|
+
* which is the expected iOS cold-launch outcome, not an error.
|
|
2546
2604
|
*
|
|
2547
2605
|
* Fires `onLinkOpened` with `isDeferred: true` on success.
|
|
2548
2606
|
*/
|
|
2549
2607
|
async matchDeferred(input = {}) {
|
|
2550
|
-
|
|
2608
|
+
const fingerprintHash = input.fingerprintHash;
|
|
2551
2609
|
let installReferrer = input.installReferrer;
|
|
2552
2610
|
if (!fingerprintHash && !installReferrer) {
|
|
2553
2611
|
installReferrer = await getPlayInstallReferrer() ?? void 0;
|
|
2554
|
-
if (!installReferrer) {
|
|
2555
|
-
try {
|
|
2556
|
-
fingerprintHash = await computeDeviceFingerprint();
|
|
2557
|
-
} catch (err) {
|
|
2558
|
-
if (this.deps.debug) console.warn("[sendoracloud] fingerprint compute failed:", err);
|
|
2559
|
-
}
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
if (!fingerprintHash && !installReferrer) {
|
|
2563
|
-
throw new LinkError(
|
|
2564
|
-
"INVALID_INPUT",
|
|
2565
|
-
"links.matchDeferred: install `react-native-play-install-referrer` for Android, or ensure web crypto / expo-crypto is available for iOS fingerprint compute."
|
|
2566
|
-
);
|
|
2567
2612
|
}
|
|
2613
|
+
const canMatch = !!fingerprintHash || !!installReferrer || input.allowProbabilisticMatch === true;
|
|
2614
|
+
if (!canMatch) return null;
|
|
2568
2615
|
const body = {};
|
|
2569
2616
|
if (fingerprintHash) body.fingerprintHash = fingerprintHash;
|
|
2570
2617
|
if (installReferrer) body.installReferrer = installReferrer;
|
|
@@ -3012,9 +3059,11 @@ var SendoraSDK = class {
|
|
|
3012
3059
|
if (userId) this.storage.set(USER_ID_KEY, userId);
|
|
3013
3060
|
else this.storage.remove(USER_ID_KEY);
|
|
3014
3061
|
},
|
|
3015
|
-
onAnonymousWipe: async () => {
|
|
3016
|
-
|
|
3017
|
-
|
|
3062
|
+
onAnonymousWipe: async (reason) => {
|
|
3063
|
+
if (reason === "user" || reason === "account_deleted") {
|
|
3064
|
+
this.anonId = uuid();
|
|
3065
|
+
this.storage.set(ANON_KEY, this.anonId);
|
|
3066
|
+
}
|
|
3018
3067
|
this.userId = null;
|
|
3019
3068
|
this.storage.remove(USER_ID_KEY);
|
|
3020
3069
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
|
-
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
59
|
+
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts && tsx test/deferred-match.test.ts && tsx test/anon-id-stability.test.ts"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/react": "^19.2.16",
|