@sendoracloud/sdk-react-native 1.34.0 → 1.36.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 +107 -6
- package/dist/index.d.cts +55 -2
- package/dist/index.d.ts +55 -2
- package/dist/index.js +107 -6
- 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.36.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"
|
|
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"
|
|
268
288
|
},
|
|
269
289
|
devDependencies: {
|
|
270
290
|
"@types/react": "^19.2.16",
|
|
@@ -494,6 +514,14 @@ function classifyAuthErrorKind(code, status) {
|
|
|
494
514
|
return "account_locked";
|
|
495
515
|
case "CREDENTIAL_IN_USE":
|
|
496
516
|
return "credential_in_use";
|
|
517
|
+
case "LAST_CREDENTIAL":
|
|
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";
|
|
497
525
|
case "NOT_ANONYMOUS":
|
|
498
526
|
case "FORBIDDEN_NON_ANONYMOUS":
|
|
499
527
|
return "already_identified";
|
|
@@ -1333,6 +1361,56 @@ var Auth = class {
|
|
|
1333
1361
|
}
|
|
1334
1362
|
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1335
1363
|
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Remove one linked provider from the signed-in account.
|
|
1366
|
+
*
|
|
1367
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1368
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1369
|
+
*
|
|
1370
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1371
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1372
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1373
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1374
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1375
|
+
*
|
|
1376
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1377
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1378
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1379
|
+
* backstop, not the UX.
|
|
1380
|
+
*
|
|
1381
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1382
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1383
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1384
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1385
|
+
*/
|
|
1386
|
+
async unlink(provider) {
|
|
1387
|
+
const token = await this.getAccessToken();
|
|
1388
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
|
|
1389
|
+
const res = await del(
|
|
1390
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1391
|
+
`/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
|
|
1392
|
+
{ Authorization: `Bearer ${token}` }
|
|
1393
|
+
);
|
|
1394
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Failed to unlink identity");
|
|
1395
|
+
let parsed;
|
|
1396
|
+
try {
|
|
1397
|
+
parsed = await res.json();
|
|
1398
|
+
} catch {
|
|
1399
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
1400
|
+
}
|
|
1401
|
+
if (!parsed.success || !parsed.data) {
|
|
1402
|
+
throw new AuthError(
|
|
1403
|
+
parsed.error?.code ?? "UNLINK_FAILED",
|
|
1404
|
+
parsed.error?.message ?? "Failed to unlink identity",
|
|
1405
|
+
{ status: res.status }
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
provider: parsed.data.provider ?? provider,
|
|
1410
|
+
removed: Number(parsed.data.removed ?? 0),
|
|
1411
|
+
remaining: Number(parsed.data.remaining ?? 0)
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1336
1414
|
/**
|
|
1337
1415
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1338
1416
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1530,13 +1608,24 @@ var Auth = class {
|
|
|
1530
1608
|
const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
|
|
1531
1609
|
return Boolean(data.confirmed);
|
|
1532
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
|
+
*/
|
|
1533
1621
|
async disableMfa() {
|
|
1534
|
-
await post(
|
|
1622
|
+
const res = await post(
|
|
1535
1623
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1536
1624
|
"/api/v1/auth-service/mfa/disable",
|
|
1537
1625
|
{},
|
|
1538
1626
|
this.bearerHeaders()
|
|
1539
1627
|
);
|
|
1628
|
+
await this.unwrap(res, "MFA_DISABLE_FAILED");
|
|
1540
1629
|
}
|
|
1541
1630
|
// ============================================================
|
|
1542
1631
|
// DEVICE SESSIONS (Bearer-authenticated)
|
|
@@ -1551,19 +1640,28 @@ var Auth = class {
|
|
|
1551
1640
|
const parsed = await res.json();
|
|
1552
1641
|
return Array.isArray(parsed.data) ? parsed.data : [];
|
|
1553
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
|
+
*/
|
|
1554
1649
|
async revokeSession(sessionId) {
|
|
1555
|
-
await del(
|
|
1650
|
+
const res = await del(
|
|
1556
1651
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1557
1652
|
`/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
|
|
1558
1653
|
this.bearerHeaders()
|
|
1559
1654
|
);
|
|
1655
|
+
await this.unwrap(res, "SESSION_REVOKE_FAILED");
|
|
1560
1656
|
}
|
|
1657
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1561
1658
|
async revokeAllSessions() {
|
|
1562
|
-
await del(
|
|
1659
|
+
const res = await del(
|
|
1563
1660
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1564
1661
|
"/api/v1/auth-service/sessions/me",
|
|
1565
1662
|
this.bearerHeaders()
|
|
1566
1663
|
);
|
|
1664
|
+
await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
|
|
1567
1665
|
}
|
|
1568
1666
|
/**
|
|
1569
1667
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -1729,7 +1827,10 @@ var Auth = class {
|
|
|
1729
1827
|
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1730
1828
|
const parsed = await res.json().catch(() => ({}));
|
|
1731
1829
|
if (!res.ok || !parsed.success) {
|
|
1732
|
-
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
|
+
});
|
|
1733
1834
|
}
|
|
1734
1835
|
return parsed.data;
|
|
1735
1836
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -442,6 +442,19 @@ type LinkedIdentitiesResponse = {
|
|
|
442
442
|
identities: LinkedIdentity[];
|
|
443
443
|
hasPassword: boolean;
|
|
444
444
|
};
|
|
445
|
+
/**
|
|
446
|
+
* Result of removing one provider from the signed-in account.
|
|
447
|
+
*
|
|
448
|
+
* `removed` can exceed 1: an account may hold more than one row for a provider
|
|
449
|
+
* (distinct provider user ids across projects), and unlinking takes all of them
|
|
450
|
+
* — the floor counts them all as leaving, so reporting only one would be a lie
|
|
451
|
+
* a caller could act on.
|
|
452
|
+
*/
|
|
453
|
+
type UnlinkResult = {
|
|
454
|
+
provider: string;
|
|
455
|
+
removed: number;
|
|
456
|
+
remaining: number;
|
|
457
|
+
};
|
|
445
458
|
/**
|
|
446
459
|
* Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
|
|
447
460
|
* string-matching the ~20 raw `code`s the backend and transport can produce.
|
|
@@ -451,7 +464,7 @@ type LinkedIdentitiesResponse = {
|
|
|
451
464
|
* OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
|
|
452
465
|
* the stored refresh token exactly as they were.
|
|
453
466
|
*/
|
|
454
|
-
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "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";
|
|
455
468
|
declare class AuthError extends Error {
|
|
456
469
|
code: string;
|
|
457
470
|
/**
|
|
@@ -1034,6 +1047,29 @@ declare class Auth {
|
|
|
1034
1047
|
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
1035
1048
|
*/
|
|
1036
1049
|
listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
|
|
1050
|
+
/**
|
|
1051
|
+
* Remove one linked provider from the signed-in account.
|
|
1052
|
+
*
|
|
1053
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1054
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1055
|
+
*
|
|
1056
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1057
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1058
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1059
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1060
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1061
|
+
*
|
|
1062
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1063
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1064
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1065
|
+
* backstop, not the UX.
|
|
1066
|
+
*
|
|
1067
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1068
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1069
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1070
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1071
|
+
*/
|
|
1072
|
+
unlink(provider: string): Promise<UnlinkResult>;
|
|
1037
1073
|
/**
|
|
1038
1074
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1039
1075
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1073,6 +1109,16 @@ declare class Auth {
|
|
|
1073
1109
|
recoveryCodes: string[];
|
|
1074
1110
|
}>;
|
|
1075
1111
|
confirmMfa(code: string): Promise<boolean>;
|
|
1112
|
+
/**
|
|
1113
|
+
* Disable MFA.
|
|
1114
|
+
*
|
|
1115
|
+
* ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
|
|
1116
|
+
* `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
|
|
1117
|
+
*
|
|
1118
|
+
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1119
|
+
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1120
|
+
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1121
|
+
*/
|
|
1076
1122
|
disableMfa(): Promise<void>;
|
|
1077
1123
|
listMySessions(): Promise<Array<{
|
|
1078
1124
|
id: string;
|
|
@@ -1081,7 +1127,14 @@ declare class Auth {
|
|
|
1081
1127
|
lastUsedAt: string | null;
|
|
1082
1128
|
createdAt: string;
|
|
1083
1129
|
}>>;
|
|
1130
|
+
/**
|
|
1131
|
+
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1132
|
+
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1133
|
+
* that reports success on a failure is the one place a user acts on the claim
|
|
1134
|
+
* and then stops worrying.
|
|
1135
|
+
*/
|
|
1084
1136
|
revokeSession(sessionId: string): Promise<void>;
|
|
1137
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1085
1138
|
revokeAllSessions(): Promise<void>;
|
|
1086
1139
|
/**
|
|
1087
1140
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -2038,4 +2091,4 @@ declare class SendoraSDK {
|
|
|
2038
2091
|
}
|
|
2039
2092
|
declare const SendoraCloud: SendoraSDK;
|
|
2040
2093
|
|
|
2041
|
-
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
|
2094
|
+
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
package/dist/index.d.ts
CHANGED
|
@@ -442,6 +442,19 @@ type LinkedIdentitiesResponse = {
|
|
|
442
442
|
identities: LinkedIdentity[];
|
|
443
443
|
hasPassword: boolean;
|
|
444
444
|
};
|
|
445
|
+
/**
|
|
446
|
+
* Result of removing one provider from the signed-in account.
|
|
447
|
+
*
|
|
448
|
+
* `removed` can exceed 1: an account may hold more than one row for a provider
|
|
449
|
+
* (distinct provider user ids across projects), and unlinking takes all of them
|
|
450
|
+
* — the floor counts them all as leaving, so reporting only one would be a lie
|
|
451
|
+
* a caller could act on.
|
|
452
|
+
*/
|
|
453
|
+
type UnlinkResult = {
|
|
454
|
+
provider: string;
|
|
455
|
+
removed: number;
|
|
456
|
+
remaining: number;
|
|
457
|
+
};
|
|
445
458
|
/**
|
|
446
459
|
* Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
|
|
447
460
|
* string-matching the ~20 raw `code`s the backend and transport can produce.
|
|
@@ -451,7 +464,7 @@ type LinkedIdentitiesResponse = {
|
|
|
451
464
|
* OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
|
|
452
465
|
* the stored refresh token exactly as they were.
|
|
453
466
|
*/
|
|
454
|
-
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "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";
|
|
455
468
|
declare class AuthError extends Error {
|
|
456
469
|
code: string;
|
|
457
470
|
/**
|
|
@@ -1034,6 +1047,29 @@ declare class Auth {
|
|
|
1034
1047
|
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
1035
1048
|
*/
|
|
1036
1049
|
listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
|
|
1050
|
+
/**
|
|
1051
|
+
* Remove one linked provider from the signed-in account.
|
|
1052
|
+
*
|
|
1053
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1054
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1055
|
+
*
|
|
1056
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1057
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1058
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1059
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1060
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1061
|
+
*
|
|
1062
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1063
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1064
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1065
|
+
* backstop, not the UX.
|
|
1066
|
+
*
|
|
1067
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1068
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1069
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1070
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1071
|
+
*/
|
|
1072
|
+
unlink(provider: string): Promise<UnlinkResult>;
|
|
1037
1073
|
/**
|
|
1038
1074
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1039
1075
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1073,6 +1109,16 @@ declare class Auth {
|
|
|
1073
1109
|
recoveryCodes: string[];
|
|
1074
1110
|
}>;
|
|
1075
1111
|
confirmMfa(code: string): Promise<boolean>;
|
|
1112
|
+
/**
|
|
1113
|
+
* Disable MFA.
|
|
1114
|
+
*
|
|
1115
|
+
* ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with 403
|
|
1116
|
+
* `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`.
|
|
1117
|
+
*
|
|
1118
|
+
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1119
|
+
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1120
|
+
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1121
|
+
*/
|
|
1076
1122
|
disableMfa(): Promise<void>;
|
|
1077
1123
|
listMySessions(): Promise<Array<{
|
|
1078
1124
|
id: string;
|
|
@@ -1081,7 +1127,14 @@ declare class Auth {
|
|
|
1081
1127
|
lastUsedAt: string | null;
|
|
1082
1128
|
createdAt: string;
|
|
1083
1129
|
}>>;
|
|
1130
|
+
/**
|
|
1131
|
+
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1132
|
+
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1133
|
+
* that reports success on a failure is the one place a user acts on the claim
|
|
1134
|
+
* and then stops worrying.
|
|
1135
|
+
*/
|
|
1084
1136
|
revokeSession(sessionId: string): Promise<void>;
|
|
1137
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1085
1138
|
revokeAllSessions(): Promise<void>;
|
|
1086
1139
|
/**
|
|
1087
1140
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -2038,4 +2091,4 @@ declare class SendoraSDK {
|
|
|
2038
2091
|
}
|
|
2039
2092
|
declare const SendoraCloud: SendoraSDK;
|
|
2040
2093
|
|
|
2041
|
-
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
|
2094
|
+
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
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.36.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"
|
|
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"
|
|
223
243
|
},
|
|
224
244
|
devDependencies: {
|
|
225
245
|
"@types/react": "^19.2.16",
|
|
@@ -449,6 +469,14 @@ function classifyAuthErrorKind(code, status) {
|
|
|
449
469
|
return "account_locked";
|
|
450
470
|
case "CREDENTIAL_IN_USE":
|
|
451
471
|
return "credential_in_use";
|
|
472
|
+
case "LAST_CREDENTIAL":
|
|
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";
|
|
452
480
|
case "NOT_ANONYMOUS":
|
|
453
481
|
case "FORBIDDEN_NON_ANONYMOUS":
|
|
454
482
|
return "already_identified";
|
|
@@ -1288,6 +1316,56 @@ var Auth = class {
|
|
|
1288
1316
|
}
|
|
1289
1317
|
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1290
1318
|
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Remove one linked provider from the signed-in account.
|
|
1321
|
+
*
|
|
1322
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1323
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1324
|
+
*
|
|
1325
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1326
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1327
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1328
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1329
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1330
|
+
*
|
|
1331
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1332
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1333
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1334
|
+
* backstop, not the UX.
|
|
1335
|
+
*
|
|
1336
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1337
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1338
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1339
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1340
|
+
*/
|
|
1341
|
+
async unlink(provider) {
|
|
1342
|
+
const token = await this.getAccessToken();
|
|
1343
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
|
|
1344
|
+
const res = await del(
|
|
1345
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1346
|
+
`/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
|
|
1347
|
+
{ Authorization: `Bearer ${token}` }
|
|
1348
|
+
);
|
|
1349
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Failed to unlink identity");
|
|
1350
|
+
let parsed;
|
|
1351
|
+
try {
|
|
1352
|
+
parsed = await res.json();
|
|
1353
|
+
} catch {
|
|
1354
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
1355
|
+
}
|
|
1356
|
+
if (!parsed.success || !parsed.data) {
|
|
1357
|
+
throw new AuthError(
|
|
1358
|
+
parsed.error?.code ?? "UNLINK_FAILED",
|
|
1359
|
+
parsed.error?.message ?? "Failed to unlink identity",
|
|
1360
|
+
{ status: res.status }
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
provider: parsed.data.provider ?? provider,
|
|
1365
|
+
removed: Number(parsed.data.removed ?? 0),
|
|
1366
|
+
remaining: Number(parsed.data.remaining ?? 0)
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1291
1369
|
/**
|
|
1292
1370
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1293
1371
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1485,13 +1563,24 @@ var Auth = class {
|
|
|
1485
1563
|
const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
|
|
1486
1564
|
return Boolean(data.confirmed);
|
|
1487
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
|
+
*/
|
|
1488
1576
|
async disableMfa() {
|
|
1489
|
-
await post(
|
|
1577
|
+
const res = await post(
|
|
1490
1578
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1491
1579
|
"/api/v1/auth-service/mfa/disable",
|
|
1492
1580
|
{},
|
|
1493
1581
|
this.bearerHeaders()
|
|
1494
1582
|
);
|
|
1583
|
+
await this.unwrap(res, "MFA_DISABLE_FAILED");
|
|
1495
1584
|
}
|
|
1496
1585
|
// ============================================================
|
|
1497
1586
|
// DEVICE SESSIONS (Bearer-authenticated)
|
|
@@ -1506,19 +1595,28 @@ var Auth = class {
|
|
|
1506
1595
|
const parsed = await res.json();
|
|
1507
1596
|
return Array.isArray(parsed.data) ? parsed.data : [];
|
|
1508
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
|
+
*/
|
|
1509
1604
|
async revokeSession(sessionId) {
|
|
1510
|
-
await del(
|
|
1605
|
+
const res = await del(
|
|
1511
1606
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1512
1607
|
`/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
|
|
1513
1608
|
this.bearerHeaders()
|
|
1514
1609
|
);
|
|
1610
|
+
await this.unwrap(res, "SESSION_REVOKE_FAILED");
|
|
1515
1611
|
}
|
|
1612
|
+
/** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
|
|
1516
1613
|
async revokeAllSessions() {
|
|
1517
|
-
await del(
|
|
1614
|
+
const res = await del(
|
|
1518
1615
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1519
1616
|
"/api/v1/auth-service/sessions/me",
|
|
1520
1617
|
this.bearerHeaders()
|
|
1521
1618
|
);
|
|
1619
|
+
await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
|
|
1522
1620
|
}
|
|
1523
1621
|
/**
|
|
1524
1622
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
@@ -1684,7 +1782,10 @@ var Auth = class {
|
|
|
1684
1782
|
if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
|
|
1685
1783
|
const parsed = await res.json().catch(() => ({}));
|
|
1686
1784
|
if (!res.ok || !parsed.success) {
|
|
1687
|
-
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
|
+
});
|
|
1688
1789
|
}
|
|
1689
1790
|
return parsed.data;
|
|
1690
1791
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.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"
|
|
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"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/react": "^19.2.16",
|