@sendoracloud/sdk-react-native 1.39.1 → 1.41.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 CHANGED
@@ -30,18 +30,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AMBIGUOUS_HELD_GUEST: () => AMBIGUOUS_HELD_GUEST,
33
34
  AlreadyIdentifiedError: () => AlreadyIdentifiedError,
34
35
  Auth: () => Auth,
35
36
  AuthError: () => AuthError,
36
37
  CredentialInUseError: () => CredentialInUseError,
37
38
  EmailAlreadyTakenError: () => EmailAlreadyTakenError,
39
+ HELD_GUEST_SLOTS: () => HELD_GUEST_SLOTS,
40
+ HeldGuestLimitError: () => HeldGuestLimitError,
41
+ HeldGuestProjectMismatch: () => HeldGuestProjectMismatch,
38
42
  LinkError: () => LinkError,
39
43
  Links: () => Links,
40
44
  SendoraCloud: () => SendoraCloud,
41
45
  computeDeviceFingerprint: () => computeDeviceFingerprint,
46
+ decodeJwtPayload: () => decodeJwtPayload,
42
47
  default: () => index_default,
43
48
  extractShortcodeFromUrl: () => extractShortcodeFromUrl,
44
- getPlayInstallReferrer: () => getPlayInstallReferrer
49
+ getPlayInstallReferrer: () => getPlayInstallReferrer,
50
+ projectVerdict: () => projectVerdict
45
51
  });
46
52
  module.exports = __toCommonJS(index_exports);
47
53
  var import_react_native_get_random_values = require("react-native-get-random-values");
@@ -186,13 +192,42 @@ var Storage = class {
186
192
  });
187
193
  }
188
194
  /**
189
- * Remove every sendora_*-prefixed key from both memory AND
190
- * AsyncStorage, plus every secure key from the adapter. Uses
191
- * getAllKeys() to find keys this process never cached (e.g. orphaned
192
- * writes from a prior SDK version). Falls back to cache enumeration
193
- * if the AsyncStorage backend doesn't expose getAllKeys.
195
+ * Clear this SDK's keys from memory, AsyncStorage and the secure adapter.
196
+ *
197
+ * **With `opts.keys` (ADR-033, the `reset()` path):** removes EXACTLY the
198
+ * listed bare keys of the active namespace — minus `opts.except` — from both
199
+ * stores. An exact list is what lets `Sendora.reset()` keep the held-guest
200
+ * slots by default (a guest's refresh token is its only credential, so
201
+ * sweeping them on logout would be deletion by assumption, s58.316), and it
202
+ * never touches a sibling `sendora_<env>_` keyspace that happens to share
203
+ * the prefix. Values kept in `except` stay in the cache too.
204
+ *
205
+ * **Without `opts` (legacy):** the pre-1.41 sweep — every prefixed
206
+ * AsyncStorage key (via `getAllKeys()`, falling back to the secure-key list)
207
+ * plus every secure key from the adapter.
194
208
  */
195
- async clearAll() {
209
+ async clearAll(opts) {
210
+ if (opts) {
211
+ const except = new Set(opts.except ?? []);
212
+ const store2 = await getAsyncStorage();
213
+ for (const k of opts.keys) {
214
+ if (except.has(k)) continue;
215
+ this.cache.delete(k);
216
+ if (this.isSecure(k)) {
217
+ try {
218
+ await this.secure.removeItem(this.secureName(k));
219
+ } catch {
220
+ }
221
+ }
222
+ if (store2) {
223
+ try {
224
+ await store2.removeItem(this.prefix + k);
225
+ } catch {
226
+ }
227
+ }
228
+ }
229
+ return;
230
+ }
196
231
  this.cache.clear();
197
232
  if (this.secure) {
198
233
  for (const k of this.secureKeys) {
@@ -228,7 +263,7 @@ var Storage = class {
228
263
  // package.json
229
264
  var package_default = {
230
265
  name: "@sendoracloud/sdk-react-native",
231
- version: "1.39.1",
266
+ version: "1.41.0",
232
267
  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`).",
233
268
  type: "module",
234
269
  main: "./dist/index.cjs",
@@ -250,6 +285,7 @@ var package_default = {
250
285
  "dist",
251
286
  "examples",
252
287
  "README.md",
288
+ "CHANGELOG.md",
253
289
  "LICENSE",
254
290
  "app.plugin.js",
255
291
  "plugin"
@@ -284,7 +320,7 @@ var package_default = {
284
320
  scripts: {
285
321
  build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
286
322
  typecheck: "tsc --noEmit",
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 && tsx test/event-queue.test.ts"
323
+ 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 && tsx test/event-queue.test.ts && tsx test/anonymous-over-account.test.ts && tsx test/auth-machine-conformance.test.ts && tsx test/held-guests.test.ts"
288
324
  },
289
325
  devDependencies: {
290
326
  "@types/react": "^19.2.16",
@@ -295,10 +331,6 @@ var package_default = {
295
331
  typescript: "^5.0.0"
296
332
  },
297
333
  license: "MIT",
298
- repository: {
299
- type: "git",
300
- url: "https://github.com/sendoracloud/sdk-react-native"
301
- },
302
334
  homepage: "https://sendoracloud.com/sdks"
303
335
  };
304
336
 
@@ -423,29 +455,149 @@ function requireOptionalNativeModule(name) {
423
455
  return void 0;
424
456
  }
425
457
 
458
+ // src/project-verdict.ts
459
+ var PROJECT_CLAIM = "project";
460
+ function projectVerdict(expectedProjectId, accessJwt) {
461
+ if (expectedProjectId === void 0) return "match";
462
+ const payload = decodeJwtPayload(accessJwt);
463
+ if (payload === null) return "unknown";
464
+ if (!Object.prototype.hasOwnProperty.call(payload, PROJECT_CLAIM)) return "unknown";
465
+ const claim = payload[PROJECT_CLAIM];
466
+ if (claim === null) return "mismatch";
467
+ if (typeof claim === "string") return claim === expectedProjectId ? "match" : "mismatch";
468
+ return "mismatch";
469
+ }
470
+ function decodeJwtPayload(token) {
471
+ if (typeof token !== "string" || token.length === 0) return null;
472
+ const parts = token.split(".");
473
+ if (parts.length !== 3) return null;
474
+ const bytes = decodeBase64Url(parts[1]);
475
+ if (bytes === null) return null;
476
+ const json = decodeUtf8(bytes);
477
+ if (json === null) return null;
478
+ let parsed;
479
+ try {
480
+ parsed = JSON.parse(json);
481
+ } catch {
482
+ return null;
483
+ }
484
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
485
+ return parsed;
486
+ }
487
+ var B64_LOOKUP = (() => {
488
+ const table = new Int16Array(128).fill(-1);
489
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
490
+ for (let i = 0; i < alphabet.length; i++) table[alphabet.charCodeAt(i)] = i;
491
+ table["-".charCodeAt(0)] = 62;
492
+ table["_".charCodeAt(0)] = 63;
493
+ table["+".charCodeAt(0)] = 62;
494
+ table["/".charCodeAt(0)] = 63;
495
+ return table;
496
+ })();
497
+ function decodeBase64Url(input) {
498
+ let end = input.length;
499
+ while (end > 0 && input.charCodeAt(end - 1) === 61) end--;
500
+ if (end % 4 === 1) return null;
501
+ const out = new Uint8Array(Math.floor(end * 3 / 4));
502
+ let buffer = 0;
503
+ let bits = 0;
504
+ let o = 0;
505
+ for (let i = 0; i < end; i++) {
506
+ const code = input.charCodeAt(i);
507
+ const v = code < 128 ? B64_LOOKUP[code] : -1;
508
+ if (v < 0) return null;
509
+ buffer = buffer << 6 | v;
510
+ bits += 6;
511
+ if (bits >= 8) {
512
+ bits -= 8;
513
+ out[o++] = buffer >> bits & 255;
514
+ }
515
+ }
516
+ return o === out.length ? out : out.subarray(0, o);
517
+ }
518
+ function decodeUtf8(bytes) {
519
+ let out = "";
520
+ let i = 0;
521
+ const n = bytes.length;
522
+ while (i < n) {
523
+ const b0 = bytes[i];
524
+ let cp;
525
+ let need;
526
+ if (b0 < 128) {
527
+ cp = b0;
528
+ need = 0;
529
+ } else if (b0 >= 194 && b0 <= 223) {
530
+ cp = b0 & 31;
531
+ need = 1;
532
+ } else if (b0 >= 224 && b0 <= 239) {
533
+ cp = b0 & 15;
534
+ need = 2;
535
+ } else if (b0 >= 240 && b0 <= 244) {
536
+ cp = b0 & 7;
537
+ need = 3;
538
+ } else {
539
+ return null;
540
+ }
541
+ if (i + need >= n) return null;
542
+ for (let k = 1; k <= need; k++) {
543
+ const b = bytes[i + k];
544
+ if ((b & 192) !== 128) return null;
545
+ cp = cp << 6 | b & 63;
546
+ }
547
+ if (need === 2 && cp < 2048 || need === 3 && (cp < 65536 || cp > 1114111) || cp >= 55296 && cp <= 57343) {
548
+ return null;
549
+ }
550
+ if (cp > 65535) {
551
+ const v = cp - 65536;
552
+ out += String.fromCharCode(55296 + (v >> 10), 56320 + (v & 1023));
553
+ } else {
554
+ out += String.fromCharCode(cp);
555
+ }
556
+ i += need + 1;
557
+ }
558
+ return out;
559
+ }
560
+
426
561
  // src/auth.ts
427
562
  var import_react_native2 = require("react-native");
428
563
  var TOKEN_KEY = "auth_access_token";
429
564
  var TOKEN_EXPIRES_KEY = "auth_access_expires";
430
565
  var REFRESH_KEY = "auth_refresh_token";
431
566
  var USER_KEY = "auth_user";
567
+ var SESSION_KEY = "auth_session";
568
+ var MINT_KEY_KEY = "auth_mint_key";
569
+ var HELD_GUEST_SLOTS = 5;
570
+ var HELD_SLOT_KEYS = ["auth_held_0", "auth_held_1", "auth_held_2", "auth_held_3", "auth_held_4"];
571
+ var AUTH_SECURE_KEYS = [REFRESH_KEY, TOKEN_KEY, USER_KEY, SESSION_KEY, ...HELD_SLOT_KEYS];
572
+ var AUTH_HYDRATE_KEYS = [TOKEN_KEY, TOKEN_EXPIRES_KEY, REFRESH_KEY, USER_KEY, SESSION_KEY, MINT_KEY_KEY, ...HELD_SLOT_KEYS];
432
573
  var REFRESH_SAFETY_MS = 3e4;
574
+ var SWITCH_CREDENTIAL_MAX_AGE_MS = 5 * 6e4;
575
+ var DEAD_CODES = ["INVALID_REFRESH_TOKEN"];
576
+ var RELEASE_HELD_ON_END_REASONS = ["deleted", "revoked", "expired"];
577
+ var AMBIGUOUS_HELD_GUEST = "AMBIGUOUS_HELD_GUEST";
433
578
  function isAuthApiResponse(v) {
434
579
  if (!v || typeof v !== "object") return false;
435
580
  const r = v;
436
581
  const user = r.user;
437
582
  const tokens = r.tokens;
438
- if (!user || typeof user.id !== "string" || user.id.length === 0) return false;
583
+ if (!isAuthUserShape(user)) return false;
439
584
  if (!tokens) return false;
440
585
  if (typeof tokens.accessToken !== "string" || tokens.accessToken.length === 0) return false;
441
586
  if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.length === 0) return false;
442
587
  if (typeof tokens.expiresIn !== "number" || tokens.expiresIn <= 0) return false;
443
- if (user.isAnonymous !== void 0 && typeof user.isAnonymous !== "boolean") return false;
444
- if (user.email !== void 0 && user.email !== null && typeof user.email !== "string") return false;
445
- if (user.emailVerified !== void 0 && typeof user.emailVerified !== "boolean") return false;
446
- if (user.name !== void 0 && user.name !== null && typeof user.name !== "string") return false;
447
- if (user.signupMethod !== void 0 && user.signupMethod !== null && typeof user.signupMethod !== "string") return false;
448
- if (user.lastLoginMethod !== void 0 && user.lastLoginMethod !== null && typeof user.lastLoginMethod !== "string") return false;
588
+ return true;
589
+ }
590
+ function isAuthUserShape(user) {
591
+ if (!user || typeof user !== "object") return false;
592
+ const u = user;
593
+ if (typeof u.id !== "string" || u.id.length === 0) return false;
594
+ if (u.isAnonymous !== void 0 && typeof u.isAnonymous !== "boolean") return false;
595
+ if (u.email !== void 0 && u.email !== null && typeof u.email !== "string") return false;
596
+ if (u.emailVerified !== void 0 && typeof u.emailVerified !== "boolean") return false;
597
+ if (u.name !== void 0 && u.name !== null && typeof u.name !== "string") return false;
598
+ if (u.signupMethod !== void 0 && u.signupMethod !== null && typeof u.signupMethod !== "string") return false;
599
+ if (u.lastLoginMethod !== void 0 && u.lastLoginMethod !== null && typeof u.lastLoginMethod !== "string") return false;
600
+ if (u.project !== void 0 && u.project !== null && typeof u.project !== "string") return false;
449
601
  return true;
450
602
  }
451
603
  var EmailAlreadyTakenError = class extends Error {
@@ -492,6 +644,11 @@ function readCollisionDetails(parsed) {
492
644
  collision: d.collision === "identity" || d.collision === "email" ? d.collision : void 0
493
645
  };
494
646
  }
647
+ function readEndReason(parsed) {
648
+ const details = parsed?.error?.details;
649
+ const v = details?.endReason;
650
+ return v === "expired" || v === "revoked" || v === "evicted" || v === "deleted" || v === "reuse" || v === "unknown" ? v : void 0;
651
+ }
495
652
  var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
496
653
  "network",
497
654
  "server",
@@ -534,6 +691,9 @@ function classifyAuthErrorKind(code, status) {
534
691
  case "PLAY_GAMES_UNAVAILABLE":
535
692
  case "ENTITLEMENT_ERROR":
536
693
  case "tier_required":
694
+ // ADR-033 §3.5 [2c]: the two project-guard refusals reuse the `config` kind.
695
+ case "SESSION_WITHHELD":
696
+ case "PROJECT_MISMATCH":
537
697
  return "config";
538
698
  case "UNAUTHORIZED":
539
699
  case "INVALID_CREDENTIALS":
@@ -559,11 +719,30 @@ var AuthError = class extends Error {
559
719
  this.name = "AuthError";
560
720
  this.code = code;
561
721
  this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
562
- this.retryable = RETRYABLE_KINDS.has(this.kind);
722
+ this.retryable = opts?.retryable ?? RETRYABLE_KINDS.has(this.kind);
563
723
  this.retryAfterSeconds = opts?.retryAfterSeconds;
564
724
  this.status = opts?.status;
725
+ this.endReason = opts?.endReason;
726
+ }
727
+ };
728
+ var HeldGuestLimitError = class extends AuthError {
729
+ constructor(message = `This device already holds ${HELD_GUEST_SLOTS} guest accounts; restore or discard one before minting another`) {
730
+ super("HELD_GUEST_LIMIT", message, { kind: "unknown" });
731
+ this.name = "HeldGuestLimitError";
732
+ }
733
+ };
734
+ var HeldGuestProjectMismatch = class extends AuthError {
735
+ constructor(userId, expectedProjectId, actualProjectId) {
736
+ super("HELD_GUEST_PROJECT_MISMATCH", "The held guest belongs to a different project than this app is bound to", { kind: "unknown" });
737
+ this.name = "HeldGuestProjectMismatch";
738
+ this.userId = userId;
739
+ this.expectedProjectId = expectedProjectId;
740
+ this.actualProjectId = actualProjectId;
565
741
  }
566
742
  };
743
+ function withheldError(code, message) {
744
+ return new AuthError(code, message, { kind: "config", retryable: false });
745
+ }
567
746
  function asAuthError(err) {
568
747
  if (err instanceof AuthError) return err;
569
748
  if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
@@ -624,11 +803,113 @@ async function fetchPlayGamesAuthCodeNative() {
624
803
  }
625
804
  return { serverAuthCode };
626
805
  }
806
+ var LOGIN_PATH = "/api/v1/auth-service/login";
807
+ var SOCIAL_LOGIN_PATH = "/api/v1/auth-service/login/social";
808
+ var GAME_CENTER_LOGIN_PATH = "/api/v1/auth-service/login/game-center";
809
+ var PLAY_GAMES_LOGIN_PATH = "/api/v1/auth-service/login/play-games";
810
+ var MFA_CHALLENGE_PATH = "/api/v1/auth-service/mfa/challenge";
811
+ var MAGIC_LINK_VERIFY_PATH = "/api/v1/auth-service/magic-link/verify";
812
+ var EMAIL_OTP_VERIFY_PATH = "/api/v1/auth-service/email-otp/verify";
813
+ var PASSKEY_FINISH_PATH = "/api/v1/auth-service/passkeys/authenticate/finish";
814
+ var LINK_EMAIL_PATH = "/api/v1/auth-service/me/link/email";
815
+ var LINK_SOCIAL_PATH = "/api/v1/auth-service/me/link/social";
816
+ var LINK_GAME_CENTER_PATH = "/api/v1/auth-service/me/link/game-center";
817
+ var LINK_PLAY_GAMES_PATH = "/api/v1/auth-service/me/link/play-games";
818
+ var REFRESH_PATH = "/api/v1/auth-service/token/refresh";
819
+ var REVOKE_PATH = "/api/v1/auth-service/token/revoke";
820
+ var SOCIAL_KINDS = /* @__PURE__ */ new Set(["google", "github", "apple", "microsoft", "linkedin", "facebook", "twitter", "discord"]);
821
+ function signInWire(credential) {
822
+ switch (credential.kind) {
823
+ case "password":
824
+ return { path: LOGIN_PATH, body: { email: credential.email, password: credential.password } };
825
+ case "gameCenter": {
826
+ const { kind: _k, ...payload } = credential;
827
+ return { path: GAME_CENTER_LOGIN_PATH, body: { ...payload } };
828
+ }
829
+ case "playGames":
830
+ return { path: PLAY_GAMES_LOGIN_PATH, body: { serverAuthCode: credential.serverAuthCode } };
831
+ case "magicLink":
832
+ return { path: MAGIC_LINK_VERIFY_PATH, body: { token: credential.token } };
833
+ case "emailOtp":
834
+ return { path: EMAIL_OTP_VERIFY_PATH, body: { email: credential.email, code: credential.code } };
835
+ case "passkey": {
836
+ const a = credential.assertion;
837
+ const body = "response" in a ? { ...a } : { response: a, userId: null };
838
+ return { path: PASSKEY_FINISH_PATH, body };
839
+ }
840
+ case "mfa":
841
+ return { path: MFA_CHALLENGE_PATH, body: { challengeToken: credential.challengeToken, code: credential.code } };
842
+ default: {
843
+ const { kind, ...rest } = credential;
844
+ return { path: SOCIAL_LOGIN_PATH, body: { provider: kind, ...stripUndefined(rest) } };
845
+ }
846
+ }
847
+ }
848
+ function linkWire(credential) {
849
+ switch (credential.kind) {
850
+ case "password":
851
+ return { path: LINK_EMAIL_PATH, body: { email: credential.email, password: credential.password } };
852
+ case "gameCenter": {
853
+ const { kind: _k, ...payload } = credential;
854
+ return { path: LINK_GAME_CENTER_PATH, body: { ...payload } };
855
+ }
856
+ case "playGames":
857
+ return { path: LINK_PLAY_GAMES_PATH, body: { serverAuthCode: credential.serverAuthCode } };
858
+ case "magicLink":
859
+ case "emailOtp":
860
+ case "passkey":
861
+ case "mfa":
862
+ return null;
863
+ default: {
864
+ const { kind, ...rest } = credential;
865
+ return SOCIAL_KINDS.has(kind) ? { path: LINK_SOCIAL_PATH, body: { provider: kind, ...stripUndefined(rest) } } : null;
866
+ }
867
+ }
868
+ }
869
+ function stripUndefined(o) {
870
+ const out = {};
871
+ for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = v;
872
+ return out;
873
+ }
874
+ function providerLabel(credential) {
875
+ switch (credential.kind) {
876
+ case "password":
877
+ return "password";
878
+ case "gameCenter":
879
+ return "gamecenter";
880
+ case "playGames":
881
+ return "playgames";
882
+ case "magicLink":
883
+ return "magic_link";
884
+ case "emailOtp":
885
+ return "email_otp";
886
+ default:
887
+ return credential.kind;
888
+ }
889
+ }
890
+ function generateMintKey() {
891
+ const g = globalThis;
892
+ if (!g.crypto?.getRandomValues) return null;
893
+ const bytes = new Uint8Array(16);
894
+ g.crypto.getRandomValues(bytes);
895
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
896
+ }
627
897
  var Auth = class {
628
898
  constructor(hooks) {
899
+ // ── The ONE identity slot (written only by commit()) ──────────────────
629
900
  this.user = null;
630
901
  this.accessToken = null;
631
902
  this.accessExpiresAt = 0;
903
+ /** The in-memory mirror of the K held slots (index = slot number). Written only by commit(). */
904
+ this.held = HELD_SLOT_KEYS.map(() => null);
905
+ /** Non-null ⇔ `status() === "withheld"` (ADR-033 [2c]). Never persisted. */
906
+ this.withheld = null;
907
+ /** The refresh token the withheld record carried — the CAS key of `discardWithheld()`. */
908
+ this.withheldRefresh = null;
909
+ /** B15: held guests whose `POST /guest/held` report has not yet returned 2xx. Persisted in `auth_session`. */
910
+ this.heldReportsPending = /* @__PURE__ */ new Set();
911
+ /** The in-flight identity-changing proposal; the proactive cron pauses while it is set. */
912
+ this.attempt = null;
632
913
  /**
633
914
  * Set once we have PROOF that this device's clock runs fast, which
634
915
  * releases the `exp` guard in `canServeCachedToken`.
@@ -653,15 +934,10 @@ var Auth = class {
653
934
  this.inflight = Promise.resolve();
654
935
  this.refreshInflight = null;
655
936
  /**
656
- * Wave 62 — refresh failure back-pressure.
657
- *
658
- * Consecutive network-timeout failures don't wipe identity (refresh
659
- * may still be valid; iOS NSURLSession was just stalled) but do
660
- * back off so the next caller doesn't immediately fire another
661
- * fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
662
- * the counter resets + retries are allowed. AppState foreground
663
- * also clears it so a user returning to the app gets an immediate
664
- * fresh attempt.
937
+ * Wave 62 — refresh failure back-pressure. Consecutive network-timeout
938
+ * failures don't wipe identity (refresh may still be valid; iOS NSURLSession
939
+ * was just stalled) but do back off so the next caller doesn't immediately
940
+ * fire another fetch that will also time out. AppState foreground clears it.
665
941
  */
666
942
  this.refreshFailureCount = 0;
667
943
  this.refreshCooldownUntil = 0;
@@ -671,107 +947,237 @@ var Auth = class {
671
947
  this.lastDeletionCancelled = null;
672
948
  this.lastAnonRetirement = null;
673
949
  this.lastTakeover = null;
674
- /**
675
- * Anon refresh token captured at `signIn()` time, stashed keyed to the
676
- * issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
677
- * for device-takeover (s58.111). Before this, the MFA branch wiped the anon
678
- * identity before the challenge resolved → the takeover hint was lost → one
679
- * device ended with two user_ids + duplicate pushes (audit s58.203 fix).
680
- */
681
- this.pendingAnonTakeover = null;
682
- /**
683
- * True once `hydrate()` has run. Distinguishes "no session restored YET"
684
- * from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
685
- */
950
+ /** True once `hydrate()` has run. Gates `onAuthStateChanged`'s replay-on-subscribe. */
686
951
  this.hydrated = false;
687
952
  /**
688
- * Proactive refresh (s58.72). Fires when access-token age crosses
689
- * ~80% of TTL. Pairs with the AppState foreground listener so a
690
- * backgrounded app coming back to active state immediately runs
691
- * a tick — much better UX than waiting 60s for the next interval.
692
- * Stopped on signOut() / wipeLocalIdentity().
953
+ * Proactive refresh (s58.72). Fires when access-token age crosses ~80% of
954
+ * TTL, repairs a stale deadline the combined guard can see, and performs the
955
+ * single gap-fill refresh for an `unknown` record. Armed ONLY by `commit()`
956
+ * (install / hydrate), stopped by the clear and withhold paths. ⚠ Never
957
+ * starts a refresh while an identity attempt is pending (ADR-033 §3.3
958
+ * scheduling rule 2) and never refreshes a held guest.
693
959
  */
694
960
  this.proactiveRefreshTimer = null;
695
961
  this.proactiveRefreshAppStateUnsub = null;
962
+ this.pendingWipeHook = null;
696
963
  this.hooks = hooks;
697
964
  this.readyPromise = new Promise((res) => {
698
965
  this.resolveReady = res;
699
966
  });
700
967
  }
968
+ // ============================================================
969
+ // HYDRATE (ADR-033 §3.3 rows 1–4; INV-9 hydrate coherence)
970
+ // ============================================================
701
971
  /**
702
- * Re-hydrate session from storage. Called by the parent SDK during
703
- * init() after Storage.hydrate() has run. Marks the internal ready
704
- * promise as resolved so queued auth calls unblock.
972
+ * Restore the session from storage. Called by the parent SDK during
973
+ * init() after Storage.hydrate() has run. Disk-only, never waits on the
974
+ * network; resolves the internal ready gate so queued auth calls unblock.
975
+ *
976
+ * Reads `auth_session` FIRST, then the four frozen keys. When the frozen keys
977
+ * carry a refresh token that differs from the record's, the FROZEN keys win:
978
+ * every SDK version writes them, so after a 1.41 → 1.40 → 1.41 round trip
979
+ * they are the fresher copy and the stale `auth_session` must not resurrect a
980
+ * rotated-away token. The record is then written forward from them.
705
981
  *
706
- * s58.47 — restore the user even when the cached ACCESS token is
707
- * missing/expired, as long as USER_KEY + REFRESH_KEY are present.
708
- * Pre-s58.47 this required BOTH USER_KEY and TOKEN_KEY, which
709
- * caused a cold-start regression: if the access token rotated out
710
- * of storage (TTL elapsed, app killed between persists, partial
711
- * write, AsyncStorage eviction) the user appeared signed out even
712
- * though their refresh chain was still alive in storage. Hosts
713
- * then called `signInAnonymously()` on every launch and minted a
714
- * fresh anon user, fragmenting analytics across sessions
715
- * (Pulse News symptom). With the refresh token present, the next
716
- * `getAccessToken()` call will silently refresh + restore a valid
717
- * access token without disturbing identity.
982
+ * Coherence (INV-9): user ∧ refresh → judged by the project guard
983
+ * (match → installed; mismatch → `withheld`, ZERO writes; unknown → stay
984
+ * `unknown`, one gap-fill refresh re-judges it); refresh ∧ ¬user → `unknown`
985
+ * (LIMBO_A — never `signed_out`, never a mint; the refresh token is the only
986
+ * thing that can recover the account, 1.28.0); user ∧ ¬refresh → the stale
987
+ * blob is cleared → `signed_out`. Held slots: a slot whose `userId` equals the
988
+ * current user is dropped (current wins); a slot that fails to parse is KEPT
989
+ * and ignored, never cleared (P11).
718
990
  */
719
991
  hydrate() {
720
- const cachedUser = this.hooks.storage.get(USER_KEY);
721
- const cachedToken = this.hooks.storage.get(TOKEN_KEY);
722
- const cachedRefresh = this.hooks.storage.get(REFRESH_KEY);
723
- const cachedExpires = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
724
- if (cachedUser && (cachedToken || cachedRefresh)) {
992
+ const disk = this.readDiskSession();
993
+ this.heldReportsPending = new Set(disk.heldReportsPending);
994
+ const hadRefresh = !!disk.refreshToken;
995
+ if (disk.refreshToken && disk.user) {
996
+ const verdict = this.verdictFor(disk.user, disk.accessToken);
997
+ if (verdict === "mismatch") {
998
+ this.enterWithheld(disk.user, disk.accessToken, disk.refreshToken);
999
+ } else if (verdict === "match") {
1000
+ this.commit({
1001
+ kind: "install",
1002
+ reason: "hydrate",
1003
+ record: {
1004
+ user: disk.user,
1005
+ accessToken: disk.accessToken ?? "",
1006
+ refreshToken: disk.refreshToken,
1007
+ expiresIn: 0
1008
+ },
1009
+ accessExpiresAt: disk.accessToken ? disk.accessExpiresAt : 0,
1010
+ writeForward: disk.legacyOnly
1011
+ });
1012
+ } else {
1013
+ this.startProactiveRefreshCron();
1014
+ }
1015
+ } else if (disk.refreshToken) {
1016
+ this.startProactiveRefreshCron();
1017
+ } else if (disk.user || this.hooks.storage.get(USER_KEY) || this.hooks.storage.get(SESSION_KEY)) {
1018
+ this.commit({ kind: "clear", reason: "stale_blob" });
1019
+ }
1020
+ this.hydrateHeldSlots();
1021
+ this.hydrated = true;
1022
+ this.resolveReady();
1023
+ if (hadRefresh && this.status() === "unknown") {
1024
+ setTimeout(() => {
1025
+ void this.refreshAccessToken();
1026
+ }, 0);
1027
+ }
1028
+ this.retryPendingHeldReports();
1029
+ }
1030
+ /** Read `auth_session` + the four frozen keys and reconcile them (see `hydrate()`). */
1031
+ readDiskSession() {
1032
+ const st = this.hooks.storage;
1033
+ let record = null;
1034
+ const raw = st.get(SESSION_KEY);
1035
+ if (raw) {
1036
+ try {
1037
+ const parsed = JSON.parse(raw);
1038
+ if (parsed && typeof parsed === "object" && parsed.v === 2) record = parsed;
1039
+ } catch {
1040
+ }
1041
+ }
1042
+ const legacyUserRaw = st.get(USER_KEY);
1043
+ const legacyToken = st.get(TOKEN_KEY);
1044
+ const legacyRefresh = st.get(REFRESH_KEY);
1045
+ const legacyExpires = st.get(TOKEN_EXPIRES_KEY);
1046
+ let legacyUser = null;
1047
+ if (legacyUserRaw) {
725
1048
  try {
726
- const parsed = JSON.parse(cachedUser);
727
- if (typeof parsed.id !== "string" || parsed.id.length === 0) throw new Error("invalid cache");
728
- this.user = parsed;
729
- if (cachedToken) {
730
- this.accessToken = cachedToken;
731
- this.accessExpiresAt = cachedExpires ? Number(cachedExpires) : 0;
1049
+ const parsed = JSON.parse(legacyUserRaw);
1050
+ if (isAuthUserShape(parsed)) legacyUser = parsed;
1051
+ } catch {
1052
+ }
1053
+ }
1054
+ const pending = Array.isArray(record?.heldReportsPending) ? record.heldReportsPending.filter((x) => typeof x === "string") : [];
1055
+ const recordRefresh = typeof record?.refreshToken === "string" && record.refreshToken ? record.refreshToken : null;
1056
+ const recordUser = record?.user && isAuthUserShape(record.user) ? record.user : null;
1057
+ const useRecord = recordRefresh !== null && legacyRefresh !== null && legacyRefresh === recordRefresh;
1058
+ if (useRecord) {
1059
+ return {
1060
+ user: recordUser ?? legacyUser,
1061
+ accessToken: typeof record.accessToken === "string" && record.accessToken ? record.accessToken : legacyToken,
1062
+ accessExpiresAt: typeof record.accessExpiresAt === "number" ? record.accessExpiresAt : legacyExpires ? Number(legacyExpires) : 0,
1063
+ refreshToken: recordRefresh,
1064
+ heldReportsPending: pending,
1065
+ legacyOnly: false
1066
+ };
1067
+ }
1068
+ return {
1069
+ user: legacyUser,
1070
+ accessToken: legacyToken,
1071
+ accessExpiresAt: legacyExpires ? Number(legacyExpires) : 0,
1072
+ refreshToken: legacyRefresh,
1073
+ heldReportsPending: pending,
1074
+ legacyOnly: legacyRefresh !== null
1075
+ };
1076
+ }
1077
+ /** Load the K slots into the in-memory mirror; dedupe against the current user (current wins); keep unparseable slots untouched. */
1078
+ hydrateHeldSlots() {
1079
+ const seen = /* @__PURE__ */ new Set();
1080
+ for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
1081
+ const rec = this.readSlot(i);
1082
+ if (!rec) continue;
1083
+ if (this.user && rec.userId === this.user.id) {
1084
+ this.writeSlot(i, null);
1085
+ continue;
1086
+ }
1087
+ if (seen.has(rec.userId)) {
1088
+ const j = this.held.findIndex((h) => h?.userId === rec.userId);
1089
+ const other = this.held[j];
1090
+ if (rec.heldAt >= other.heldAt) {
1091
+ this.writeSlot(j, null);
1092
+ this.held[i] = rec;
732
1093
  } else {
733
- this.accessToken = null;
734
- this.accessExpiresAt = 0;
1094
+ this.writeSlot(i, null);
735
1095
  }
736
- this.hooks.onIdentityChange(this.user.id);
737
- } catch {
738
- this.hooks.storage.remove(USER_KEY);
739
- this.hooks.storage.remove(TOKEN_KEY);
740
- this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
1096
+ continue;
741
1097
  }
1098
+ seen.add(rec.userId);
1099
+ this.held[i] = rec;
742
1100
  }
743
- this.hydrated = true;
744
- if (this.user && this.hooks.storage.get(REFRESH_KEY)) this.startProactiveRefreshCron();
745
- this.resolveReady();
746
1101
  }
747
- getCurrentUser() {
748
- return this.user;
1102
+ /** Parse one slot off disk. `null` for an empty OR unparseable slot (the latter is left on disk, P11). */
1103
+ readSlot(i) {
1104
+ const raw = this.hooks.storage.get(HELD_SLOT_KEYS[i]);
1105
+ if (!raw) return null;
1106
+ try {
1107
+ const p = JSON.parse(raw);
1108
+ if (!p || typeof p !== "object" || p.v !== 1) return null;
1109
+ if (typeof p.userId !== "string" || !p.userId) return null;
1110
+ if (typeof p.refreshToken !== "string" || !p.refreshToken) return null;
1111
+ return {
1112
+ v: 1,
1113
+ userId: p.userId,
1114
+ sid: typeof p.sid === "string" ? p.sid : "",
1115
+ refreshToken: p.refreshToken,
1116
+ tokenAtHold: typeof p.tokenAtHold === "string" && p.tokenAtHold ? p.tokenAtHold : p.refreshToken,
1117
+ heldAt: typeof p.heldAt === "number" && Number.isFinite(p.heldAt) ? p.heldAt : 0,
1118
+ project: typeof p.project === "string" ? p.project : null,
1119
+ autoRestore: p.autoRestore !== false
1120
+ };
1121
+ } catch {
1122
+ return null;
1123
+ }
749
1124
  }
1125
+ // ============================================================
1126
+ // STATUS + READINESS (ADR-033 §3.5)
1127
+ // ============================================================
750
1128
  /**
751
- * Subscribe to device-takeover events (s58.111+). Backend retires
752
- * the anonymous user_id whenever a previously-anon device signs in
753
- * to an identified account. Use this to clean up any local mirror
754
- * keyed by the anonymous id — your own users table, an analytics
755
- * cache, an audience filter — without needing a webhook receiver.
756
- *
757
- * Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
758
- * `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
759
- * `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
760
- * completion (`consumeSsoFragment`) — because it is fired centrally from
761
- * `persist()` whenever the response carries `retiredAnonUserId`.
1129
+ * The closed boot/identity state. Synchronous, never waits on the network.
762
1130
  *
763
- * ⚠ The two gaming paths are the ones that matter most here, and were
764
- * missing from this list before 1.31.0. When the presented player identity
765
- * already belongs to another account, the sign-in ADOPTS that account and
766
- * the anonymous account on this device is retired — its row is DELETED
767
- * server-side. This listener is the only client-side signal that happened;
768
- * the call itself resolves successfully. If you would rather it not happen
769
- * at all, pass `onCredentialInUse: "reject"`.
1131
+ * - `unknown` — not hydrated yet, OR a refresh token is on disk but the
1132
+ * user is not readable / not yet judged (the gap-fill
1133
+ * refresh settles it). Session-installing verbs QUEUE here.
1134
+ * - `signed_out` — no refresh token. The only state a mint executes in.
1135
+ * - `anonymous` / `identified` — a live session of that kind.
1136
+ * - `withheld` — a stored session refused by `init({expectedProjectId})`.
770
1137
  *
771
- * Returns an unsubscribe function.
1138
+ * ⚠ This is the ONLY way to tell `withheld` and `unknown` from `signed_out`:
1139
+ * `getUserSync()` is `null` and `isReady()` is `true` in all three. Consumers
1140
+ * MUST default-branch — a later value ships opt-in-gated or in a MAJOR.
1141
+ */
1142
+ status() {
1143
+ if (this.withheld) return "withheld";
1144
+ if (!this.hydrated) return "unknown";
1145
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
1146
+ if (!refresh) return "signed_out";
1147
+ if (!this.user) return "unknown";
1148
+ return this.user.isAnonymous ? "anonymous" : "identified";
1149
+ }
1150
+ /** Resolves after hydrate with the settled status — possibly `unknown` or `withheld`. Never waits on the network. */
1151
+ async authStateReady() {
1152
+ await this.readyPromise;
1153
+ return this.status();
1154
+ }
1155
+ getCurrentUser() {
1156
+ return this.user;
1157
+ }
1158
+ /** Alias of `getCurrentUser()` gated on hydration (parity with `Sendora.getUserSync()`). */
1159
+ getUserSync() {
1160
+ return this.hydrated ? this.user : null;
1161
+ }
1162
+ /** True once `hydrate()` has run. `true` in `withheld` and `unknown` too — read `status()`. */
1163
+ isReady() {
1164
+ return this.hydrated;
1165
+ }
1166
+ // ============================================================
1167
+ // LISTENERS
1168
+ // ============================================================
1169
+ /**
1170
+ * Subscribe to device-takeover events (s58.111+). LEGACY after ADR-033: a
1171
+ * wave-1 SDK never sends the guest hint, so the server never retires a guest
1172
+ * for its sign-ins and this fires only if the SERVER reports a retirement.
1173
+ * Kept callable; replace your handler with `onAuthStateChanged` +
1174
+ * `guest_held` / `getHeldGuests()`. Returns an unsubscribe function.
772
1175
  *
773
- * Listeners are best-effort: errors thrown inside them are swallowed
774
- * + logged in dev mode. Auth state never depends on listener success.
1176
+ * Historically the account-DELETING paths were the gaming sign-ins —
1177
+ * `signInWithGameCenter` / `signInWithPlayGames` (and `loginSocial`) adopting
1178
+ * an existing account retired this device's guest row while the call itself
1179
+ * resolved successfully. On 1.41.0 those calls HOLD the guest instead; a
1180
+ * takeover can only be reported for a sign-in that reached the legacy path.
775
1181
  */
776
1182
  onDeviceTakeover(listener) {
777
1183
  this.takeoverListeners.add(listener);
@@ -780,36 +1186,55 @@ var Auth = class {
780
1186
  };
781
1187
  }
782
1188
  /**
783
- * Subscribe to every auth-state transition (1.28.0) — the single stream that
784
- * answers "what happened to my session?". Firebase `onAuthStateChanged` /
785
- * Supabase `onAuthStateChange` parity.
1189
+ * Subscribe to every auth-state transition — the single stream that answers
1190
+ * "what happened to my session?". Firebase `onAuthStateChanged` / Supabase
1191
+ * `onAuthStateChange` parity.
786
1192
  *
787
- * The reason it exists: a session that dies in the BACKGROUND (the server
788
- * rejected the stored refresh token) used to emit no signal whatsoever, so
789
- * an app could not distinguish it from a deliberate sign-out and only
790
- * noticed when `getAccessToken()` began returning null. That transition is
791
- * now `{ event: "signed_out", reason: "session_expired" }`.
792
- *
793
- * Fires the CURRENT state immediately on subscribe once `init()` has
794
- * hydrated (a `signed_in` when a session was restored from disk — including
795
- * offline, since hydrate is disk-only), so a subscriber never misses the
796
- * state it started in. Nothing is emitted for a signed-out cold start.
1193
+ * Replays the SETTLED boot state exactly once on subscribe, once `init()` has
1194
+ * hydrated, as `initial{status,user,withheld?}` — including `signed_out`,
1195
+ * `unknown` and `withheld`, so a subscriber can never confuse "no session"
1196
+ * with "not hydrated yet". With a live session (`anonymous` / `identified`)
1197
+ * `initial` is FOLLOWED by the legacy `signed_in{user}` replay that
1198
+ * 1.28.0–1.40.0 sent (kept — ADR-023); new code should read `initial` and
1199
+ * ignore a `signed_in` that carries no `cause` right after it.
797
1200
  *
798
1201
  * A failed sign-in emits NOTHING: the session is unchanged, so there is no
799
1202
  * transition to report — the rejected promise is the whole story.
800
1203
  *
801
- * Listeners are best-effort; a throwing listener is swallowed (dev-logged)
802
- * and never affects auth state. Returns an unsubscribe function.
1204
+ * ⚠ This is a TRANSITION stream, not a health or connectivity signal. A
1205
+ * transient failure of the background token refresh (offline, timeout, 429,
1206
+ * 5xx) emits nothing: the stored refresh token is kept and the refresh retried
1207
+ * with backoff. Only a server-REJECTED refresh token produces `signed_out`
1208
+ * (reason `session_expired`, or the server's own `revoked` / `deleted`). For
1209
+ * liveness read `getAccessToken()` (null = no usable token right now).
1210
+ *
1211
+ * Replacing a guest with an account emits `guest_held` then ONE `signed_in`
1212
+ * — no `signed_out`. Listeners are best-effort; a throwing listener is
1213
+ * swallowed (dev-logged). Returns an unsubscribe function.
803
1214
  */
804
1215
  onAuthStateChanged(listener) {
805
1216
  this.authStateListeners.add(listener);
806
- if (this.hydrated && this.user) {
807
- const user = this.user;
1217
+ if (this.hydrated) {
1218
+ const status = this.status();
1219
+ const change = {
1220
+ event: "initial",
1221
+ status,
1222
+ user: this.user,
1223
+ ...this.withheld ? { withheld: this.withheld } : {}
1224
+ };
808
1225
  try {
809
- listener({ event: "signed_in", user });
1226
+ listener(change);
810
1227
  } catch (err) {
811
1228
  if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
812
1229
  }
1230
+ const user = this.user;
1231
+ if (user && (status === "anonymous" || status === "identified") && this.authStateListeners.has(listener)) {
1232
+ try {
1233
+ listener({ event: "signed_in", user });
1234
+ } catch (err) {
1235
+ if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
1236
+ }
1237
+ }
813
1238
  }
814
1239
  return () => {
815
1240
  this.authStateListeners.delete(listener);
@@ -825,16 +1250,10 @@ var Auth = class {
825
1250
  }
826
1251
  }
827
1252
  }
828
- /**
829
- * Inspect the most recent device-takeover event observed by the
830
- * SDK. Cleared on signOut. Returns null when no takeover has
831
- * happened during this session — useful for late subscribers or
832
- * post-sign-in side-effects (e.g. app-launch hydrate).
833
- */
1253
+ /** The most recent device-takeover event observed, or null. Cleared on signOut. */
834
1254
  getLastDeviceTakeover() {
835
1255
  return this.lastTakeover;
836
1256
  }
837
- /** Internal — fan out to subscribers + cache the latest. */
838
1257
  fireDeviceTakeover(retiredAnonUserId, identifiedUserId) {
839
1258
  const evt = { retiredAnonUserId, identifiedUserId, at: /* @__PURE__ */ new Date() };
840
1259
  this.lastTakeover = evt;
@@ -849,10 +1268,8 @@ var Auth = class {
849
1268
  /**
850
1269
  * Subscribe to deletion-cancelled events (s58.269): fires when a sign-in
851
1270
  * cancelled a pending self-service account deletion within its grace window
852
- * (the account is restored, same user_id). Use it to show "your account
853
- * deletion was cancelled" + reconcile local state. Fires from signIn /
854
- * loginSocial / signInWithGameCenter / signInWithPlayGames. Returns an
855
- * unsubscribe function; listener errors are swallowed (best-effort).
1271
+ * (the account is restored, same user_id). Also surfaced as
1272
+ * `SignInResult.reactivated`. Returns an unsubscribe function.
856
1273
  */
857
1274
  onDeletionCancelled(listener) {
858
1275
  this.deletionCancelledListeners.add(listener);
@@ -865,19 +1282,15 @@ var Auth = class {
865
1282
  return this.lastDeletionCancelled;
866
1283
  }
867
1284
  /**
868
- * What the last sign-in did with this device's guest account, or null if no
869
- * sign-in has happened this session (s58.278).
870
- *
871
- * The one worth acting on is `"preserved"`: a guest account was presented,
872
- * was NOT retired, and is therefore still alive server-side — so "recover
873
- * your other account" is a real offer rather than a guess. `"retired"` means
874
- * migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
875
- * reconcile. Cleared on signOut.
1285
+ * What the last sign-in did with this device's guest account (s58.278).
1286
+ * LEGACY: a wave-1 SDK never presents a guest token, so a current backend
1287
+ * answers `"none"` for every sign-in. Read `getHeldGuests()` instead — the
1288
+ * guest is on this device, held. Recorded only when the server states the
1289
+ * field. Cleared on signOut.
876
1290
  */
877
1291
  getLastAnonRetirement() {
878
1292
  return this.lastAnonRetirement;
879
1293
  }
880
- /** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
881
1294
  fireDeletionCancelled(userId) {
882
1295
  const evt = { userId, at: /* @__PURE__ */ new Date() };
883
1296
  this.lastDeletionCancelled = evt;
@@ -889,42 +1302,9 @@ var Auth = class {
889
1302
  }
890
1303
  }
891
1304
  }
892
- /**
893
- * Returns a VALID (non-expired) access token, or null.
894
- *
895
- * Hard contract (matches sdk-ios / sdk-android, and the industry
896
- * token-client pattern — Supabase `_callRefreshToken`, Auth0
897
- * `getTokenSilently`, Firebase `getIdToken`): this method NEVER
898
- * returns a token past its `exp`. Callers attach the result as a
899
- * Bearer to arbitrary resource servers — including third-party
900
- * backends (PostgREST/Supabase) that have NO knowledge of Sendora's
901
- * own refresh grace window — so a token must be either fresh or
902
- * absent. A token expired by hours is useless and yields a 401.
903
- *
904
- * Resolution order:
905
- * 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
906
- * before BOTH the tracked deadline and the token's own `exp`) →
907
- * return it. Fast path, no I/O. See `canServeCachedToken` for why
908
- * both are required and when the second is released.
909
- * Pass `{ forceRefresh: true }` to skip this step when the caller
910
- * knows the tracked deadline is wrong.
911
- * 2. No cached token AND no refresh token in storage → genuinely
912
- * signed out → null.
913
- * 3. Expired/near-expiry but a refresh chain exists:
914
- * a. A refresh is already inflight → AWAIT it and return the
915
- * fresh token. Concurrent callers coalesce onto the single
916
- * in-flight `/token/refresh` (single-flight) — nobody is
917
- * handed a stale token. The inflight fetch is
918
- * AbortController-bounded (http.ts caps `/auth-service/*` at
919
- * 10s), so awaiting can NEVER hang the JS bridge. That
920
- * bounded-fetch guarantee — NOT the old stale-token return —
921
- * is what actually fixed the Wave-62 cold-launch hang.
922
- * b. A recent refresh failed and we're in backoff cooldown →
923
- * null. Honest "no usable token right now" rather than a
924
- * provably-expired one; the AppState foreground hook clears
925
- * the cooldown for an immediate retry on resume.
926
- * c. Otherwise → fire the refresh and await it.
927
- */
1305
+ // ============================================================
1306
+ // TOKEN LIVENESS (s58.281 — unchanged machinery)
1307
+ // ============================================================
928
1308
  /**
929
1309
  * The cached-token guard, and the single place the two deadlines are
930
1310
  * combined. Both must agree before a cached token is served:
@@ -935,16 +1315,8 @@ var Auth = class {
935
1315
  * full offset on a constantly-wrong clock.
936
1316
  *
937
1317
  * Guard 2 is released once `clockSkewConfirmed` proves the second failure
938
- * mode is the one we are actually in.
939
- *
940
- * The gap this closes: `accessExpiresAt` is written in the device's clock
941
- * frame at mint and PERSISTED. Step the clock backwards between mint and
942
- * read — an iPhone acquiring automatic time after a long power-off, a
943
- * restore, or a manual set — and the deadline is stale by the size of the
944
- * correction, surviving relaunches. The SDK then serves a provably dead
945
- * token, every request 401s, and because the fast path returns before
946
- * reaching `persist()` or `wipeLocalIdentity()` nothing emits
947
- * `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
1318
+ * mode is the one we are actually in. ⚠ NEVER derive one deadline from the
1319
+ * other (`accessExpiresAt = exp * 1000` throws away skew-invariance).
948
1320
  */
949
1321
  canServeToken(token, expiresAt, nowMs) {
950
1322
  if (!token || !expiresAt) return false;
@@ -969,10 +1341,7 @@ var Auth = class {
969
1341
  * the correction, so a mint-time probe records "clock is fast", and the flag
970
1342
  * then waves through the very token the guard exists to refuse. Probing here
971
1343
  * — only on a refresh we performed BECAUSE `exp` contradicted a tracked
972
- * deadline that still claimed life — separates the two cases, because by now
973
- * the clock has either been corrected (fresh token reads live → stale
974
- * deadline, guard holds) or is genuinely fast (fresh token still reads dead
975
- * → release the guard, one probe, no loop).
1344
+ * deadline that still claimed life — separates the two cases: one probe, no loop.
976
1345
  */
977
1346
  async refreshAndReconcile(nowMs) {
978
1347
  const reconciling = this.deadlinesDisagree(nowMs);
@@ -980,15 +1349,7 @@ var Auth = class {
980
1349
  if (reconciling && token) this.learnClockSkewFrom(token, Date.now());
981
1350
  return token;
982
1351
  }
983
- /**
984
- * Record whether a token the server issued MOMENTS AGO already reads as
985
- * expired. It cannot genuinely be — so a `false` here is proof the device
986
- * clock runs fast, and releases the `exp` guard for this process.
987
- *
988
- * Call this at every site that installs a server-issued token, and only
989
- * with a token that just arrived. Handing it a cached token would let a
990
- * stale deadline confirm itself.
991
- */
1352
+ /** A token the server issued MOMENTS AGO already reads as expired ⇒ the clock runs fast; release the `exp` guard for this process. */
992
1353
  learnClockSkewFrom(freshToken, nowMs) {
993
1354
  if (!accessTokenExpIsFuture(freshToken, nowMs)) this.clockSkewConfirmed = true;
994
1355
  }
@@ -996,7 +1357,32 @@ var Auth = class {
996
1357
  deadlinesDisagree(nowMs) {
997
1358
  return this.trackedDeadlineAlive(nowMs) && !this.canServeCachedToken(nowMs);
998
1359
  }
1360
+ /**
1361
+ * Returns a VALID (non-expired) access token, or null.
1362
+ *
1363
+ * Hard contract (matches sdk-ios / sdk-android, and the industry
1364
+ * token-client pattern — Supabase `_callRefreshToken`, Auth0
1365
+ * `getTokenSilently`, Firebase `getIdToken`): this method NEVER returns a
1366
+ * token past its `exp`. Callers attach the result as a Bearer to arbitrary
1367
+ * resource servers that know nothing of Sendora's refresh grace, so a token
1368
+ * must be either fresh or absent.
1369
+ *
1370
+ * Resolution order:
1371
+ * 0. `withheld` → null, no refresh (ADR-033 [2c]: the refused session is
1372
+ * never served and never refreshed by this process).
1373
+ * 1. Cached token with comfortable life left (>= 30 s before BOTH the
1374
+ * tracked deadline and the token's own `exp`) → return it. Fast path.
1375
+ * `{ forceRefresh: true }` skips it (the caller has out-of-band evidence
1376
+ * the tracked deadline is wrong); it does NOT bypass the single-flight or
1377
+ * the backoff cooldown.
1378
+ * 2. No refresh token in storage → genuinely signed out → null.
1379
+ * 3. Expired/near-expiry but a refresh chain exists: a refresh already
1380
+ * inflight → await it (single-flight, AbortController-bounded, can never
1381
+ * hang the bridge); in backoff cooldown → null (honest "no token now");
1382
+ * else fire the refresh and await it.
1383
+ */
999
1384
  async getAccessToken(options) {
1385
+ if (this.withheld) return null;
1000
1386
  if (!options?.forceRefresh && this.canServeCachedToken(Date.now())) {
1001
1387
  return this.accessToken;
1002
1388
  }
@@ -1005,188 +1391,245 @@ var Auth = class {
1005
1391
  if (Date.now() < this.refreshCooldownUntil) return null;
1006
1392
  return this.refreshAndReconcile(Date.now());
1007
1393
  }
1008
- /** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
1394
+ /** Sync read — returns whatever's cached even if expired (`null` in `withheld`). Prefer the async variant. */
1009
1395
  getAccessTokenSync() {
1396
+ if (this.withheld) return null;
1010
1397
  return this.accessToken;
1011
1398
  }
1012
1399
  /**
1013
- * Mint (or return) an anonymous session.
1400
+ * The currently-stored refresh token, or null when signed out (and in
1401
+ * `withheld`). Kept for integrators that journal the guest token for the
1402
+ * deprecated `merge()`; since 1.41.0 the guest is HELD on the device instead —
1403
+ * read `getHeldGuests()`.
1404
+ */
1405
+ getRefreshToken() {
1406
+ if (this.withheld) return null;
1407
+ return this.hooks.storage.get(REFRESH_KEY) ?? null;
1408
+ }
1409
+ // ============================================================
1410
+ // MINT (ADR-033 §3.3 rows 6–11)
1411
+ // ============================================================
1412
+ /**
1413
+ * Return the current session, minting an anonymous one only when there is
1414
+ * none.
1014
1415
  *
1015
- * Reuses the existing anonymous user when there already is one — the same
1016
- * short-circuit Firebase's `signInAnonymously` performs. Without it, an app
1017
- * that calls this defensively on every cold launch (the most natural thing
1018
- * to write) mints a fresh `user_id` each time, and `persist()` overwrites
1019
- * the stored refresh token — the previous anonymous account's only durable
1020
- * handle — with no takeover, no webhook and no state event. That is the same
1021
- * silent progress loss as a failed sign-in wiping the session, except it
1022
- * happens on a *healthy* network.
1416
+ * - `anonymous` / `identified` → returned as-is, no network (the 1.29 / 1.40.0
1417
+ * guards as one row). Read `user.isAnonymous` to know which kind you got.
1418
+ * - `signed_out` → with `autoRestoreHeldGuest` on (default) and an eligible
1419
+ * held guest on the device, the NEWEST one is restored instead of minting —
1420
+ * visibly: `signed_in{cause:'restore'}` + `guest_released{reason:'restored'}`.
1421
+ * A dead slot is released (`guest_released{dead}`) and the next tried; none
1422
+ * left → a fresh mint in the same call. Otherwise `POST /anonymous` →
1423
+ * `signed_in{cause:'mint'}`.
1424
+ * - `unknown` → queued until the gap-fill refresh settles the status.
1425
+ * - `withheld` → rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`.
1023
1426
  *
1024
- * Pass `forceNew: true` to deliberately abandon the current anonymous
1025
- * session and mint a separate one.
1427
+ * `forceNew: true` abandons the current session and mints a separate guest:
1428
+ * from a guest the current guest is HELD (never deleted); from an identified
1429
+ * session it is replaced silently (its refresh token revoked). At the held
1430
+ * capacity (K=5) it throws `HeldGuestLimitError` and mutates nothing.
1026
1431
  */
1027
1432
  signInAnonymously(opts) {
1028
1433
  return this.serialize(async () => {
1434
+ this.rejectIfWithheld("signInAnonymously");
1435
+ await this.awaitSettled();
1029
1436
  const existing = this.user;
1030
- if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
1437
+ if (!opts?.forceNew && existing && this.hooks.storage.get(REFRESH_KEY)) {
1031
1438
  return existing;
1032
1439
  }
1033
- const data = await this.callAuth("/api/v1/auth-service/anonymous", {
1034
- name: opts?.name,
1035
- metadata: opts?.metadata
1440
+ const heldCount = this.heldCount();
1441
+ const t = heldCount + (this.status() === "anonymous" ? 1 : 0);
1442
+ if (opts?.forceNew && t >= HELD_GUEST_SLOTS) {
1443
+ throw new HeldGuestLimitError();
1444
+ }
1445
+ if (!opts?.forceNew && this.status() === "signed_out") {
1446
+ const eligible = this.eligibleHeldSlots();
1447
+ if (eligible.length > 0 && (this.hooks.autoRestoreHeldGuest !== false || t >= HELD_GUEST_SLOTS)) {
1448
+ for (const i of eligible) {
1449
+ const restored = await this.restoreSlotForMint(i);
1450
+ if (restored) return restored;
1451
+ }
1452
+ } else if (t >= HELD_GUEST_SLOTS) {
1453
+ throw new HeldGuestLimitError();
1454
+ }
1455
+ }
1456
+ return this.mintFresh({ name: opts?.name, metadata: opts?.metadata, forceNew: opts?.forceNew === true });
1457
+ });
1458
+ }
1459
+ /** Alias of `signInAnonymously()` — "make sure I have a session". */
1460
+ ensureSession() {
1461
+ return this.signInAnonymously();
1462
+ }
1463
+ /** Row 6 (and row 11's second half): `POST /anonymous` → ProjectGuard on the RESPONSE → install. */
1464
+ async mintFresh(opts) {
1465
+ this.attempt = { kind: "mint", expect: this.snapshotIdentity() };
1466
+ try {
1467
+ await this.awaitRefreshQuiet();
1468
+ const expect = this.snapshotIdentity();
1469
+ this.attempt = { kind: "mint", expect };
1470
+ const mintKey = this.currentMintKey();
1471
+ const body = {};
1472
+ if (opts.name !== void 0) body.name = opts.name;
1473
+ if (opts.metadata !== void 0) body.metadata = opts.metadata;
1474
+ if (mintKey) body.mintKey = mintKey;
1475
+ const data = await this.callAuth("/api/v1/auth-service/anonymous", body);
1476
+ await this.awaitRefreshQuiet();
1477
+ const outcome = this.commit({
1478
+ kind: "install",
1479
+ reason: "mint",
1480
+ record: { user: data.user, accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, expiresIn: data.tokens.expiresIn },
1481
+ expect,
1482
+ holdPrevious: opts.forceNew,
1483
+ response: data
1036
1484
  });
1037
- this.persist(data);
1485
+ if (outcome.status === "project_mismatch") {
1486
+ this.revokeFireAndForget(data.tokens.refreshToken);
1487
+ throw withheldError("PROJECT_MISMATCH", "The minted session belongs to a different project than init({expectedProjectId}); nothing was installed");
1488
+ }
1489
+ if (outcome.status === "held_limit") {
1490
+ this.revokeFireAndForget(data.tokens.refreshToken);
1491
+ throw new HeldGuestLimitError();
1492
+ }
1493
+ if (outcome.status !== "applied") {
1494
+ this.revokeFireAndForget(data.tokens.refreshToken);
1495
+ throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the mint was in flight; nothing was installed", { kind: "unknown" });
1496
+ }
1497
+ this.hooks.storage.remove(MINT_KEY_KEY);
1038
1498
  return data.user;
1039
- });
1499
+ } finally {
1500
+ this.attempt = null;
1501
+ }
1502
+ }
1503
+ /**
1504
+ * Row 7 — one auto-restore attempt for `signInAnonymously()`. Returns the
1505
+ * restored user, or `null` when the slot was dead and released (the caller
1506
+ * tries the next slot, then mints). A transient failure propagates.
1507
+ */
1508
+ async restoreSlotForMint(i) {
1509
+ const slot = this.held[i];
1510
+ if (!slot) return null;
1511
+ const expect = this.snapshotIdentity();
1512
+ this.attempt = { kind: "restore", expect };
1513
+ try {
1514
+ const refreshed = await this.refreshHeld(i);
1515
+ if (refreshed.outcome === "dead") return null;
1516
+ if (refreshed.outcome === "kept") {
1517
+ throw new AuthError("RESTORE_FAILED", "The held guest could not be refreshed right now; it is still held", { kind: "server", retryable: true });
1518
+ }
1519
+ return this.installRestored(i, refreshed.data, expect);
1520
+ } finally {
1521
+ this.attempt = null;
1522
+ }
1523
+ }
1524
+ /** The per-attempt B10 key: reuse the persisted one (a crash-retry) or mint a new one and persist it until MINT_OK. */
1525
+ currentMintKey() {
1526
+ const existing = this.hooks.storage.get(MINT_KEY_KEY);
1527
+ if (existing && existing.length >= 16 && existing.length <= 128) return existing;
1528
+ const fresh = generateMintKey();
1529
+ if (fresh) this.hooks.storage.set(MINT_KEY_KEY, fresh);
1530
+ return fresh;
1040
1531
  }
1532
+ // ============================================================
1533
+ // SIGN UP (ADR-033 §3.5 upgrade)
1534
+ // ============================================================
1041
1535
  /**
1042
- * Create an email/password account.
1536
+ * Create an email/password account — routed by `status()`:
1043
1537
  *
1044
- * Industry-standard upgrade-first flow (matches Firebase
1045
- * `linkWithCredential`, Auth0 anonymous-merge, Supabase
1046
- * `linkIdentity`): when a refresh token is present in storage we
1047
- * try `/auth-service/upgrade` first — backend validates the
1048
- * refresh, checks the bound user is anonymous, and promotes in
1049
- * place preserving `user.id`. If the bound user is NOT anonymous
1050
- * (already identified, expired refresh, etc.), backend rejects
1051
- * with FORBIDDEN_NON_ANONYMOUS / INVALID_REFRESH and we fall back
1052
- * to a fresh /signup.
1538
+ * - `anonymous` → `POST /me/link/email` on the current Bearer session: the
1539
+ * guest is promoted IN PLACE (same `user.id`), the rotated
1540
+ * pair is installed, `signed_in{cause:'link'}` fires. A
1541
+ * taken email throws `EmailAlreadyTakenError` and changes
1542
+ * nothing.
1543
+ * - `signed_out` → `POST /signup`, a fresh account.
1544
+ * - `identified` → `AlreadyIdentifiedError`, decided LOCALLY (no round-trip,
1545
+ * nothing minted — use `linkEmailPassword()` instead).
1546
+ * - `unknown` → waits for the status to settle, then routes as above.
1547
+ * - `withheld` → rejects `SESSION_WITHHELD`.
1053
1548
  *
1054
- * Why we don't gate on the cached `this.user.isAnonymous`
1055
- * boolean: the in-memory user can be stale during cold start or
1056
- * fast-refresh races. The refresh token in storage is the
1057
- * authoritative signal — if it's there, the upgrade path is
1058
- * worth attempting; backend has the source of truth.
1549
+ * Never calls `/upgrade` (ADR-033 INV-4): a generic 401 there used to wipe
1550
+ * the guest, and the "dead token → wipe → fresh signup" branch is gone.
1059
1551
  */
1060
1552
  signUp(email, password, opts) {
1061
1553
  return this.serialize(async () => {
1062
- const refresh = this.hooks.storage.get(REFRESH_KEY);
1063
- if (refresh) {
1554
+ this.rejectIfWithheld("signUp");
1555
+ await this.awaitSettled();
1556
+ const status = this.status();
1557
+ if (status === "identified") throw new AlreadyIdentifiedError();
1558
+ if (status === "anonymous") {
1559
+ const body = { email, password };
1560
+ if (opts?.name !== void 0) body.name = opts.name;
1561
+ const result = await this.doLink(LINK_EMAIL_PATH, body, { legacyEmit: true, label: "password" });
1562
+ if (result.status === "credential_in_use") throw new EmailAlreadyTakenError();
1563
+ if (result.status === "recent_auth_required") {
1564
+ throw new AuthError("RECENT_AUTH_REQUIRED", "Re-authenticate before adding a password", { status: 403 });
1565
+ }
1566
+ return result.user;
1567
+ }
1568
+ const expect = this.snapshotIdentity();
1569
+ this.attempt = { kind: "sign_in", expect };
1570
+ try {
1571
+ const body = { email, password };
1572
+ if (opts?.name !== void 0) body.name = opts.name;
1573
+ if (opts?.metadata !== void 0) body.metadata = opts.metadata;
1574
+ let data;
1064
1575
  try {
1065
- const data = await this.callAuth("/api/v1/auth-service/upgrade", {
1066
- refreshToken: refresh,
1067
- email,
1068
- password,
1069
- name: opts?.name
1070
- });
1071
- this.persist(data);
1072
- return data.user;
1576
+ data = await this.callAuth("/api/v1/auth-service/signup", body);
1073
1577
  } catch (err) {
1074
- if (err instanceof AuthError && (err.code === "NOT_ANONYMOUS" || err.code === "FORBIDDEN_NON_ANONYMOUS")) {
1075
- throw new AlreadyIdentifiedError(err.message);
1076
- }
1077
1578
  if (err instanceof AuthError && (err.code === "CONFLICT" || err.code === "EMAIL_ALREADY_TAKEN")) {
1078
1579
  throw new EmailAlreadyTakenError(err.message);
1079
1580
  }
1080
- if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
1081
- await this.wipeLocalIdentity("session_expired");
1082
- } else {
1083
- throw err;
1084
- }
1581
+ throw err;
1085
1582
  }
1086
- }
1087
- try {
1088
- const data = await this.callAuth("/api/v1/auth-service/signup", {
1089
- email,
1090
- password,
1091
- name: opts?.name,
1092
- metadata: opts?.metadata
1093
- });
1094
- this.persist(data);
1583
+ this.installSignIn(data, expect);
1095
1584
  return data.user;
1096
- } catch (err) {
1097
- if (err instanceof AuthError && err.code === "CONFLICT") {
1098
- throw new EmailAlreadyTakenError(err.message);
1099
- }
1100
- throw err;
1585
+ } finally {
1586
+ this.attempt = null;
1101
1587
  }
1102
1588
  });
1103
1589
  }
1590
+ // ============================================================
1591
+ // SIGN-IN (ADR-033 §3.3 rows 17–19: SIGN_IN = switch) — legacy signatures
1592
+ // ============================================================
1104
1593
  /**
1105
1594
  * Sign in with email + password. When the user has confirmed MFA,
1106
1595
  * returns `{mfaRequired, mfaChallengeToken}` instead of an
1107
1596
  * `AuthUser`; caller must follow up with `challengeMfa()`.
1597
+ *
1598
+ * A sign-in from a guest HOLDS the guest on this device (`guest_held`, then
1599
+ * ONE `signed_in{cause:'sign_in'}`); the guest's server row survives. The
1600
+ * request never carries the guest's refresh token (ADR-033 INV-3).
1108
1601
  */
1109
1602
  signIn(email, password) {
1110
1603
  return this.serialize(async () => {
1111
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1112
- const res = await post(
1113
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1114
- "/api/v1/auth-service/login",
1115
- prevAnonRefreshToken ? { email, password, prevAnonRefreshToken } : { email, password }
1116
- );
1117
- if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1118
- let parsed;
1119
- try {
1120
- parsed = await res.json();
1121
- } catch {
1122
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1123
- }
1124
- if (!res.ok || !parsed.success || !parsed.data) {
1125
- throw new AuthError(
1126
- parsed.error?.code ?? `HTTP_${res.status}`,
1127
- parsed.error?.message ?? `HTTP ${res.status}`,
1128
- { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1129
- );
1130
- }
1131
- const r = parsed.data;
1132
- if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
1133
- if (prevAnonRefreshToken) {
1134
- this.pendingAnonTakeover = { challengeToken: r.mfaChallengeToken, prevAnonRefreshToken };
1135
- }
1136
- const u = r.user;
1137
- return { mfaRequired: true, mfaChallengeToken: r.mfaChallengeToken, userId: u?.id ?? "" };
1138
- }
1139
- if (!isAuthApiResponse(parsed.data)) {
1140
- throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Login failed");
1141
- }
1142
- if (this.user !== null) await this.wipeLocalIdentity();
1143
- this.persist(parsed.data);
1144
- return parsed.data.user;
1604
+ this.rejectIfWithheld("signIn");
1605
+ await this.awaitSettled();
1606
+ const r = await this.doSignIn({ kind: "password", email, password });
1607
+ if (r.kind === "mfa") return { mfaRequired: true, mfaChallengeToken: r.challengeToken, userId: r.userId };
1608
+ return r.user;
1145
1609
  });
1146
1610
  }
1147
1611
  /**
1148
1612
  * Verify an IdP-issued credential (authorization code OR id_token)
1149
1613
  * and mint a Sendora session. Customer's app handles the IdP
1150
1614
  * dance — typically via expo-auth-session / react-native-app-auth
1151
- * — then hands the result here. See web SDK for full provider
1152
- * matrix; mirrors that surface 1:1.
1615
+ * — then hands the result here. Mirrors the web SDK's surface 1:1.
1616
+ *
1617
+ * `link: true` (ADR-033 INV-14) — attach-or-error, never a silent switch:
1618
+ * from a guest the account is promoted IN PLACE (same `user.id`,
1619
+ * `signed_in{cause:'link'}`); over an identified session the identity is
1620
+ * attached (`user_updated{cause:'link'}`), an identity already on the account
1621
+ * resolves quietly, one owned elsewhere throws `CredentialInUseError`
1622
+ * (or switches when `onCredentialInUse: "adopt"`), a stale session throws
1623
+ * `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
1153
1624
  */
1154
1625
  loginSocial(input) {
1626
+ const { link, onCredentialInUse, ...socialInput } = input;
1627
+ const credential = { ...socialInput, kind: input.provider };
1155
1628
  return this.serialize(async () => {
1156
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1157
- const { link, onCredentialInUse, ...socialInput } = input;
1158
- const payload = { ...socialInput };
1159
- if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1160
- if (link) payload.linkAnonymous = true;
1161
- if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1162
- const res = await post(
1163
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1164
- "/api/v1/auth-service/login/social",
1165
- payload
1166
- );
1167
- if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1168
- let parsed;
1169
- try {
1170
- parsed = await res.json();
1171
- } catch {
1172
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1173
- }
1174
- if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1175
- if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1176
- throw new CredentialInUseError(parsed.error.message, {
1177
- ...readCollisionDetails(parsed),
1178
- status: res.status
1179
- });
1180
- }
1181
- throw new AuthError(
1182
- parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
1183
- parsed.error?.message ?? "Social login failed",
1184
- { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1185
- );
1186
- }
1187
- if (this.user !== null) await this.wipeLocalIdentity("replaced");
1188
- this.persist(parsed.data);
1189
- return parsed.data.user;
1629
+ this.rejectIfWithheld("loginSocial");
1630
+ await this.awaitSettled();
1631
+ if (link && this.hasSession()) return this.legacyLink(credential, onCredentialInUse);
1632
+ return (await this.doSignInDone(credential)).user;
1190
1633
  });
1191
1634
  }
1192
1635
  signInWithGoogle(code, redirectUri, link) {
@@ -1211,66 +1654,198 @@ var Auth = class {
1211
1654
  return this.loginSocial({ provider: "discord", code, redirectUri });
1212
1655
  }
1213
1656
  signInWithGameCenter(payloadOrOpts, maybeOpts) {
1214
- const path = "/api/v1/auth-service/login/game-center";
1215
1657
  if (payloadOrOpts && "publicKeyUrl" in payloadOrOpts) {
1216
- return this.gameIdentitySignIn(path, { ...payloadOrOpts }, maybeOpts);
1658
+ const payload = payloadOrOpts;
1659
+ return this.gameIdentitySignIn(async () => ({ kind: "gameCenter", ...payload }), maybeOpts);
1217
1660
  }
1218
1661
  return this.gameIdentitySignIn(
1219
- path,
1220
- async () => await fetchGameCenterPayloadNative(),
1662
+ async () => ({ kind: "gameCenter", ...await fetchGameCenterPayloadNative() }),
1221
1663
  payloadOrOpts
1222
1664
  );
1223
1665
  }
1224
1666
  signInWithPlayGames(payloadOrOpts, maybeOpts) {
1225
- const path = "/api/v1/auth-service/login/play-games";
1226
1667
  if (payloadOrOpts && "serverAuthCode" in payloadOrOpts) {
1227
1668
  const { serverAuthCode } = payloadOrOpts;
1228
- return this.gameIdentitySignIn(path, { serverAuthCode }, maybeOpts);
1669
+ return this.gameIdentitySignIn(async () => ({ kind: "playGames", serverAuthCode }), maybeOpts);
1229
1670
  }
1230
1671
  return this.gameIdentitySignIn(
1231
- path,
1232
- async () => await fetchPlayGamesAuthCodeNative(),
1672
+ async () => ({ kind: "playGames", ...await fetchPlayGamesAuthCodeNative() }),
1233
1673
  payloadOrOpts
1234
1674
  );
1235
1675
  }
1236
- // ============================================================
1237
- // IDENTITY LINKING (ADR-030) — attach a SECOND credential to the
1238
- // CURRENT signed-in account, preserving the same user id (sub).
1239
- // ============================================================
1240
- //
1241
- // Unlike signUp() / loginSocial({link}) — which only preserve the sub from
1242
- // an ANONYMOUS session — these operate on an ALREADY-IDENTIFIED account.
1243
- // No token rotation (the session is unchanged); they refresh the cached user
1244
- // object in place. Require a signed-in user (throws NOT_SIGNED_IN otherwise).
1245
- //
1246
- // Primary use: ONE account across platforms. A Game Center player on iOS
1247
- // links email/Google; on Android they sign in with that same credential and
1248
- // land on the SAME sub, progress intact. Collision (the credential already
1249
- // belongs to another account) → CredentialInUseError (never merges).
1250
- /** Link email + password to the current account (sub preserved). */
1251
- linkEmailPassword(email, password) {
1252
- return this.linkCredential("/api/v1/auth-service/me/link/email", { email, password });
1253
- }
1254
- /** Link an OAuth social identity (verified idToken or code) to the current account. */
1255
- linkSocial(input) {
1256
- return this.linkCredential("/api/v1/auth-service/me/link/social", input);
1257
- }
1258
- /** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
1259
- linkGoogle(input) {
1260
- return this.linkSocial({ provider: "google", ...input });
1261
- }
1262
- /** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
1263
- linkApple(input) {
1264
- return this.linkSocial({ provider: "apple", ...input });
1265
- }
1266
1676
  /**
1267
- * Link an Apple Game Center identity to the current account. No-arg drives the
1268
- * native GameKit fetch (needs `@sendoracloud/sdk-react-native-gaming`); or pass
1269
- * an explicit payload obtained from your own GameKit bridge.
1677
+ * Shared gaming-identity sign-in. Resolves the credential FIRST (for the
1678
+ * no-arg native path this triggers the Game Center / Play Games sheet inside
1679
+ * the serialized op — a cancelled sheet leaves the session untouched), then
1680
+ * routes by `link` and `status()` exactly like `loginSocial`.
1270
1681
  */
1271
- async linkGameCenter(payload) {
1682
+ gameIdentitySignIn(credentialOf, opts) {
1683
+ return this.serialize(async () => {
1684
+ this.rejectIfWithheld("signInWithGameCenter/PlayGames");
1685
+ await this.awaitSettled();
1686
+ const credential = await credentialOf();
1687
+ if (opts?.link && this.hasSession()) return this.legacyLink(credential, opts.onCredentialInUse);
1688
+ return (await this.doSignInDone(credential)).user;
1689
+ });
1690
+ }
1691
+ /**
1692
+ * The legacy-signature LINK (`signInWith*({link:true})`, `loginSocial({link:true})`)
1693
+ * in `anonymous` / `identified` (ADR-033 rows 15–16, §8-Q18):
1694
+ * - a promotion / attach resolves with the user (`signed_in{cause:'link'}` on
1695
+ * the same-`sub` promotion — preserved from 1.30–1.40; `user_updated` on an
1696
+ * identified attach; NOTHING on `alreadyLinked`);
1697
+ * - 409 → `CredentialInUseError`, or with `onCredentialInUse: "adopt"` the
1698
+ * explicit switch to the owning account (the guest is HELD);
1699
+ * - 403 → `AuthError` `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`).
1700
+ */
1701
+ async legacyLink(credential, policy) {
1702
+ const wire = linkWire(credential);
1703
+ if (!wire) return (await this.doSignInDone(credential)).user;
1704
+ const r = await this.doLink(wire.path, wire.body, { legacyEmit: true, label: providerLabel(credential) });
1705
+ if (r.status === "done") return r.user;
1706
+ if (r.status === "credential_in_use") {
1707
+ if (policy === "adopt") return (await this.doSignInDone(credential)).user;
1708
+ throw new CredentialInUseError(r.message, { provider: r.provider, collision: r.collision, status: r.httpStatus });
1709
+ }
1710
+ throw new AuthError("RECENT_AUTH_REQUIRED", r.message, { status: 403 });
1711
+ }
1712
+ /**
1713
+ * Exchange the MFA challenge token from `signIn` for a real session by
1714
+ * submitting a TOTP or recovery code. A wrong code throws and preserves the
1715
+ * current session for a retry; success holds the guest (if any) and installs
1716
+ * the account exactly like a direct sign-in.
1717
+ */
1718
+ challengeMfa(mfaChallengeToken, code) {
1719
+ return this.serialize(async () => {
1720
+ this.rejectIfWithheld("challengeMfa");
1721
+ await this.awaitSettled();
1722
+ return (await this.doSignInDone({ kind: "mfa", challengeToken: mfaChallengeToken, code })).user;
1723
+ });
1724
+ }
1725
+ // ============================================================
1726
+ // TYPED VERBS (ADR-033 §3.5) — signInWith / link / switchAccount
1727
+ // ============================================================
1728
+ /**
1729
+ * Plain sign-in / SWITCH with a typed credential. Never links: from a guest
1730
+ * the guest is HELD (`previous.held === true`); from an identified account
1731
+ * the previous session is replaced and its refresh token revoked
1732
+ * (`previous.isAnonymous === false` ⇒ skip `mergeAnon`, reset local save
1733
+ * state). The request never carries a guest credential (INV-3). MFA returns
1734
+ * `{status:'mfa_required', verify(code)}`.
1735
+ */
1736
+ signInWith(credential) {
1737
+ return this.serialize(async () => {
1738
+ this.rejectIfWithheld("signInWith");
1739
+ await this.awaitSettled();
1740
+ return this.toSignInResult(await this.doSignIn(credential));
1741
+ });
1742
+ }
1743
+ /**
1744
+ * Attach a credential to the CURRENT session — the ONLY upgrade (INV-4).
1745
+ * From a guest: promoted in place (same `user.id`, `upgraded: true`,
1746
+ * `user_updated{cause:'link'}`). From an identified account: attached
1747
+ * (`user_updated`), or `alreadyLinked: true` with no event. Owned elsewhere →
1748
+ * `{status:'credential_in_use', switchAccount(), keepCurrent()}` with nothing
1749
+ * mutated; stale session → `{status:'recent_auth_required'}`. Throws
1750
+ * `NOT_SIGNED_IN` in `signed_out` (nothing to attach to) and
1751
+ * `UNSUPPORTED_LINK_CREDENTIAL` for a kind that cannot be attached
1752
+ * (`magicLink`, `emailOtp`, `passkey`, `mfa`).
1753
+ */
1754
+ link(credential) {
1755
+ return this.serialize(async () => {
1756
+ this.rejectIfWithheld("link");
1757
+ await this.awaitSettled();
1758
+ const wire = linkWire(credential);
1759
+ if (!wire) {
1760
+ throw new AuthError("UNSUPPORTED_LINK_CREDENTIAL", `A ${credential.kind} credential cannot be attached to an account; sign in with it instead`, { kind: "config" });
1761
+ }
1762
+ if (!this.hasSession()) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
1763
+ const r = await this.doLink(wire.path, wire.body, { legacyEmit: false, label: providerLabel(credential) });
1764
+ if (r.status === "done") return { status: "done", user: r.user, upgraded: r.upgraded, alreadyLinked: r.alreadyLinked };
1765
+ if (r.status === "recent_auth_required") return { status: "recent_auth_required" };
1766
+ const refusedAt = Date.now();
1767
+ return {
1768
+ status: "credential_in_use",
1769
+ provider: r.provider,
1770
+ collision: r.collision,
1771
+ switchAccount: () => this.switchAccount(credential, refusedAt),
1772
+ keepCurrent: () => void 0
1773
+ };
1774
+ });
1775
+ }
1776
+ /**
1777
+ * The ONLY path from a refused link to a sign-in, BOUND to the refused
1778
+ * credential (INV-14): the same payload is reused while under 5 minutes old;
1779
+ * older than that, a Game Center / Play Games credential is re-fetched
1780
+ * natively and the Game Center `teamPlayerId` compared — a different player
1781
+ * aborts with `CREDENTIAL_CHANGED` and nothing is mutated. (A Play Games
1782
+ * server auth code carries no player id, so a re-fetched code is used as-is.)
1783
+ */
1784
+ switchAccount(credential, refusedAt) {
1785
+ return this.serialize(async () => {
1786
+ this.rejectIfWithheld("switchAccount");
1787
+ await this.awaitSettled();
1788
+ let bound = credential;
1789
+ if (Date.now() - refusedAt > SWITCH_CREDENTIAL_MAX_AGE_MS) {
1790
+ if (credential.kind === "gameCenter") {
1791
+ const fresh = await fetchGameCenterPayloadNative();
1792
+ if (fresh.teamPlayerId !== credential.teamPlayerId) {
1793
+ throw new AuthError("CREDENTIAL_CHANGED", "The Game Center player on this device is no longer the one whose link was refused", { kind: "invalid_credential" });
1794
+ }
1795
+ bound = { kind: "gameCenter", ...fresh };
1796
+ } else if (credential.kind === "playGames") {
1797
+ bound = { kind: "playGames", ...await fetchPlayGamesAuthCodeNative() };
1798
+ }
1799
+ }
1800
+ return this.toSignInResult(await this.doSignIn(bound));
1801
+ });
1802
+ }
1803
+ toSignInResult(r) {
1804
+ if (r.kind === "mfa") {
1805
+ const challengeToken = r.challengeToken;
1806
+ return {
1807
+ status: "mfa_required",
1808
+ challengeToken,
1809
+ verify: (code) => this.signInWith({ kind: "mfa", challengeToken, code })
1810
+ };
1811
+ }
1812
+ return { status: "done", user: r.user, previous: r.previous, reactivated: r.reactivated };
1813
+ }
1814
+ // ============================================================
1815
+ // IDENTITY LINKING (ADR-030) — legacy link*() verbs
1816
+ // ============================================================
1817
+ //
1818
+ // Attach a SECOND credential to the CURRENT account, preserving the `sub`.
1819
+ // From an identified account: no token rotation; the cached user is refreshed
1820
+ // in place (`user_updated{cause:'link'}`). From a guest (1.30.0 / ADR-033
1821
+ // INV-4): the server promotes the guest in place and ROTATES the session; the
1822
+ // returned pair is installed (the old refresh token is revoked server-side).
1823
+ // Collision → `CredentialInUseError` (never merges). Require a session
1824
+ // (throw NOT_SIGNED_IN otherwise).
1825
+ /** Link email + password to the current account (sub preserved). */
1826
+ linkEmailPassword(email, password) {
1827
+ return this.linkCredential(LINK_EMAIL_PATH, { email, password }, "password");
1828
+ }
1829
+ /** Link an OAuth social identity (verified idToken or code) to the current account. */
1830
+ linkSocial(input) {
1831
+ return this.linkCredential(LINK_SOCIAL_PATH, stripUndefined(input), input.provider);
1832
+ }
1833
+ /** Convenience: link a Google identity (native idToken, or web code+redirectUri). */
1834
+ linkGoogle(input) {
1835
+ return this.linkSocial({ provider: "google", ...input });
1836
+ }
1837
+ /** Convenience: link an Apple identity (native idToken, or web code+redirectUri). */
1838
+ linkApple(input) {
1839
+ return this.linkSocial({ provider: "apple", ...input });
1840
+ }
1841
+ /**
1842
+ * Link an Apple Game Center identity to the current account. No-arg drives the
1843
+ * native GameKit fetch (needs `@sendoracloud/sdk-react-native-gaming`); or pass
1844
+ * an explicit payload obtained from your own GameKit bridge.
1845
+ */
1846
+ async linkGameCenter(payload) {
1272
1847
  const body = payload ?? await fetchGameCenterPayloadNative();
1273
- return this.linkCredential("/api/v1/auth-service/me/link/game-center", { ...body });
1848
+ return this.linkCredential(LINK_GAME_CENTER_PATH, { ...body }, "gamecenter");
1274
1849
  }
1275
1850
  /**
1276
1851
  * Link a Google Play Games identity to the current account. No-arg drives the
@@ -1278,74 +1853,41 @@ var Auth = class {
1278
1853
  */
1279
1854
  async linkPlayGames(payload) {
1280
1855
  const body = payload ?? await fetchPlayGamesAuthCodeNative();
1281
- return this.linkCredential("/api/v1/auth-service/me/link/play-games", { ...body });
1856
+ return this.linkCredential(LINK_PLAY_GAMES_PATH, { ...body }, "playgames");
1282
1857
  }
1283
1858
  /**
1284
- * Shared link executor. POSTs the verified credential with the caller's
1285
- * end-user Bearer token, then refreshes the cached user object IN PLACE (no
1286
- * token rotation — the sub is unchanged). Deliberately NOT wrapped in
1287
- * serialize(): getAccessToken() serializes internally, so re-entering would
1288
- * deadlock — same non-serialized posture as deleteAccount().
1859
+ * Shared `link*()` executor — the LINK row with the `link*()` emission
1860
+ * (`user_updated{cause:'link'}`, never `signed_in`). A 409 throws
1861
+ * `CredentialInUseError`; a 403 throws `RECENT_AUTH_REQUIRED`; no session →
1862
+ * `NOT_SIGNED_IN` with no network call.
1289
1863
  */
1290
- async linkCredential(path, body) {
1291
- const token = await this.getAccessToken();
1292
- if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
1293
- const res = await post(
1294
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1295
- path,
1296
- body,
1297
- { Authorization: `Bearer ${token}` }
1298
- );
1299
- if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1300
- let parsed;
1301
- try {
1302
- parsed = await res.json();
1303
- } catch {
1304
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1305
- }
1306
- if (!res.ok || !parsed.success || !parsed.data) {
1307
- const code = parsed.error?.code ?? `HTTP_${res.status}`;
1308
- if (code === "CREDENTIAL_IN_USE") {
1309
- throw new CredentialInUseError(parsed.error?.message, {
1310
- ...readCollisionDetails(parsed),
1311
- status: res.status
1312
- });
1864
+ linkCredential(path, body, label) {
1865
+ return this.serialize(async () => {
1866
+ this.rejectIfWithheld("link");
1867
+ await this.awaitSettled();
1868
+ if (!this.hasSession()) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
1869
+ const r = await this.doLink(path, body, { legacyEmit: false, label });
1870
+ if (r.status === "done") return r.user;
1871
+ if (r.status === "credential_in_use") {
1872
+ throw new CredentialInUseError(r.message, { provider: r.provider, collision: r.collision, status: r.httpStatus });
1313
1873
  }
1314
- throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
1315
- status: res.status,
1316
- retryAfterSeconds: readRetryAfterSeconds(parsed)
1317
- });
1318
- }
1319
- if (parsed.data.upgraded && isAuthApiResponse(parsed.data)) {
1320
- this.persist(parsed.data);
1321
- return parsed.data.user;
1322
- }
1323
- if (parsed.data.user) this.updateLocalUser(parsed.data.user);
1324
- return parsed.data.user ?? this.user;
1325
- }
1326
- /**
1327
- * Refresh the cached user after a link — the sub is unchanged, so only the
1328
- * user object + its storage copy are updated (no token/identity rotation).
1329
- */
1330
- updateLocalUser(user) {
1331
- this.user = user;
1332
- this.hooks.storage.set(USER_KEY, JSON.stringify(user));
1874
+ throw new AuthError("RECENT_AUTH_REQUIRED", r.message, { status: 403 });
1875
+ });
1333
1876
  }
1334
1877
  /**
1335
1878
  * List the auth methods/identities linked to the current account (ADR-030
1336
- * read side, s58.270). Returns every social/gaming identity on this account
1337
- * plus a `hasPassword` flag — the cross-device / reinstall-durable source of
1338
- * truth for a "Connected: Game Center · Google" UI (an on-device tracker
1339
- * misses a link made on another device). Bearer-gated network read (NOT a
1340
- * cache read — `getUserSync()` only holds the primary `signupMethod`/
1341
- * `lastLoginMethod`). Resolves a FRESH access token first (mirrors
1342
- * `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
1879
+ * read side, s58.270): every social/gaming identity plus a `hasPassword`
1880
+ * flag — the cross-device / reinstall-durable source of truth for a
1881
+ * "Connected: Game Center · Google" UI. Bearer-gated network read that
1882
+ * resolves a FRESH access token first. Firebase `user.providerData` /
1883
+ * Supabase `user.identities` parity.
1343
1884
  */
1344
1885
  async listLinkedIdentities() {
1886
+ this.rejectIfWithheld("listLinkedIdentities");
1345
1887
  const token = await this.getAccessToken();
1346
1888
  if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before listing linked identities");
1347
1889
  const res = await getJson(
1348
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1890
+ this.http(),
1349
1891
  "/api/v1/auth-service/me/identities",
1350
1892
  { Authorization: `Bearer ${token}` }
1351
1893
  );
@@ -1362,32 +1904,23 @@ var Auth = class {
1362
1904
  return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
1363
1905
  }
1364
1906
  /**
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.
1907
+ * Remove one linked provider from the signed-in account — the write half of
1908
+ * `listLinkedIdentities()`.
1369
1909
  *
1370
1910
  * ⚠ 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.
1911
+ * `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"` — a
1912
+ * permanent lockout, not an inconvenience. Prefer preventing the tap: disable
1913
+ * Disconnect when `identities.length + (hasPassword ? 1 : 0) <= 1`.
1380
1914
  *
1381
1915
  * 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.
1916
+ * provider is not linked (an absent provider is NOT reported as removed).
1385
1917
  */
1386
1918
  async unlink(provider) {
1919
+ this.rejectIfWithheld("unlink");
1387
1920
  const token = await this.getAccessToken();
1388
1921
  if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
1389
1922
  const res = await del(
1390
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1923
+ this.http(),
1391
1924
  `/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
1392
1925
  { Authorization: `Bearer ${token}` }
1393
1926
  );
@@ -1411,129 +1944,35 @@ var Auth = class {
1411
1944
  remaining: Number(parsed.data.remaining ?? 0)
1412
1945
  };
1413
1946
  }
1414
- /**
1415
- * Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
1416
- * ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
1417
- * identity, forwards it as `prevAnonRefreshToken`, and opts into
1418
- * `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
1419
- * centrally from `retiredAnonUserId`.
1420
- */
1421
- gameIdentitySignIn(path, identityBody, opts) {
1422
- const link = opts?.link;
1423
- const onCredentialInUse = opts?.onCredentialInUse;
1424
- return this.serialize(async () => {
1425
- const body = typeof identityBody === "function" ? await identityBody() : identityBody;
1426
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1427
- const payload = { ...body };
1428
- if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
1429
- if (link) payload.linkAnonymous = true;
1430
- if (onCredentialInUse) payload.onCredentialInUse = onCredentialInUse;
1431
- const res = await post(
1432
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1433
- path,
1434
- payload
1435
- );
1436
- if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1437
- let parsed;
1438
- try {
1439
- parsed = await res.json();
1440
- } catch {
1441
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
1442
- }
1443
- if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
1444
- if (parsed.error?.code === "CREDENTIAL_IN_USE") {
1445
- throw new CredentialInUseError(parsed.error.message, {
1446
- ...readCollisionDetails(parsed),
1447
- status: res.status
1448
- });
1449
- }
1450
- throw new AuthError(
1451
- parsed.error?.code ?? "GAME_LOGIN_FAILED",
1452
- parsed.error?.message ?? "Game sign-in failed",
1453
- { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
1454
- );
1455
- }
1456
- if (this.user !== null) await this.wipeLocalIdentity("replaced");
1457
- this.persist(parsed.data);
1458
- return parsed.data.user;
1459
- });
1460
- }
1461
- /**
1462
- * Read the stored anon refresh token if (and only if) the local
1463
- * subject is currently anonymous. Used by every identified-session
1464
- * mint path so the backend can run device-takeover (s58.111 +
1465
- * s58.112). Returns `undefined` when not anonymous — never forward
1466
- * an identified user's own refresh token.
1467
- */
1468
- readPrevAnonRefreshToken() {
1469
- if (!this.user?.isAnonymous) return void 0;
1470
- return this.hooks.storage.get(REFRESH_KEY) ?? void 0;
1471
- }
1472
- /**
1473
- * Exchange the MFA challenge token from `signIn` for a real session
1474
- * by submitting a TOTP or recovery code from the user's authenticator.
1475
- */
1476
- challengeMfa(mfaChallengeToken, code) {
1477
- return this.serialize(async () => {
1478
- const stashed = this.pendingAnonTakeover?.challengeToken === mfaChallengeToken ? this.pendingAnonTakeover.prevAnonRefreshToken : void 0;
1479
- const prevAnonRefreshToken = stashed ?? this.readPrevAnonRefreshToken();
1480
- const body = {
1481
- challengeToken: mfaChallengeToken,
1482
- code
1483
- };
1484
- if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1485
- const data = await this.callAuth("/api/v1/auth-service/mfa/challenge", body);
1486
- if (this.user !== null) await this.wipeLocalIdentity();
1487
- this.persist(data);
1488
- return data.user;
1489
- });
1490
- }
1491
1947
  // ============================================================
1492
- // MAGIC LINK
1948
+ // MAGIC LINK / EMAIL OTP (sign-in continuations — SIGN_IN row)
1493
1949
  // ============================================================
1494
1950
  async sendMagicLink(email) {
1495
- const res = await post(
1496
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1497
- "/api/v1/auth-service/magic-link/request",
1498
- { email }
1499
- );
1951
+ const res = await post(this.http(), "/api/v1/auth-service/magic-link/request", { email });
1500
1952
  if (!res || !res.ok) {
1501
1953
  throw new AuthError("MAGIC_LINK_REQUEST_FAILED", "Failed to send link");
1502
1954
  }
1503
1955
  }
1956
+ /** A magic-link token is routinely expired or already consumed (mail scanners pre-fetch, users tap twice); a rejection never costs the session. */
1504
1957
  verifyMagicLink(token) {
1505
1958
  return this.serialize(async () => {
1506
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1507
- const body = { token };
1508
- if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1509
- const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
1510
- if (this.user !== null) await this.wipeLocalIdentity("replaced");
1511
- this.persist(data);
1512
- return data.user;
1959
+ this.rejectIfWithheld("verifyMagicLink");
1960
+ await this.awaitSettled();
1961
+ return (await this.doSignInDone({ kind: "magicLink", token })).user;
1513
1962
  });
1514
1963
  }
1515
- // ============================================================
1516
- // EMAIL OTP (6-digit cross-device code)
1517
- // ============================================================
1518
1964
  async sendEmailOtp(email) {
1519
- const res = await post(
1520
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1521
- "/api/v1/auth-service/email-otp/request",
1522
- { email }
1523
- );
1965
+ const res = await post(this.http(), "/api/v1/auth-service/email-otp/request", { email });
1524
1966
  if (!res || !res.ok) {
1525
1967
  throw new AuthError("EMAIL_OTP_REQUEST_FAILED", "Failed to send code");
1526
1968
  }
1527
1969
  }
1970
+ /** Mistyping a 6-digit code is the most common outcome of this flow; the session survives for the retry. */
1528
1971
  verifyEmailOtp(email, code) {
1529
1972
  return this.serialize(async () => {
1530
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1531
- const body = { email, code };
1532
- if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1533
- const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
1534
- if (this.user !== null) await this.wipeLocalIdentity("replaced");
1535
- this.persist(data);
1536
- return data.user;
1973
+ this.rejectIfWithheld("verifyEmailOtp");
1974
+ await this.awaitSettled();
1975
+ return (await this.doSignInDone({ kind: "emailOtp", email, code })).user;
1537
1976
  });
1538
1977
  }
1539
1978
  // ============================================================
@@ -1541,153 +1980,282 @@ var Auth = class {
1541
1980
  // ============================================================
1542
1981
  /** Trigger password-reset email. Resolves on 204 even when address is unknown (anti-enumeration). */
1543
1982
  async requestPasswordReset(email) {
1544
- const res = await post(
1545
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1546
- "/api/v1/auth-service/password/forgot",
1547
- { email }
1548
- );
1983
+ const res = await post(this.http(), "/api/v1/auth-service/password/forgot", { email });
1549
1984
  if (!res || !res.ok) {
1550
1985
  throw new AuthError("PASSWORD_RESET_REQUEST_FAILED", "Failed to send reset email");
1551
1986
  }
1552
1987
  }
1553
1988
  /** Pair the reset-email token with the new password to complete the reset. */
1554
1989
  async resetPassword(token, newPassword) {
1555
- const res = await post(
1556
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1557
- "/api/v1/auth-service/password/reset",
1558
- { token, newPassword }
1559
- );
1990
+ const res = await post(this.http(), "/api/v1/auth-service/password/reset", { token, newPassword });
1560
1991
  if (!res || !res.ok) {
1561
1992
  throw new AuthError("PASSWORD_RESET_FAILED", "Reset failed");
1562
1993
  }
1563
1994
  }
1564
1995
  /** Verify the email-address token from the link Sendora sent on signup. */
1565
1996
  async verifyEmail(token) {
1566
- const res = await post(
1567
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1568
- "/api/v1/auth-service/email/verify",
1569
- { token }
1570
- );
1997
+ const res = await post(this.http(), "/api/v1/auth-service/email/verify", { token });
1571
1998
  if (!res || !res.ok) {
1572
1999
  throw new AuthError("EMAIL_VERIFICATION_FAILED", "Verification failed");
1573
2000
  }
1574
2001
  }
1575
2002
  /** Re-send the email-verification email for the currently-signed-in user. */
1576
2003
  async sendVerificationEmail() {
1577
- const headers = this.bearerHeaders();
1578
- if (!headers) throw new AuthError("UNAUTHORIZED", "Sign in before requesting a verification email");
1579
- const res = await post(
1580
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1581
- "/api/v1/auth-service/email/verify/resend",
1582
- {},
1583
- headers
1584
- );
2004
+ this.rejectIfWithheld("sendVerificationEmail");
2005
+ const headers = await this.bearerHeaders();
2006
+ const res = await post(this.http(), "/api/v1/auth-service/email/verify/resend", {}, headers);
1585
2007
  if (!res || !res.ok) {
1586
2008
  throw new AuthError("VERIFICATION_RESEND_FAILED", "Failed to send verification email");
1587
2009
  }
1588
2010
  }
1589
2011
  // ============================================================
1590
- // TOTP MFA — enrollment management (Bearer-authenticated)
2012
+ // HELD GUESTS (ADR-033 §3.5) — getHeldGuests / claim / restore / discard
2013
+ // ============================================================
2014
+ /**
2015
+ * The guests held on this device, newest first (`heldAt` desc). Never exposes
2016
+ * tokens. Best-effort: a guest may have been deleted server-side since it was
2017
+ * held — that is only discovered when its token is presented (restore /
2018
+ * claim / discard), as `guest_released{reason:'dead'}`.
2019
+ *
2020
+ * `autoRestore === false` ⇔ the slot was stamped by `deleteAccount()`: only an
2021
+ * explicit `restoreHeldGuest(id)` revives it. `project` is the guest's JWT
2022
+ * `project` claim at HOLD (`null` when it was unreadable).
2023
+ */
2024
+ getHeldGuests() {
2025
+ return this.heldSorted().map((h) => ({ userId: h.userId, heldAt: h.heldAt, autoRestore: h.autoRestore, project: h.project }));
2026
+ }
2027
+ /** The newest held guest, or null. */
2028
+ getHeldGuest() {
2029
+ const newest = this.heldSorted()[0];
2030
+ return newest ? { userId: newest.userId, heldAt: newest.heldAt } : null;
2031
+ }
2032
+ /**
2033
+ * Fold a held guest INTO the current identified account (`POST /merge`):
2034
+ * reassigns its events, profile, entities and push tokens, DELETES the guest
2035
+ * row and fires `auth.user_merged`. Irreversible, Sendora-side only — it does
2036
+ * not touch app data keyed on the guest's `sub`. ⚠ OPTIONAL: an app whose
2037
+ * backend does not act on `auth.user_merged` MUST NOT call it.
2038
+ *
2039
+ * Identified only. `id` may be omitted only when exactly one guest is held
2040
+ * (else `AMBIGUOUS_HELD_GUEST`). The held token is refreshed FIRST (proves
2041
+ * liveness, repairs a spent parent inside the 60 s grace) and the rotation is
2042
+ * persisted into the slot BEFORE the merge; a dead guest releases the slot
2043
+ * (`guest_released{dead}`) and rejects `HELD_GUEST_GONE`. Emits
2044
+ * `user_updated{cause:'merge'}` + `guest_released{reason:'claimed'}`.
2045
+ */
2046
+ claimHeldGuest(id) {
2047
+ return this.serialize(async () => {
2048
+ this.rejectIfWithheld("claimHeldGuest");
2049
+ await this.awaitSettled();
2050
+ const status = this.status();
2051
+ if (status !== "identified") {
2052
+ throw new AuthError(
2053
+ "CLAIM_REQUIRES_IDENTIFIED",
2054
+ status === "anonymous" ? "claimHeldGuest() merges a guest INTO an identified account; the current session is a guest" : "Sign in to an account before claiming a held guest",
2055
+ { kind: "unknown" }
2056
+ );
2057
+ }
2058
+ const i = this.resolveSlot(id);
2059
+ const expect = this.snapshotIdentity();
2060
+ this.attempt = { kind: "claim", expect };
2061
+ try {
2062
+ const refreshed = await this.refreshHeld(i);
2063
+ if (refreshed.outcome === "dead") throw new AuthError("HELD_GUEST_GONE", "The held guest is no longer available on the server", { kind: "unknown" });
2064
+ if (refreshed.outcome === "kept") throw new AuthError("RESTORE_FAILED", "The held guest could not be refreshed right now; it is still held", { kind: "server", retryable: true });
2065
+ const heldRefresh = refreshed.data.tokens.refreshToken;
2066
+ const target = this.hooks.storage.get(REFRESH_KEY);
2067
+ if (!target) throw new AuthError("NOT_SIGNED_IN", "Sign in before claiming a held guest");
2068
+ const merged = await this.postMerge(heldRefresh, target);
2069
+ await this.awaitRefreshQuiet();
2070
+ const user = this.user;
2071
+ if (!user) throw new AuthError("NOT_SIGNED_IN", "The session ended while the claim was in flight");
2072
+ const outcome = this.commit({
2073
+ kind: "install",
2074
+ reason: "claim",
2075
+ record: { user, accessToken: merged.tokens.accessToken, refreshToken: merged.tokens.refreshToken, expiresIn: merged.tokens.expiresIn },
2076
+ expect,
2077
+ expectRefresh: target
2078
+ });
2079
+ if (outcome.status !== "applied") {
2080
+ throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the claim was in flight", { kind: "unknown" });
2081
+ }
2082
+ this.commit({ kind: "release", reason: "claimed", slot: i, userId: refreshed.data.user.id });
2083
+ return { user, stats: merged.stats };
2084
+ } finally {
2085
+ this.attempt = null;
2086
+ }
2087
+ });
2088
+ }
2089
+ /**
2090
+ * Sign back in AS a held guest without merging (the RESTORE row). From
2091
+ * `signed_out` or an identified account (whose refresh token is revoked
2092
+ * fire-and-forget); from a guest the CURRENT guest is held first (its slot is
2093
+ * written before the chosen one is released, so there is never a window with
2094
+ * zero handles to either). `id` may be omitted only when exactly one guest is
2095
+ * held. Client-side project check FIRST: a slot whose recorded `project`
2096
+ * mismatches `expectedProjectId ?? config.projectId` throws
2097
+ * `HeldGuestProjectMismatch` with no network and the slot kept. A dead guest
2098
+ * releases the slot and rejects `HELD_GUEST_GONE`. Emits
2099
+ * `signed_in{cause:'restore'}` (re-register push) + `guest_released{restored}`.
2100
+ */
2101
+ restoreHeldGuest(id) {
2102
+ return this.serialize(async () => {
2103
+ this.rejectIfWithheld("restoreHeldGuest");
2104
+ await this.awaitSettled();
2105
+ const i = this.resolveSlot(id);
2106
+ const slot = this.held[i];
2107
+ const expectedProject = this.hooks.expectedProjectId ?? this.hooks.projectId;
2108
+ if (expectedProject !== void 0 && slot.project !== null && slot.project !== expectedProject) {
2109
+ throw new HeldGuestProjectMismatch(slot.userId, expectedProject, slot.project);
2110
+ }
2111
+ const status = this.status();
2112
+ if (status === "anonymous" && this.freeSlotIndex() === -1) throw new HeldGuestLimitError();
2113
+ const expect = this.snapshotIdentity();
2114
+ this.attempt = { kind: "restore", expect };
2115
+ try {
2116
+ const refreshed = await this.refreshHeld(i);
2117
+ if (refreshed.outcome === "dead") throw new AuthError("HELD_GUEST_GONE", "The held guest is no longer available on the server", { kind: "unknown" });
2118
+ if (refreshed.outcome === "kept") throw new AuthError("RESTORE_FAILED", "The held guest could not be refreshed right now; it is still held", { kind: "server", retryable: true });
2119
+ return this.installRestored(i, refreshed.data, expect);
2120
+ } finally {
2121
+ this.attempt = null;
2122
+ }
2123
+ });
2124
+ }
2125
+ /**
2126
+ * DELETE a held guest on the server (`POST /guest/discard` — a real erasure:
2127
+ * data → credentials → row → `auth.user_deleted{reason:'guest_discarded'}`)
2128
+ * and release its slot. The app is the requester on behalf of its player
2129
+ * (s58.316); the SDK never calls this on its own. `id` may be omitted only
2130
+ * when exactly one guest is held. A 2xx or a 404 (already gone) releases the
2131
+ * slot; any other failure keeps it. Emits `guest_released{reason:'discarded'}`.
2132
+ */
2133
+ discardHeldGuest(id) {
2134
+ return this.serialize(async () => {
2135
+ this.rejectIfWithheld("discardHeldGuest");
2136
+ await this.awaitSettled();
2137
+ const i = this.resolveSlot(id);
2138
+ await this.discardSlot(i);
2139
+ });
2140
+ }
2141
+ /** Shared body of `discardHeldGuest()` and `deleteAccount({alsoDiscardHeldGuests})`. */
2142
+ async discardSlot(i) {
2143
+ const slot = this.held[i];
2144
+ if (!slot) return;
2145
+ this.attempt = { kind: "discard", expect: this.snapshotIdentity() };
2146
+ try {
2147
+ const res = await post(this.http(), "/api/v1/auth-service/guest/discard", { refreshToken: slot.refreshToken });
2148
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2149
+ if (!res.ok && res.status !== 404) {
2150
+ const parsed = await res.json().catch(() => ({}));
2151
+ throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`, {
2152
+ status: res.status,
2153
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
2154
+ });
2155
+ }
2156
+ this.commit({ kind: "release", reason: "discarded", slot: i, userId: slot.userId });
2157
+ } finally {
2158
+ this.attempt = null;
2159
+ }
2160
+ }
2161
+ // ============================================================
2162
+ // WITHHELD (ADR-033 [2c]) — getWithheld / discardWithheld
2163
+ // ============================================================
2164
+ /**
2165
+ * While `status() === "withheld"`: why the stored session was refused. Never
2166
+ * exposes tokens. `userId` lets a `sub`-keyed backend record oldSub → newSub
2167
+ * before `discardWithheld()`. `null` in every other state.
2168
+ */
2169
+ getWithheld() {
2170
+ return this.withheld;
2171
+ }
2172
+ /**
2173
+ * Resolve `withheld` — the local counterpart of `signOut()` in that state
2174
+ * (and what `signOut()` does there). Precondition `status() === "withheld"`
2175
+ * (else resolves `false`, nothing mutated). CAS on the withheld refresh token;
2176
+ * clears the session keys only; fire-and-forget `/token/revoke` like
2177
+ * `signOut()`; LOCAL ONLY — never `/guest/discard`, never a server delete;
2178
+ * KEEPS held slots, the event queue and the persisted `user_id`. Emits
2179
+ * `signed_out{reason:'user'}`. The other exit is relaunching with the matching
2180
+ * `expectedProjectId`.
2181
+ */
2182
+ discardWithheld() {
2183
+ return this.serialize(async () => {
2184
+ if (!this.withheld) return false;
2185
+ return this.commit({ kind: "discard_withheld" }).status === "applied";
2186
+ });
2187
+ }
2188
+ // ============================================================
2189
+ // SESSION VERBS (Bearer; NOT transitions — reject in `withheld`)
1591
2190
  // ============================================================
1592
2191
  async enrollMfa() {
1593
- const res = await post(
1594
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1595
- "/api/v1/auth-service/mfa/enroll/start",
1596
- {},
1597
- this.bearerHeaders()
1598
- );
2192
+ this.rejectIfWithheld("enrollMfa");
2193
+ const res = await post(this.http(), "/api/v1/auth-service/mfa/enroll/start", {}, await this.bearerHeaders());
1599
2194
  return await this.unwrap(res, "MFA_ENROLL_FAILED");
1600
2195
  }
1601
2196
  async confirmMfa(code) {
1602
- const res = await post(
1603
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1604
- "/api/v1/auth-service/mfa/enroll/confirm",
1605
- { code },
1606
- this.bearerHeaders()
1607
- );
2197
+ this.rejectIfWithheld("confirmMfa");
2198
+ const res = await post(this.http(), "/api/v1/auth-service/mfa/enroll/confirm", { code }, await this.bearerHeaders());
1608
2199
  const data = await this.unwrap(res, "MFA_CONFIRM_FAILED");
1609
2200
  return Boolean(data.confirmed);
1610
2201
  }
1611
2202
  /**
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.
2203
+ * Disable MFA. ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with
2204
+ * 403 `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`. The response is
2205
+ * inspected (s58.335): `request()` never throws on a non-2xx, so a discarded
2206
+ * return would report the refusal as success.
1620
2207
  */
1621
2208
  async disableMfa() {
1622
- const res = await post(
1623
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1624
- "/api/v1/auth-service/mfa/disable",
1625
- {},
1626
- this.bearerHeaders()
1627
- );
2209
+ this.rejectIfWithheld("disableMfa");
2210
+ const res = await post(this.http(), "/api/v1/auth-service/mfa/disable", {}, await this.bearerHeaders());
1628
2211
  await this.unwrap(res, "MFA_DISABLE_FAILED");
1629
2212
  }
1630
- // ============================================================
1631
- // DEVICE SESSIONS (Bearer-authenticated)
1632
- // ============================================================
1633
2213
  async listMySessions() {
1634
- const res = await getJson(
1635
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1636
- "/api/v1/auth-service/sessions/me",
1637
- this.bearerHeaders()
1638
- );
2214
+ this.rejectIfWithheld("listMySessions");
2215
+ const res = await getJson(this.http(), "/api/v1/auth-service/sessions/me", await this.bearerHeaders());
1639
2216
  if (!res || !res.ok) return [];
1640
2217
  const parsed = await res.json();
1641
2218
  return Array.isArray(parsed.data) ? parsed.data : [];
1642
2219
  }
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
- */
2220
+ /** Revoke one of the caller's own sessions ("sign out that device"). Inspects the response (s58.335). */
1649
2221
  async revokeSession(sessionId) {
1650
- const res = await del(
1651
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1652
- `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`,
1653
- this.bearerHeaders()
1654
- );
2222
+ this.rejectIfWithheld("revokeSession");
2223
+ const res = await del(this.http(), `/api/v1/auth-service/sessions/me/${encodeURIComponent(sessionId)}`, await this.bearerHeaders());
1655
2224
  await this.unwrap(res, "SESSION_REVOKE_FAILED");
1656
2225
  }
1657
- /** Revoke every session for the caller. Same reporting defect as `revokeSession`. */
2226
+ /** Revoke every session for the caller. Inspects the response (s58.335). */
1658
2227
  async revokeAllSessions() {
1659
- const res = await del(
1660
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1661
- "/api/v1/auth-service/sessions/me",
1662
- this.bearerHeaders()
1663
- );
2228
+ this.rejectIfWithheld("revokeAllSessions");
2229
+ const res = await del(this.http(), "/api/v1/auth-service/sessions/me", await this.bearerHeaders());
1664
2230
  await this.unwrap(res, "SESSION_REVOKE_ALL_FAILED");
1665
2231
  }
2232
+ // ============================================================
2233
+ // DELETE (ADR-033 §3.3 row 28)
2234
+ // ============================================================
1666
2235
  /**
1667
2236
  * Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
1668
- * Honors the project's configured grace period:
1669
- * - `"purged"` — hard-deleted immediately (grace = 0).
1670
- * - `"pending"` — deactivated + all sessions revoked now; hard-deleted at
1671
- * `scheduledPurgeAt`. The user can CANCEL by signing back in before then.
1672
- * Either outcome wipes local identity (the server has already revoked the
1673
- * session). Requires a signed-in user. Throws on a non-2xx response.
2237
+ * Honors the project's configured grace period: `"purged"` (hard-deleted
2238
+ * now) or `"pending"` (deactivated + sessions revoked now, hard-deleted at
2239
+ * `scheduledPurgeAt`; cancellable by signing back in). Resolves a FRESH access
2240
+ * token first — a stale token's 401 would silently strand the user with an
2241
+ * undeleted account.
1674
2242
  *
1675
- * Resolves a FRESH access token via `getAccessToken()` first (refreshing if
1676
- * the cached one expired) — unlike the other Bearer calls this is a one-shot
1677
- * destructive action, so a 401 from a stale token would silently strand the
1678
- * user with an undeleted account. A stale `this.accessToken` was the cause of
1679
- * the 401s seen in prod when a user tapped "delete" after the app sat idle.
2243
+ * Held guests (ADR-033): deleting an account never touches a held guest, on
2244
+ * the device or the server. By default they SURVIVE with auto-restore
2245
+ * suppressed (`autoRestore: false` — only an explicit `restoreHeldGuest(id)`
2246
+ * revives one; `ensureSession()` mints fresh). `alsoDiscardHeldGuests: true`
2247
+ * is the player's explicit ask to erase them too: each is discarded after the
2248
+ * 2xx, a slot that fails to discard stays held and is listed in
2249
+ * `discardFailed`. `heldGuests` = guests still held afterwards. The queued
2250
+ * analytics events are dropped (erasure completeness, 1.39.1).
1680
2251
  */
1681
- async deleteAccount() {
2252
+ async deleteAccount(opts) {
2253
+ this.rejectIfWithheld("deleteAccount");
1682
2254
  const token = await this.getAccessToken();
1683
2255
  if (!token) {
1684
2256
  throw new AuthError("NOT_SIGNED_IN", "Sign in before deleting your account");
1685
2257
  }
1686
- const res = await del(
1687
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1688
- "/api/v1/auth-service/me",
1689
- { Authorization: `Bearer ${token}` }
1690
- );
2258
+ const res = await del(this.http(), "/api/v1/auth-service/me", { Authorization: `Bearer ${token}` });
1691
2259
  if (!res || !res.ok) {
1692
2260
  throw new Error(`deleteAccount failed${res ? ` (HTTP ${res.status})` : " (network error)"}`);
1693
2261
  }
@@ -1705,183 +2273,592 @@ var Auth = class {
1705
2273
  graceDays: parsed.data.graceDays ?? 0
1706
2274
  };
1707
2275
  }
1708
- } catch {
2276
+ } catch {
2277
+ }
2278
+ await this.commitAsync({ kind: "clear", reason: "account_deleted", suppressHeldAutoRestore: true });
2279
+ const discardFailed = [];
2280
+ if (opts?.alsoDiscardHeldGuests) {
2281
+ for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
2282
+ const slot = this.held[i];
2283
+ if (!slot) continue;
2284
+ try {
2285
+ await this.discardSlot(i);
2286
+ } catch {
2287
+ discardFailed.push(slot.userId);
2288
+ }
2289
+ }
2290
+ }
2291
+ return { ...out, heldGuests: this.heldCount(), discardFailed };
2292
+ }
2293
+ // ============================================================
2294
+ // OIDC / SAML SSO (sign-in continuation)
2295
+ // ============================================================
2296
+ /**
2297
+ * Start OIDC sign-in. Returns the IdP's authorization URL — caller opens it
2298
+ * via `Linking.openURL()` (or `WebBrowser.openAuthSessionAsync`). The IdP
2299
+ * redirects through Sendora's callback to the `returnTo` URL with
2300
+ * `#sendora_oidc_token=...` in the fragment; hand that URL to
2301
+ * `consumeSsoFragment(url)`. `returnTo` MUST be a custom URL scheme
2302
+ * registered in Info.plist / AndroidManifest. No guest hint travels (INV-3).
2303
+ */
2304
+ async startSso(returnTo) {
2305
+ const res = await post(this.http(), "/api/v1/auth-service/sso/oidc/start", { returnTo });
2306
+ if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
2307
+ const parsed = await res.json();
2308
+ if (!parsed.success || !parsed.data?.authorizationUrl) {
2309
+ throw new AuthError("SSO_START_FAILED", "Missing authorizationUrl");
2310
+ }
2311
+ return { authorizationUrl: parsed.data.authorizationUrl };
2312
+ }
2313
+ /** Kick off a SAML 2.0 SSO flow. Mirrors `startSso`; `consumeSsoFragment` handles both fragments. */
2314
+ async startSaml(returnTo) {
2315
+ const res = await post(this.http(), "/api/v1/auth-service/sso/saml/start", { returnTo });
2316
+ if (!res || !res.ok) throw new AuthError("SAML_START_FAILED", `HTTP ${res?.status ?? "network"}`);
2317
+ const parsed = await res.json();
2318
+ if (!parsed.success || !parsed.data?.authorizationUrl) {
2319
+ throw new AuthError("SAML_START_FAILED", "Missing authorizationUrl");
2320
+ }
2321
+ return { authorizationUrl: parsed.data.authorizationUrl };
2322
+ }
2323
+ /**
2324
+ * Consume an OIDC/SAML callback URL captured via `Linking.addEventListener`.
2325
+ * Reads the fragment for `sendora_oidc_token` / `sendora_saml_token`,
2326
+ * exchanges it for a session via `/token/refresh`, and installs it as a
2327
+ * sign-in (holding a guest, if any). The callback is parsed and exchanged
2328
+ * FIRST — an IdP "Cancel" or a garbage fragment leaves the session untouched.
2329
+ */
2330
+ consumeSsoFragment(callbackUrl) {
2331
+ return this.serialize(async () => {
2332
+ this.rejectIfWithheld("consumeSsoFragment");
2333
+ await this.awaitSettled();
2334
+ const hashIdx = callbackUrl.indexOf("#");
2335
+ const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
2336
+ const params = new URLSearchParams(fragment);
2337
+ const err = params.get("sendora_oidc_error") ?? params.get("sendora_saml_error");
2338
+ if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
2339
+ const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
2340
+ if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
2341
+ const retiredAnonUserId = params.get("sendora_retired_anon");
2342
+ const expect = this.snapshotIdentity();
2343
+ this.attempt = { kind: "sign_in", expect };
2344
+ try {
2345
+ await this.awaitRefreshQuiet();
2346
+ const res = await post(this.http(), REFRESH_PATH, { refreshToken: refresh });
2347
+ if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
2348
+ const parsed = await res.json();
2349
+ const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
2350
+ if (!rotated) {
2351
+ throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
2352
+ }
2353
+ const d = parsed.data;
2354
+ const user = isAuthUserShape(d?.user) ? d.user : this.userFromClaims(rotated.accessToken);
2355
+ const data = {
2356
+ user,
2357
+ tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn, tokenType: "Bearer" },
2358
+ ...retiredAnonUserId ? { retiredAnonUserId } : {}
2359
+ };
2360
+ await this.awaitRefreshQuiet();
2361
+ this.installSignIn(data, expect);
2362
+ return user;
2363
+ } finally {
2364
+ this.attempt = null;
2365
+ }
2366
+ });
2367
+ }
2368
+ /** Populate a user object from access-token claims when a response carries none (pre-B17 `/token/refresh`). */
2369
+ userFromClaims(access) {
2370
+ const claims = decodeJwtPayload(access);
2371
+ return {
2372
+ id: typeof claims?.sub === "string" ? claims.sub : "",
2373
+ email: typeof claims?.email === "string" ? claims.email : null,
2374
+ emailVerified: typeof claims?.email_verified === "boolean" ? claims.email_verified : true,
2375
+ name: typeof claims?.name === "string" ? claims.name : null,
2376
+ isAnonymous: claims?.is_anonymous === true,
2377
+ ...claims && Object.prototype.hasOwnProperty.call(claims, "project") ? { project: typeof claims.project === "string" ? claims.project : null } : {}
2378
+ };
2379
+ }
2380
+ /**
2381
+ * Build the Authorization header for end-user-authenticated routes. Awaits
2382
+ * `getAccessToken()` (parity with sdk-web since 1.41.0), so a cold start with
2383
+ * no cached access token or an expired token transparently refreshes first.
2384
+ * Throws `NOT_SIGNED_IN` when there is genuinely no usable session.
2385
+ */
2386
+ async bearerHeaders() {
2387
+ const accessToken = await this.getAccessToken();
2388
+ if (!accessToken) {
2389
+ throw new AuthError("NOT_SIGNED_IN", "Not signed in");
2390
+ }
2391
+ return { Authorization: `Bearer ${accessToken}` };
2392
+ }
2393
+ async unwrap(res, errCode) {
2394
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2395
+ const parsed = await res.json().catch(() => ({}));
2396
+ if (!res.ok || !parsed.success) {
2397
+ throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
2398
+ status: res.status,
2399
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
2400
+ });
2401
+ }
2402
+ return parsed.data;
2403
+ }
2404
+ // ============================================================
2405
+ // SIGN OUT (ADR-033 §3.3 row 27) — local-first, the one deliberate exception
2406
+ // ============================================================
2407
+ /**
2408
+ * Sign out: clear the local session FIRST so the user is logged out on
2409
+ * device even if the revoke network call hangs (airplane mode, 5xx); revoke
2410
+ * is fire-and-forget after the clear. Rotates the analytics anon id (a
2411
+ * person boundary). KEEPS held guests — a guest's refresh token is its only
2412
+ * credential, so wiping the slots on logout would be deletion by assumption
2413
+ * (use `discardHeldGuest()` / `reset({includeHeldGuests:true})` deliberately).
2414
+ * In `withheld` this is `discardWithheld()`.
2415
+ */
2416
+ signOut() {
2417
+ return this.serialize(async () => {
2418
+ if (this.withheld) {
2419
+ this.commit({ kind: "discard_withheld" });
2420
+ return;
2421
+ }
2422
+ await this.commitAsync({ kind: "clear", reason: "user" });
2423
+ });
2424
+ }
2425
+ // ============================================================
2426
+ // DEPRECATED, kept callable
2427
+ // ============================================================
2428
+ /**
2429
+ * @deprecated Transfer every event + trait from an anonymous user to the
2430
+ * currently-signed-in (target) user, then delete the anonymous row.
2431
+ *
2432
+ * Since 1.41.0 the guest you signed in over is HELD on this device, so a
2433
+ * token that matches a held slot (its current refresh token OR the token it
2434
+ * was held with) routes to `claimHeldGuest(thatGuest)` — a DESTRUCTIVE claim.
2435
+ * Any other token takes the raw `/merge` path as before. Prefer
2436
+ * `claimHeldGuest(id)`; never call either from a backend that does not act
2437
+ * on `auth.user_merged`.
2438
+ */
2439
+ async merge(anonymousRefreshToken, targetRefreshToken) {
2440
+ const i = this.held.findIndex((h) => h !== null && (h.refreshToken === anonymousRefreshToken || h.tokenAtHold === anonymousRefreshToken));
2441
+ if (i !== -1 && targetRefreshToken === void 0) {
2442
+ const claimed = await this.claimHeldGuest(this.held[i].userId);
2443
+ return claimed.stats;
2444
+ }
2445
+ const target = targetRefreshToken ?? this.getRefreshToken();
2446
+ if (!target) {
2447
+ throw new AuthError(
2448
+ "NO_TARGET_REFRESH",
2449
+ "merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
2450
+ );
2451
+ }
2452
+ const merged = await this.postMerge(anonymousRefreshToken, target);
2453
+ const user = this.user;
2454
+ if (user && this.hooks.storage.get(REFRESH_KEY) === target) {
2455
+ await this.awaitRefreshQuiet();
2456
+ this.commit({
2457
+ kind: "install",
2458
+ reason: "claim",
2459
+ record: { user, accessToken: merged.tokens.accessToken, refreshToken: merged.tokens.refreshToken, expiresIn: merged.tokens.expiresIn },
2460
+ expect: this.snapshotIdentity(),
2461
+ expectRefresh: target,
2462
+ silent: true
2463
+ });
2464
+ }
2465
+ return merged.stats;
2466
+ }
2467
+ /** `POST /merge` — bypasses `callAuth`'s `isAuthApiResponse` gate (`/merge` returns `{ ...stats, tokens, csrfToken }`, no `user`). */
2468
+ async postMerge(anonymousRefreshToken, targetRefreshToken) {
2469
+ const res = await post(this.http(), "/api/v1/auth-service/merge", { anonymousRefreshToken, targetRefreshToken });
2470
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2471
+ let parsed;
2472
+ try {
2473
+ parsed = await res.json();
2474
+ } catch {
2475
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
2476
+ }
2477
+ if (!res.ok || !parsed.success || !parsed.data) {
2478
+ throw new AuthError(
2479
+ parsed.error?.code ?? `HTTP_${res.status}`,
2480
+ parsed.error?.message ?? `HTTP ${res.status}`,
2481
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
2482
+ );
2483
+ }
2484
+ const tokens = readRotatedTokens({ tokens: parsed.data.tokens });
2485
+ if (!tokens) throw new AuthError("MERGE_FAILED", "merge response carried no rotated token pair", { status: res.status });
2486
+ const d = parsed.data;
2487
+ return {
2488
+ tokens,
2489
+ stats: {
2490
+ mergedUserId: d.mergedUserId ?? "",
2491
+ deletedAnonymousId: d.deletedAnonymousId ?? "",
2492
+ eventsReassigned: d.eventsReassigned ?? 0,
2493
+ profilesReassigned: d.profilesReassigned ?? 0,
2494
+ profilesDropped: d.profilesDropped ?? 0,
2495
+ entitiesReassigned: d.entitiesReassigned ?? 0,
2496
+ entitiesDropped: d.entitiesDropped ?? 0
2497
+ }
2498
+ };
2499
+ }
2500
+ // ============================================================
2501
+ // INTERNALS — attempts, settling, refresh, cron
2502
+ // ============================================================
2503
+ http() {
2504
+ return { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug };
2505
+ }
2506
+ /** ADR-033 [2c]: in `withheld` every verb in REJECTED_IN_WITHHELD rejects with no network and no write. */
2507
+ rejectIfWithheld(verb) {
2508
+ if (this.withheld) {
2509
+ throw withheldError(
2510
+ "SESSION_WITHHELD",
2511
+ `${verb}() is unavailable while the stored session is withheld (its project claim disagrees with init({expectedProjectId})). Call discardWithheld() or relaunch with the matching project.`
2512
+ );
2513
+ }
2514
+ }
2515
+ /** True in `anonymous` / `identified` — a live session a LINK can attach to. */
2516
+ hasSession() {
2517
+ const s = this.status();
2518
+ return s === "anonymous" || s === "identified";
2519
+ }
2520
+ serialize(op) {
2521
+ const guarded = async () => {
2522
+ await this.readyPromise;
2523
+ try {
2524
+ return await op();
2525
+ } catch (err) {
2526
+ throw asAuthError(err);
2527
+ }
2528
+ };
2529
+ const next = this.inflight.then(guarded, guarded);
2530
+ this.inflight = next.catch(() => void 0);
2531
+ return next;
2532
+ }
2533
+ /**
2534
+ * INV-5 — a session-installing verb in `unknown` QUEUES until the status
2535
+ * settles: the single gap-fill refresh either installs the identity or
2536
+ * reports the token dead (`signed_out`). Offline it keeps waiting with the
2537
+ * refresh backoff (never minting, never wiping). Ends in `withheld` → rejects.
2538
+ */
2539
+ async awaitSettled() {
2540
+ await this.readyPromise;
2541
+ while (!this.withheld && this.status() === "unknown") {
2542
+ await this.refreshAccessToken();
2543
+ if (this.withheld || this.status() !== "unknown") break;
2544
+ const wait = Math.min(3e4, Math.max(100, this.refreshCooldownUntil - Date.now()));
2545
+ await new Promise((r) => setTimeout(r, wait));
2546
+ }
2547
+ if (this.withheld) this.rejectIfWithheld("auth");
2548
+ }
2549
+ /** An identity-changing attempt awaits any in-flight refresh at start AND at commit (ADR-033 §3.3 scheduling rule 1). */
2550
+ async awaitRefreshQuiet() {
2551
+ while (this.refreshInflight) {
2552
+ await this.refreshInflight.catch(() => void 0);
2553
+ }
2554
+ }
2555
+ snapshotIdentity() {
2556
+ if (!this.hooks.storage.get(REFRESH_KEY) || !this.user) return { sub: null, sid: null };
2557
+ const claims = decodeJwtPayload(this.accessToken);
2558
+ return { sub: this.user.id, sid: typeof claims?.sid === "string" ? claims.sid : null };
2559
+ }
2560
+ /** The identity CAS (INV-2): `sid` when both sides know it, else the `sub`; a signed-out side never matches a live one. */
2561
+ identityMatches(expect) {
2562
+ const cur = this.snapshotIdentity();
2563
+ if (expect.sub !== cur.sub) return false;
2564
+ if (expect.sid !== null && cur.sid !== null) return expect.sid === cur.sid;
2565
+ return true;
2566
+ }
2567
+ /** The current session's JWT `project` claim (string | null), or undefined when unreadable. */
2568
+ claimProject(accessToken) {
2569
+ const claims = decodeJwtPayload(accessToken);
2570
+ if (!claims || !Object.prototype.hasOwnProperty.call(claims, "project")) return void 0;
2571
+ const p = claims.project;
2572
+ return typeof p === "string" ? p : null;
2573
+ }
2574
+ /**
2575
+ * The ProjectGuard (INV-15) as `commit()` applies it: the shared
2576
+ * `projectVerdict()` over the access JWT, and — when the claim is unreadable
2577
+ * — the B17 `user.project` field as the second source. Absent on both = `unknown`.
2578
+ */
2579
+ verdictFor(user, accessToken) {
2580
+ const expected = this.hooks.expectedProjectId;
2581
+ if (expected === void 0) return "match";
2582
+ const v = projectVerdict(expected, accessToken);
2583
+ if (v !== "unknown") return v;
2584
+ if (user.project !== void 0) {
2585
+ if (user.project === null) return "mismatch";
2586
+ return user.project === expected ? "match" : "mismatch";
2587
+ }
2588
+ return "unknown";
2589
+ }
2590
+ heldCount() {
2591
+ return this.held.reduce((n, h) => n + (h ? 1 : 0), 0);
2592
+ }
2593
+ heldSorted() {
2594
+ return this.held.filter((h) => h !== null).sort((a, b) => b.heldAt - a.heldAt);
2595
+ }
2596
+ /** Slot indices eligible for auto-restore, newest first (ADR-033 §3.3 "eligible"). */
2597
+ eligibleHeldSlots() {
2598
+ const expected = this.hooks.expectedProjectId ?? this.hooks.projectId;
2599
+ return this.held.map((h, i) => ({ h, i })).filter(({ h }) => h !== null && h.autoRestore !== false && !(expected !== void 0 && h.project !== null && h.project !== expected)).sort((a, b) => b.h.heldAt - a.h.heldAt).map(({ i }) => i);
2600
+ }
2601
+ freeSlotIndex() {
2602
+ return this.held.findIndex((h) => h === null);
2603
+ }
2604
+ /** The `id` rule shared by claim / restore / discard: omit it ONLY when exactly one guest is held. */
2605
+ resolveSlot(id) {
2606
+ const present = this.held.map((h, i2) => h ? i2 : -1).filter((i2) => i2 !== -1);
2607
+ if (id === void 0) {
2608
+ if (present.length === 1) return present[0];
2609
+ if (present.length === 0) throw new AuthError("NO_HELD_GUEST", "No guest is held on this device", { kind: "unknown" });
2610
+ throw new AuthError(AMBIGUOUS_HELD_GUEST, `${present.length} guests are held on this device; pass the userId of the one you mean`, { kind: "unknown" });
2611
+ }
2612
+ const i = this.held.findIndex((h) => h?.userId === id);
2613
+ if (i === -1) throw new AuthError("HELD_GUEST_NOT_FOUND", `No held guest with userId ${id}`, { kind: "unknown" });
2614
+ return i;
2615
+ }
2616
+ // ── SIGN_IN row implementation ───────────────────────────────────────────
2617
+ async doSignInDone(credential) {
2618
+ const r = await this.doSignIn(credential);
2619
+ if (r.kind === "mfa") {
2620
+ throw new AuthError("MFA_REQUIRED", "Multi-factor authentication is required; call challengeMfa(mfaChallengeToken, code)", { kind: "invalid_credential" });
2621
+ }
2622
+ return r;
2623
+ }
2624
+ /**
2625
+ * The SIGN_IN / SWITCH row (ADR-033 §3.3 row 18). The body carries NO guest
2626
+ * hint; a success HOLDS a previous guest (post-refresh tokens) and installs
2627
+ * the new session under the identity CAS; any failure leaves the caller
2628
+ * exactly as found.
2629
+ */
2630
+ async doSignIn(credential) {
2631
+ if (this.status() === "anonymous" && this.freeSlotIndex() === -1) throw new HeldGuestLimitError();
2632
+ this.attempt = { kind: "sign_in", expect: this.snapshotIdentity() };
2633
+ try {
2634
+ await this.awaitRefreshQuiet();
2635
+ const expect = this.snapshotIdentity();
2636
+ this.attempt = { kind: "sign_in", expect };
2637
+ const prev = this.user ? { userId: this.user.id, isAnonymous: this.user.isAnonymous } : null;
2638
+ const { path, body } = signInWire(credential);
2639
+ const res = await post(this.http(), path, body);
2640
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2641
+ let parsed;
2642
+ try {
2643
+ parsed = await res.json();
2644
+ } catch {
2645
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
2646
+ }
2647
+ if (!res.ok || !parsed.success || !parsed.data) {
2648
+ if (parsed.error?.code === "CREDENTIAL_IN_USE") {
2649
+ throw new CredentialInUseError(parsed.error.message, { ...readCollisionDetails(parsed), status: res.status });
2650
+ }
2651
+ throw new AuthError(
2652
+ parsed.error?.code ?? `HTTP_${res.status}`,
2653
+ parsed.error?.message ?? `HTTP ${res.status}`,
2654
+ { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
2655
+ );
2656
+ }
2657
+ const r = parsed.data;
2658
+ if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
2659
+ const u = r.user;
2660
+ return { kind: "mfa", challengeToken: r.mfaChallengeToken, userId: typeof u?.id === "string" ? u.id : "" };
2661
+ }
2662
+ if (!isAuthApiResponse(parsed.data)) {
2663
+ throw new AuthError(parsed.error?.code ?? "AUTH_ERROR", parsed.error?.message ?? "Sign-in failed");
2664
+ }
2665
+ const data = parsed.data;
2666
+ await this.awaitRefreshQuiet();
2667
+ const { held } = this.installSignIn(data, expect);
2668
+ return {
2669
+ kind: "done",
2670
+ user: data.user,
2671
+ previous: prev ? { ...prev, held } : null,
2672
+ reactivated: data.reactivatedFromDeletion === true
2673
+ };
2674
+ } finally {
2675
+ this.attempt = null;
1709
2676
  }
1710
- await this.wipeLocalIdentity("account_deleted");
1711
- return out;
1712
2677
  }
1713
- // ============================================================
1714
- // OIDC SSO
1715
- // ============================================================
1716
- /**
1717
- * Start OIDC sign-in. Returns the IdP's authorization URL — caller
1718
- * opens it via React Native's `Linking.openURL()` (or
1719
- * `WebBrowser.openAuthSessionAsync` from `expo-web-browser` when
1720
- * inside Expo). The IdP redirects through Sendora's callback to the
1721
- * `returnTo` URL with `#sendora_oidc_token=...` in the fragment.
1722
- *
1723
- * On the customer's app side, `returnTo` MUST be a custom URL scheme
1724
- * registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
1725
- * (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
1726
- * call `consumeSsoFragment(url)` on the captured URL.
1727
- */
1728
- async startSso(returnTo) {
1729
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1730
- const body = { returnTo };
1731
- if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1732
- const res = await post(
1733
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1734
- "/api/v1/auth-service/sso/oidc/start",
1735
- body
1736
- );
1737
- if (!res || !res.ok) throw new AuthError("SSO_START_FAILED", `HTTP ${res?.status ?? "network"}`);
1738
- const parsed = await res.json();
1739
- if (!parsed.success || !parsed.data?.authorizationUrl) {
1740
- throw new AuthError("SSO_START_FAILED", "Missing authorizationUrl");
2678
+ /** Commit a sign-in response as the SIGN_IN row; a CAS drop revokes the dropped refresh so server session B is not left live (row 19). */
2679
+ installSignIn(data, expect) {
2680
+ const outcome = this.commit({
2681
+ kind: "install",
2682
+ reason: "sign_in",
2683
+ record: { user: data.user, accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, expiresIn: data.tokens.expiresIn },
2684
+ expect,
2685
+ holdPrevious: true,
2686
+ response: data
2687
+ });
2688
+ if (outcome.status === "project_mismatch") {
2689
+ this.revokeFireAndForget(data.tokens.refreshToken);
2690
+ throw withheldError("PROJECT_MISMATCH", "The signed-in session belongs to a different project than init({expectedProjectId}); nothing was installed");
1741
2691
  }
1742
- return { authorizationUrl: parsed.data.authorizationUrl };
2692
+ if (outcome.status === "cas_mismatch" || outcome.status === "held_limit") {
2693
+ this.revokeFireAndForget(data.tokens.refreshToken);
2694
+ if (outcome.status === "held_limit") throw new HeldGuestLimitError();
2695
+ throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the sign-in was in flight; nothing was installed", { kind: "unknown" });
2696
+ }
2697
+ return { held: outcome.status === "applied" && outcome.held };
1743
2698
  }
2699
+ // ── LINK row implementation ──────────────────────────────────────────────
1744
2700
  /**
1745
- * Kick off a SAML 2.0 SSO flow. Mirrors `startSso` for SAML —
1746
- * backend POST `/auth-service/sso/saml/start` returns the IdP
1747
- * AuthnRequest URL. Caller opens it with
1748
- * ASWebAuthenticationSession (iOS) / Custom Tabs (Android) /
1749
- * Linking, captures the callback URL via the standard
1750
- * `Linking.addEventListener` flow, then passes the captured URL to
1751
- * `consumeSsoFragment(url)`. consumeSsoFragment handles both OIDC
1752
- * and SAML fragments transparently.
2701
+ * The LINK / LINK-UPGRADE rows (ADR-033 §3.3 rows 15–16) on the current
2702
+ * Bearer session. 200 with a rotated pair (`upgraded`) → ProjectGuard →
2703
+ * install (same `sub`); 200 attached → user updated in place; 200
2704
+ * `alreadyLinked` → nothing; 409 / 403 → typed outcomes, nothing mutated.
1753
2705
  */
1754
- async startSaml(returnTo) {
1755
- const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
1756
- const body = { returnTo };
1757
- if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
1758
- const res = await post(
1759
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1760
- "/api/v1/auth-service/sso/saml/start",
1761
- body
1762
- );
1763
- if (!res || !res.ok) throw new AuthError("SAML_START_FAILED", `HTTP ${res?.status ?? "network"}`);
1764
- const parsed = await res.json();
1765
- if (!parsed.success || !parsed.data?.authorizationUrl) {
1766
- throw new AuthError("SAML_START_FAILED", "Missing authorizationUrl");
2706
+ async doLink(path, body, ctx) {
2707
+ this.attempt = { kind: "link", expect: this.snapshotIdentity() };
2708
+ try {
2709
+ await this.awaitRefreshQuiet();
2710
+ const expect = this.snapshotIdentity();
2711
+ this.attempt = { kind: "link", expect };
2712
+ const token = await this.getAccessToken();
2713
+ if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before linking a credential");
2714
+ const res = await post(this.http(), path, body, { Authorization: `Bearer ${token}` });
2715
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2716
+ let parsed;
2717
+ try {
2718
+ parsed = await res.json();
2719
+ } catch {
2720
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
2721
+ }
2722
+ if (!res.ok || !parsed.success || !parsed.data) {
2723
+ const code = parsed.error?.code ?? `HTTP_${res.status}`;
2724
+ if (code === "CREDENTIAL_IN_USE") {
2725
+ const details = readCollisionDetails(parsed);
2726
+ return {
2727
+ status: "credential_in_use",
2728
+ provider: details.provider ?? ctx.label,
2729
+ collision: details.collision ?? (path === LINK_EMAIL_PATH ? "email" : "identity"),
2730
+ message: parsed.error?.message ?? "This credential is already linked to a different account",
2731
+ httpStatus: res.status
2732
+ };
2733
+ }
2734
+ if (code === "RECENT_AUTH_REQUIRED") {
2735
+ return { status: "recent_auth_required", message: parsed.error?.message ?? "Re-authenticate before changing credentials" };
2736
+ }
2737
+ throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
2738
+ status: res.status,
2739
+ retryAfterSeconds: readRetryAfterSeconds(parsed)
2740
+ });
2741
+ }
2742
+ const d = parsed.data;
2743
+ const alreadyLinked = d.alreadyLinked === true;
2744
+ await this.awaitRefreshQuiet();
2745
+ if (d.upgraded === true && isAuthApiResponse(d)) {
2746
+ const outcome = this.commit({
2747
+ kind: "install",
2748
+ reason: "upgrade",
2749
+ record: { user: d.user, accessToken: d.tokens.accessToken, refreshToken: d.tokens.refreshToken, expiresIn: d.tokens.expiresIn },
2750
+ expect,
2751
+ legacyEmit: ctx.legacyEmit,
2752
+ response: d
2753
+ });
2754
+ if (outcome.status === "project_mismatch") {
2755
+ this.revokeFireAndForget(d.tokens.refreshToken);
2756
+ throw withheldError("PROJECT_MISMATCH", "The promoted session belongs to a different project than init({expectedProjectId}); nothing was installed");
2757
+ }
2758
+ if (outcome.status !== "applied") {
2759
+ this.revokeFireAndForget(d.tokens.refreshToken);
2760
+ throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the link was in flight; nothing was installed", { kind: "unknown" });
2761
+ }
2762
+ return { status: "done", user: d.user, upgraded: true, alreadyLinked };
2763
+ }
2764
+ if (isAuthUserShape(d.user) && !alreadyLinked) {
2765
+ const outcome = this.commit({ kind: "user", reason: "link", user: d.user, expect });
2766
+ if (outcome.status !== "applied") {
2767
+ throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the link was in flight", { kind: "unknown" });
2768
+ }
2769
+ return { status: "done", user: d.user, upgraded: false, alreadyLinked: false };
2770
+ }
2771
+ const user = this.user ?? (isAuthUserShape(d.user) ? d.user : null);
2772
+ if (!user) throw new AuthError("NOT_SIGNED_IN", "The session ended while the link was in flight");
2773
+ return { status: "done", user, upgraded: false, alreadyLinked };
2774
+ } finally {
2775
+ this.attempt = null;
1767
2776
  }
1768
- return { authorizationUrl: parsed.data.authorizationUrl };
1769
2777
  }
2778
+ // ── Held-token refresh (REFRESH_OK/DEAD routed to a slot, rows 22 + 25) ──
1770
2779
  /**
1771
- * Consume an OIDC callback URL captured via React Native's
1772
- * `Linking.addEventListener('url', ...)`. Reads the fragment for
1773
- * `sendora_oidc_token`, exchanges it for a session, and persists.
1774
- * Throws AuthError on `sendora_oidc_error=...` fragment.
2780
+ * Present a held guest's refresh token to `/token/refresh` — the ONLY way a
2781
+ * held token is ever refreshed (the proactive cron never touches a slot).
2782
+ * The rotation is persisted INTO THE SLOT under a per-slot CAS before anything
2783
+ * else happens; a dead verdict releases the slot only for `endReason ∈
2784
+ * deleted|revoked|expired` (`reuse`/absent keep it: `kept`).
1775
2785
  */
1776
- consumeSsoFragment(callbackUrl) {
1777
- return this.serialize(async () => {
1778
- const hashIdx = callbackUrl.indexOf("#");
1779
- const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
1780
- const params = new URLSearchParams(fragment);
1781
- const err = params.get("sendora_oidc_error") ?? params.get("sendora_saml_error");
1782
- if (err) throw new AuthError("SSO_FAILED", decodeURIComponent(err));
1783
- const refresh = params.get("sendora_oidc_token") ?? params.get("sendora_saml_token");
1784
- if (!refresh) throw new AuthError("SSO_NO_TOKEN", "Callback URL has no SSO token");
1785
- const retiredAnonUserId = params.get("sendora_retired_anon");
1786
- const res = await post(
1787
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1788
- "/api/v1/auth-service/token/refresh",
1789
- { refreshToken: refresh }
1790
- );
1791
- if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
1792
- const parsed = await res.json();
1793
- const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
1794
- if (!rotated) {
1795
- throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
1796
- }
1797
- const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
1798
- const claims = decodeJwtPayload(access);
1799
- const user = {
1800
- id: claims?.sub ?? "",
1801
- email: claims?.email ?? null,
1802
- emailVerified: claims?.email_verified ?? true,
1803
- name: claims?.name ?? null,
1804
- isAnonymous: false
1805
- };
1806
- if (this.user !== null) await this.wipeLocalIdentity("replaced");
1807
- this.persist({
1808
- user,
1809
- tokens: {
1810
- accessToken: access,
1811
- refreshToken: newRefresh,
1812
- expiresIn,
1813
- tokenType: "Bearer"
1814
- },
1815
- ...retiredAnonUserId ? { retiredAnonUserId } : {}
1816
- });
1817
- return user;
1818
- });
1819
- }
1820
- bearerHeaders() {
1821
- if (!this.accessToken) {
1822
- throw new AuthError("NOT_SIGNED_IN", "Not signed in");
1823
- }
1824
- return { Authorization: `Bearer ${this.accessToken}` };
1825
- }
1826
- async unwrap(res, errCode) {
2786
+ async refreshHeld(i) {
2787
+ const slot = this.held[i];
2788
+ if (!slot) return { outcome: "dead" };
2789
+ const sent = slot.refreshToken;
2790
+ const res = await post(this.http(), REFRESH_PATH, { refreshToken: sent });
1827
2791
  if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
1828
- const parsed = await res.json().catch(() => ({}));
2792
+ let parsed;
2793
+ try {
2794
+ parsed = await res.json();
2795
+ } catch {
2796
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
2797
+ }
1829
2798
  if (!res.ok || !parsed.success) {
1830
- throw new AuthError(parsed.error?.code ?? errCode, parsed.error?.message ?? `HTTP ${res.status}`, {
1831
- status: res.status,
1832
- retryAfterSeconds: readRetryAfterSeconds(parsed)
1833
- });
2799
+ const code = parsed.error?.code ?? `HTTP_${res.status}`;
2800
+ if (isDeadRefreshError(code)) {
2801
+ const endReason = readEndReason(parsed);
2802
+ if (endReason !== void 0 && RELEASE_HELD_ON_END_REASONS.includes(endReason)) {
2803
+ this.commit({ kind: "release", reason: "dead", slot: i, userId: slot.userId, endReason });
2804
+ return { outcome: "dead" };
2805
+ }
2806
+ return { outcome: "kept" };
2807
+ }
2808
+ throw new AuthError(code, parsed.error?.message ?? `HTTP ${res.status}`, { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) });
1834
2809
  }
1835
- return parsed.data;
2810
+ const rotated = readRotatedTokens(parsed.data);
2811
+ if (!rotated) throw new AuthError("PARSE_ERROR", "refresh response carried no token pair", { status: res.status });
2812
+ const d = parsed.data;
2813
+ const user = isAuthUserShape(d?.user) ? d.user : { ...this.userFromClaims(rotated.accessToken), id: slot.userId, isAnonymous: true };
2814
+ const persisted = this.commit({ kind: "slot_tokens", slot: i, sent, refreshToken: rotated.refreshToken });
2815
+ if (persisted.status !== "applied") return { outcome: "kept" };
2816
+ return {
2817
+ outcome: "ok",
2818
+ data: { user, tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn, tokenType: "Bearer" } }
2819
+ };
1836
2820
  }
1837
- /**
1838
- * Sign out: wipe local identity FIRST so the user is logged out on
1839
- * device even if the revoke network call hangs (airplane mode, 5xx).
1840
- * Revoke is fire-and-forget after the wipe.
1841
- */
1842
- signOut() {
1843
- return this.serialize(async () => {
1844
- const refresh = this.hooks.storage.get(REFRESH_KEY);
1845
- await this.wipeLocalIdentity("user");
1846
- if (refresh) {
1847
- void post(
1848
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
1849
- "/api/v1/auth-service/token/revoke",
1850
- { refreshToken: refresh }
1851
- ).catch(() => void 0);
1852
- }
2821
+ /** The RESTORE row's install half: ProjectGuard on the refresh response, install BEFORE releasing the slot. */
2822
+ installRestored(i, data, expect) {
2823
+ const slot = this.held[i];
2824
+ const verdict = this.verdictFor(data.user, data.tokens.accessToken);
2825
+ if (verdict === "mismatch") {
2826
+ const actual = this.claimProject(data.tokens.accessToken) ?? data.user.project ?? null;
2827
+ this.commit({ kind: "slot_project", slot: i, project: actual });
2828
+ throw new HeldGuestProjectMismatch(slot.userId, this.hooks.expectedProjectId ?? this.hooks.projectId ?? "", actual);
2829
+ }
2830
+ const outcome = this.commit({
2831
+ kind: "install",
2832
+ reason: "restore",
2833
+ record: { user: data.user, accessToken: data.tokens.accessToken, refreshToken: data.tokens.refreshToken, expiresIn: data.tokens.expiresIn },
2834
+ expect,
2835
+ holdPrevious: true,
2836
+ response: data
1853
2837
  });
2838
+ if (outcome.status === "held_limit") throw new HeldGuestLimitError();
2839
+ if (outcome.status !== "applied") {
2840
+ throw new AuthError("ATTEMPT_SUPERSEDED", "The session changed while the restore was in flight; nothing was installed", { kind: "unknown" });
2841
+ }
2842
+ this.commit({ kind: "release", reason: "restored", slot: i, userId: slot.userId });
2843
+ return data.user;
1854
2844
  }
1855
- // --- internals ---
1856
- serialize(op) {
1857
- const guarded = async () => {
1858
- await this.readyPromise;
1859
- try {
1860
- return await op();
1861
- } catch (err) {
1862
- throw asAuthError(err);
1863
- }
1864
- };
1865
- const next = this.inflight.then(guarded, guarded);
1866
- this.inflight = next.catch(() => void 0);
1867
- return next;
1868
- }
2845
+ // ── Current-token refresh (rows 20–26) ───────────────────────────────────
1869
2846
  /**
1870
- * Hot-path token refresh. Single-flight via `refreshInflight` so a
1871
- * UI batch firing 10 simultaneous reads never produces 10 /refresh
1872
- * round-trips. Returns null on any failure so callers can decide
1873
- * whether to surface a sign-in prompt or proceed anonymously.
2847
+ * Hot-path token refresh. Single-flight via `refreshInflight` so a UI batch
2848
+ * firing 10 simultaneous reads never produces 10 `/refresh` round-trips.
2849
+ * Returns null on any failure so callers can decide whether to surface a
2850
+ * sign-in prompt or proceed anonymously.
1874
2851
  *
1875
- * s58.46 — on a `INVALID_REFRESH_TOKEN` (new backend error code) or
1876
- * HTTP 401 we WIPE the stored refresh token. Pre-s58.46 we caught
1877
- * the error and returned null but kept the stored token intact, so
1878
- * the very next caller would re-attempt the same dead token in an
1879
- * infinite loop (Pulse News iOS hammered /refresh ~1×/s for hours).
1880
- * The wipe forces the next op to fall through to /anonymous or
1881
- * surface NOT_SIGNED_IN to the host app instead of looping.
2852
+ * The result is applied ONLY if the token that was sent is still the one on
2853
+ * disk (token CAS, INV-2): a refresh landing after `signOut()` must not
2854
+ * resurrect the session, and an OLD token's rejection must not wipe a newer
2855
+ * one. A rotation is not a transition; the user object is adopted when the
2856
+ * SDK has none (gap-fill from `unknown`, 1.29.0) and re-judged by the project
2857
+ * guard before install (a mismatch → `withheld`, INV-15).
1882
2858
  */
1883
2859
  async refreshAccessToken() {
1884
2860
  if (this.refreshInflight) return this.refreshInflight;
2861
+ if (this.withheld) return null;
1885
2862
  const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
1886
2863
  if (!refreshSnapshot) return null;
1887
2864
  this.refreshInflight = (async () => {
@@ -1903,27 +2880,64 @@ var Auth = class {
1903
2880
  }
1904
2881
  }
1905
2882
  sent = current ?? refreshSnapshot;
1906
- const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
1907
- refreshToken: sent
1908
- });
1909
- if (!this.tokenStillCurrent(sent)) return null;
1910
- this.accessToken = data.tokens.accessToken;
1911
- this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
1912
- this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
1913
- this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
1914
- this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
1915
- if (this.user === null && isAuthApiResponse(data)) {
1916
- this.user = data.user;
1917
- this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
1918
- this.hooks.onIdentityChange(data.user.id);
1919
- this.emitAuthState({ event: "signed_in", user: data.user });
2883
+ const res = await post(this.http(), REFRESH_PATH, { refreshToken: sent });
2884
+ if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2885
+ let parsed;
2886
+ try {
2887
+ parsed = await res.json();
2888
+ } catch {
2889
+ throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
2890
+ }
2891
+ if (!res.ok || !parsed.success) {
2892
+ throw new AuthError(parsed.error?.code ?? `HTTP_${res.status}`, parsed.error?.message ?? `HTTP ${res.status}`, {
2893
+ status: res.status,
2894
+ retryAfterSeconds: readRetryAfterSeconds(parsed),
2895
+ endReason: readEndReason(parsed)
2896
+ });
2897
+ }
2898
+ const rotated = readRotatedTokens(parsed.data);
2899
+ if (!rotated) throw new AuthError("PARSE_ERROR", "refresh response carried no token pair", { status: res.status });
2900
+ const d = parsed.data;
2901
+ const responseUser = isAuthUserShape(d?.user) ? d.user : null;
2902
+ if (this.hooks.storage.get(REFRESH_KEY) === sent) {
2903
+ const user = responseUser ?? this.user;
2904
+ if (!user) {
2905
+ this.commit({ kind: "tokens", sent, accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn });
2906
+ } else {
2907
+ const verdict = this.verdictFor(user, rotated.accessToken);
2908
+ if (verdict === "mismatch") {
2909
+ this.commit({ kind: "withhold", user, sent, accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn });
2910
+ this.refreshFailureCount = 0;
2911
+ this.refreshCooldownUntil = 0;
2912
+ return null;
2913
+ }
2914
+ this.commit({
2915
+ kind: "install",
2916
+ reason: "refresh",
2917
+ record: { user, accessToken: rotated.accessToken, refreshToken: rotated.refreshToken, expiresIn: rotated.expiresIn },
2918
+ expectRefresh: sent
2919
+ });
2920
+ }
2921
+ } else {
2922
+ const i = this.held.findIndex((h) => h?.refreshToken === sent);
2923
+ if (i !== -1) this.commit({ kind: "slot_tokens", slot: i, sent, refreshToken: rotated.refreshToken });
2924
+ this.refreshFailureCount = 0;
2925
+ this.refreshCooldownUntil = 0;
2926
+ return null;
1920
2927
  }
1921
2928
  this.refreshFailureCount = 0;
1922
2929
  this.refreshCooldownUntil = 0;
1923
- return data.tokens.accessToken;
2930
+ return rotated.accessToken;
1924
2931
  } catch (err) {
1925
- if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
1926
- await this.wipeLocalIdentity("session_expired").catch(() => void 0);
2932
+ if (err instanceof AuthError && isDeadRefreshError(err.code)) {
2933
+ if (this.hooks.storage.get(REFRESH_KEY) === sent) {
2934
+ await this.commitAsync({ kind: "clear", reason: signedOutReasonFor(err.endReason), expectRefresh: sent });
2935
+ } else {
2936
+ const i = this.held.findIndex((h) => h?.refreshToken === sent);
2937
+ if (i !== -1 && err.endReason !== void 0 && RELEASE_HELD_ON_END_REASONS.includes(err.endReason)) {
2938
+ this.commit({ kind: "release", reason: "dead", slot: i, userId: this.held[i].userId, endReason: err.endReason });
2939
+ }
2940
+ }
1927
2941
  this.refreshFailureCount = 0;
1928
2942
  this.refreshCooldownUntil = 0;
1929
2943
  return null;
@@ -1938,16 +2952,7 @@ var Auth = class {
1938
2952
  })();
1939
2953
  return this.refreshInflight;
1940
2954
  }
1941
- /**
1942
- * Wave 62 — AppState resume hook. Clears stuck backoff so a user
1943
- * who backgrounded the app during a network blip + returns now
1944
- * gets an immediate fresh refresh attempt instead of waiting on
1945
- * the cooldown timer.
1946
- *
1947
- * Called from the AppState 'active' listener registered in
1948
- * startProactiveRefreshCron. Idempotent + cheap — three field
1949
- * writes; no I/O.
1950
- */
2955
+ /** Wave 62 — AppState resume hook: clear stuck backoff so a returning user gets an immediate attempt. */
1951
2956
  resetRefreshState() {
1952
2957
  this.refreshFailureCount = 0;
1953
2958
  this.refreshCooldownUntil = 0;
@@ -1958,9 +2963,11 @@ var Auth = class {
1958
2963
  const TICK_MS = 6e4;
1959
2964
  const JITTER_MS = 3e4;
1960
2965
  const tick = async () => {
1961
- if (!this.accessToken || !this.accessExpiresAt) return;
2966
+ if (this.attempt || this.withheld) return;
2967
+ if (!this.hooks.storage.get(REFRESH_KEY)) return;
1962
2968
  const now = Date.now();
1963
- if (!this.canServeCachedToken(now)) {
2969
+ if (!this.user || !this.accessToken || !this.accessExpiresAt || !this.canServeCachedToken(now)) {
2970
+ if (now < this.refreshCooldownUntil) return;
1964
2971
  await this.refreshAndReconcile(now);
1965
2972
  return;
1966
2973
  }
@@ -1998,82 +3005,34 @@ var Auth = class {
1998
3005
  this.proactiveRefreshAppStateUnsub = null;
1999
3006
  }
2000
3007
  }
2001
- /**
2002
- * Returns the currently-stored refresh token (or null when signed out).
2003
- * Primary use: capture the anonymous refresh token BEFORE calling
2004
- * `signIn()` so it can be transferred via `merge()` after the existing
2005
- * account is authenticated. See the "Anonymous → identified" section
2006
- * of the SDK README + /docs/quickstart-react-native#anon-existing-email.
2007
- */
2008
- getRefreshToken() {
2009
- return this.hooks.storage.get(REFRESH_KEY) ?? null;
2010
- }
2011
- /**
2012
- * Transfer every event + trait from an anonymous user to the
2013
- * currently-signed-in (target) user, then delete the anonymous row.
2014
- * `targetRefreshToken` defaults to the SDK's currently-stored refresh
2015
- * token — call this AFTER a successful `signIn()` so the storage
2016
- * already holds the target's refresh.
2017
- *
2018
- * Throws AuthError when:
2019
- * - `anonymousRefreshToken` is invalid / expired / revoked,
2020
- * - the bound source row is not anonymous (CONFLICT — use upgrade),
2021
- * - the target is anonymous (CONFLICT — sign in first),
2022
- * - both tokens resolve to the same user (CONFLICT — no-op).
2023
- *
2024
- * Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
2025
- */
2026
- async merge(anonymousRefreshToken, targetRefreshToken) {
2027
- const target = targetRefreshToken ?? this.getRefreshToken();
2028
- if (!target) {
2029
- throw new AuthError(
2030
- "NO_TARGET_REFRESH",
2031
- "merge() needs a target refresh token. Call signIn() first or pass targetRefreshToken explicitly."
2032
- );
2033
- }
2034
- const res = await post(
2035
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
2036
- "/api/v1/auth-service/merge",
2037
- { anonymousRefreshToken, targetRefreshToken: target }
2038
- );
2039
- if (!res) throw new AuthError("NETWORK_ERROR", "Network request failed");
2040
- let parsed;
2041
- try {
2042
- parsed = await res.json();
2043
- } catch {
2044
- throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
2045
- }
2046
- if (!res.ok || !parsed.success || !parsed.data) {
2047
- throw new AuthError(
2048
- parsed.error?.code ?? `HTTP_${res.status}`,
2049
- parsed.error?.message ?? `HTTP ${res.status}`,
2050
- { status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
2051
- );
3008
+ revokeFireAndForget(refreshToken) {
3009
+ void post(this.http(), REVOKE_PATH, { refreshToken }).catch(() => void 0);
3010
+ }
3011
+ /** B15: report a held guest, fire-and-forget; the pending flag is cleared only on a 2xx and retried on hydrate. */
3012
+ reportHeldFireAndForget(userId, refreshToken) {
3013
+ void post(this.http(), "/api/v1/auth-service/guest/held", { refreshToken }).then((res) => {
3014
+ if (res && res.ok) this.commit({ kind: "held_report", userId, pending: false });
3015
+ }).catch(() => void 0);
3016
+ }
3017
+ retryPendingHeldReports() {
3018
+ for (const userId of Array.from(this.heldReportsPending)) {
3019
+ const slot = this.held.find((h) => h?.userId === userId);
3020
+ if (!slot) {
3021
+ this.commit({ kind: "held_report", userId, pending: false });
3022
+ continue;
3023
+ }
3024
+ this.reportHeldFireAndForget(userId, slot.refreshToken);
2052
3025
  }
2053
- const t = parsed.data.tokens;
2054
- if (t?.accessToken && t.refreshToken && typeof t.expiresIn === "number") {
2055
- this.accessToken = t.accessToken;
2056
- this.accessExpiresAt = Date.now() + t.expiresIn * 1e3;
2057
- this.hooks.storage.set(TOKEN_KEY, t.accessToken);
2058
- this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
2059
- this.hooks.storage.set(REFRESH_KEY, t.refreshToken);
3026
+ }
3027
+ /** `Sendora.reset({includeHeldGuests:true})`: release every slot locally (NO server delete), via commit. */
3028
+ releaseAllHeldGuests() {
3029
+ for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
3030
+ const slot = this.held[i];
3031
+ if (slot) this.commit({ kind: "release", reason: "reset", slot: i, userId: slot.userId });
2060
3032
  }
2061
- return {
2062
- mergedUserId: parsed.data.mergedUserId ?? "",
2063
- deletedAnonymousId: parsed.data.deletedAnonymousId ?? "",
2064
- eventsReassigned: parsed.data.eventsReassigned ?? 0,
2065
- profilesReassigned: parsed.data.profilesReassigned ?? 0,
2066
- profilesDropped: parsed.data.profilesDropped ?? 0,
2067
- entitiesReassigned: parsed.data.entitiesReassigned ?? 0,
2068
- entitiesDropped: parsed.data.entitiesDropped ?? 0
2069
- };
2070
3033
  }
2071
3034
  async callAuth(path, body) {
2072
- const res = await post(
2073
- { apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
2074
- path,
2075
- body
2076
- );
3035
+ const res = await post(this.http(), path, body);
2077
3036
  if (!res) {
2078
3037
  throw new AuthError("NETWORK_ERROR", "Network request failed");
2079
3038
  }
@@ -2092,57 +3051,143 @@ var Auth = class {
2092
3051
  }
2093
3052
  return parsed.data;
2094
3053
  }
2095
- persist(data) {
2096
- this.user = data.user;
2097
- this.accessToken = data.tokens.accessToken;
2098
- this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
2099
- this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
2100
- this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
2101
- this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
2102
- this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
2103
- this.hooks.onIdentityChange(data.user.id);
3054
+ // ============================================================
3055
+ // commit() — THE ONLY WRITER (ADR-033 §3.3, INV-1)
3056
+ // ============================================================
3057
+ //
3058
+ // Every assignment of `this.user` / `this.accessToken` / `this.accessExpiresAt`
3059
+ // / `this.held` / `this.withheld`, every write of an auth storage key, every
3060
+ // cron start/stop and every `onAuthStateChanged` emission happens inside this
3061
+ // method or the private helpers only it calls. Public methods build a `Tx`
3062
+ // and hand it over; the CAS and the ProjectGuard are applied HERE, not at
3063
+ // the call sites, so no call site can forget them.
3064
+ commit(tx) {
3065
+ switch (tx.kind) {
3066
+ case "install":
3067
+ return this.commitInstall(tx);
3068
+ case "clear":
3069
+ return this.commitClear(tx);
3070
+ case "user":
3071
+ return this.commitUser(tx);
3072
+ case "release":
3073
+ return this.commitRelease(tx);
3074
+ case "withhold":
3075
+ return this.commitWithhold(tx);
3076
+ case "discard_withheld":
3077
+ return this.commitDiscardWithheld();
3078
+ case "tokens":
3079
+ return this.commitTokens(tx);
3080
+ case "slot_tokens":
3081
+ return this.commitSlotTokens(tx);
3082
+ case "slot_project":
3083
+ return this.commitSlotProject(tx);
3084
+ case "held_report":
3085
+ return this.commitHeldReport(tx);
3086
+ default:
3087
+ return { status: "dropped" };
3088
+ }
3089
+ }
3090
+ /** `commit()` for the clear path, awaiting the parent's `onAnonymousWipe` hook before resolving (the emission has already fired). */
3091
+ async commitAsync(tx) {
3092
+ const result = this.commit(tx);
3093
+ if (this.pendingWipeHook) {
3094
+ const p = this.pendingWipeHook;
3095
+ this.pendingWipeHook = null;
3096
+ await p.catch(() => void 0);
3097
+ }
3098
+ return result;
3099
+ }
3100
+ commitInstall(tx) {
3101
+ const { record } = tx;
3102
+ if (tx.expect && !this.identityMatches(tx.expect)) return { status: "cas_mismatch" };
3103
+ if (tx.expectRefresh !== void 0 && this.hooks.storage.get(REFRESH_KEY) !== tx.expectRefresh) return { status: "cas_mismatch" };
3104
+ if (tx.reason !== "hydrate") {
3105
+ const verdict = this.verdictFor(record.user, record.accessToken);
3106
+ if (verdict === "mismatch") return { status: "project_mismatch" };
3107
+ }
3108
+ const prevUser = this.user;
3109
+ const prevRefresh = this.hooks.storage.get(REFRESH_KEY);
3110
+ let held = false;
3111
+ if (tx.reason === "hydrate") {
3112
+ this.user = record.user;
3113
+ this.accessToken = record.accessToken || null;
3114
+ this.accessExpiresAt = this.accessToken ? tx.accessExpiresAt ?? 0 : 0;
3115
+ if (tx.writeForward) this.writeSessionRecordOnly(record.user, this.accessToken, this.accessExpiresAt, record.refreshToken);
3116
+ this.hooks.onIdentityChange(record.user.id);
3117
+ this.startProactiveRefreshCron();
3118
+ return { status: "applied", held: false };
3119
+ }
3120
+ if (tx.holdPrevious && prevUser && prevRefresh && prevUser.isAnonymous && prevUser.id !== record.user.id) {
3121
+ const free = this.freeSlotIndex();
3122
+ if (free === -1) return { status: "held_limit" };
3123
+ const slot = {
3124
+ v: 1,
3125
+ userId: prevUser.id,
3126
+ sid: this.snapshotIdentity().sid ?? "",
3127
+ refreshToken: prevRefresh,
3128
+ tokenAtHold: prevRefresh,
3129
+ // Strictly newer than every held slot: two holds in the same millisecond would otherwise tie,
3130
+ // leaving "newest" (getHeldGuests() order, the auto-restore pick) undefined. Web found it as a
3131
+ // 1-in-4 flake; same fix in all four SDKs (s58.356).
3132
+ heldAt: Math.max(Date.now(), ...this.held.filter((h) => h !== null).map((h) => h.heldAt + 1)),
3133
+ project: this.claimProject(this.accessToken) ?? (typeof prevUser.project === "string" ? prevUser.project : null),
3134
+ autoRestore: true
3135
+ };
3136
+ this.writeSlot(free, slot);
3137
+ this.heldReportsPending.add(prevUser.id);
3138
+ held = true;
3139
+ this.emitAuthState({ event: "guest_held", userId: prevUser.id, count: this.heldCount(), user: prevUser });
3140
+ } else if (prevUser && prevRefresh && !prevUser.isAnonymous && prevUser.id !== record.user.id && (tx.reason === "sign_in" || tx.reason === "mint" || tx.reason === "restore")) {
3141
+ this.revokeFireAndForget(prevRefresh);
3142
+ }
3143
+ const userChanged = !prevUser || JSON.stringify(prevUser) !== JSON.stringify(record.user);
3144
+ this.user = record.user;
3145
+ this.accessToken = record.accessToken;
3146
+ this.accessExpiresAt = Date.now() + record.expiresIn * 1e3;
3147
+ this.writeSessionRecord(record.user, record.accessToken, this.accessExpiresAt, record.refreshToken);
3148
+ this.hooks.onIdentityChange(record.user.id);
2104
3149
  this.startProactiveRefreshCron();
2105
- this.emitAuthState({ event: "signed_in", user: data.user });
2106
- if (data.retiredAnonUserId) {
2107
- this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
2108
- this.emitAuthState({
2109
- event: "device_takeover",
2110
- user: data.user,
2111
- retiredAnonUserId: data.retiredAnonUserId
2112
- });
3150
+ switch (tx.reason) {
3151
+ case "mint":
3152
+ this.emitAuthState({ event: "signed_in", user: record.user, cause: "mint" });
3153
+ break;
3154
+ case "sign_in":
3155
+ this.emitAuthState({ event: "signed_in", user: record.user, cause: "sign_in" });
3156
+ break;
3157
+ case "restore":
3158
+ this.emitAuthState({ event: "signed_in", user: record.user, cause: "restore" });
3159
+ break;
3160
+ case "upgrade":
3161
+ if (tx.legacyEmit) this.emitAuthState({ event: "signed_in", user: record.user, cause: "link" });
3162
+ else this.emitAuthState({ event: "user_updated", user: record.user, cause: "link" });
3163
+ break;
3164
+ case "claim":
3165
+ if (!tx.silent) this.emitAuthState({ event: "user_updated", user: record.user, cause: "merge" });
3166
+ break;
3167
+ case "refresh":
3168
+ case "recover":
3169
+ if (!prevUser) this.emitAuthState({ event: "signed_in", user: record.user });
3170
+ else if (userChanged) this.emitAuthState({ event: "user_updated", user: record.user, cause: "refresh" });
3171
+ break;
3172
+ default:
3173
+ break;
2113
3174
  }
2114
- if (data.anonRetirement) this.lastAnonRetirement = data.anonRetirement;
2115
- if (data.reactivatedFromDeletion) {
2116
- this.fireDeletionCancelled(data.user.id);
2117
- this.emitAuthState({ event: "deletion_cancelled", user: data.user });
3175
+ const data = tx.response;
3176
+ if (data?.retiredAnonUserId) {
3177
+ this.fireDeviceTakeover(data.retiredAnonUserId, record.user.id);
3178
+ this.emitAuthState({ event: "device_takeover", user: record.user, retiredAnonUserId: data.retiredAnonUserId });
2118
3179
  }
3180
+ if (data?.anonRetirement) this.lastAnonRetirement = data.anonRetirement;
3181
+ if (data?.reactivatedFromDeletion) {
3182
+ this.fireDeletionCancelled(record.user.id);
3183
+ this.emitAuthState({ event: "deletion_cancelled", user: record.user });
3184
+ }
3185
+ if (held && prevUser) this.reportHeldFireAndForget(prevUser.id, prevRefresh);
3186
+ return { status: "applied", held };
2119
3187
  }
2120
- /**
2121
- * Drop the local session.
2122
- *
2123
- * `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
2124
- * emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
2125
- * on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
2126
- * not a spurious logout/login pair.
2127
- *
2128
- * ⚠ Never call this before a fallible network request. A failed sign-in must
2129
- * leave the caller exactly as it found them; for an anonymous session the
2130
- * refresh token dropped here is the ONLY durable handle on the account, so a
2131
- * pre-call wipe that is not followed by a successful `persist()` orphans the
2132
- * account permanently (offline: deterministically). Capture the anon refresh
2133
- * into a local, make the call, and wipe only after the response validates.
2134
- */
2135
- /**
2136
- * True when `sent` is still the refresh token on disk — i.e. nothing
2137
- * replaced or cleared the session while a request carrying it was in
2138
- * flight. Guards both directions of the refresh race: installing a rotated
2139
- * token over a session that has since been signed out or replaced, and
2140
- * wiping a fresh session because an OLD token was rejected.
2141
- */
2142
- tokenStillCurrent(sent) {
2143
- return this.hooks.storage.get(REFRESH_KEY) === sent;
2144
- }
2145
- async wipeLocalIdentity(reason = "replaced") {
3188
+ commitClear(tx) {
3189
+ if (tx.expectRefresh !== void 0 && this.hooks.storage.get(REFRESH_KEY) !== tx.expectRefresh) return { status: "dropped" };
3190
+ const prevRefresh = this.hooks.storage.get(REFRESH_KEY);
2146
3191
  this.user = null;
2147
3192
  this.accessToken = null;
2148
3193
  this.accessExpiresAt = 0;
@@ -2150,21 +3195,216 @@ var Auth = class {
2150
3195
  this.lastTakeover = null;
2151
3196
  this.lastDeletionCancelled = null;
2152
3197
  this.lastAnonRetirement = null;
2153
- this.pendingAnonTakeover = null;
2154
- this.hooks.storage.remove(USER_KEY);
2155
- this.hooks.storage.remove(TOKEN_KEY);
2156
- this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
2157
- this.hooks.storage.remove(REFRESH_KEY);
3198
+ this.clearSessionKeys();
3199
+ this.stopProactiveRefreshCron();
3200
+ if (tx.suppressHeldAutoRestore) {
3201
+ for (let i = 0; i < HELD_SLOT_KEYS.length; i++) {
3202
+ const slot = this.held[i];
3203
+ if (slot && slot.autoRestore) this.writeSlot(i, { ...slot, autoRestore: false });
3204
+ }
3205
+ }
3206
+ const reason = tx.reason;
3207
+ if (reason === "stale_blob" || reason === "replaced") return { status: "applied", held: false };
3208
+ if (reason === "user" && prevRefresh) this.revokeFireAndForget(prevRefresh);
3209
+ this.pendingWipeHook = this.wipeLocalIdentity(reason);
3210
+ this.emitAuthState({ event: "signed_out", user: null, reason });
3211
+ return { status: "applied", held: false };
3212
+ }
3213
+ /**
3214
+ * The parent-SDK half of a clear: rotate the analytics anon id at a person
3215
+ * boundary (`"user"` / `"account_deleted"`), drop the queue on deletion, null
3216
+ * the analytics user id. Kept as its own method so its reasons stay pinned.
3217
+ */
3218
+ wipeLocalIdentity(reason) {
3219
+ return this.hooks.onAnonymousWipe(reason);
3220
+ }
3221
+ commitUser(tx) {
3222
+ if (tx.expect && !this.identityMatches(tx.expect)) return { status: "cas_mismatch" };
3223
+ const refresh = this.hooks.storage.get(REFRESH_KEY);
3224
+ if (!refresh) return { status: "dropped" };
3225
+ this.user = tx.user;
3226
+ this.writeSessionRecord(tx.user, this.accessToken, this.accessExpiresAt, refresh);
3227
+ this.hooks.onIdentityChange(tx.user.id);
3228
+ this.emitAuthState({ event: "user_updated", user: tx.user, cause: tx.reason });
3229
+ return { status: "applied", held: false };
3230
+ }
3231
+ commitRelease(tx) {
3232
+ const slot = this.held[tx.slot];
3233
+ if (!slot) return { status: "dropped" };
3234
+ this.writeSlot(tx.slot, null);
3235
+ if (this.heldReportsPending.delete(tx.userId)) this.persistPendingReports();
3236
+ const change = tx.endReason ? { event: "guest_released", userId: tx.userId, reason: tx.reason, endReason: tx.endReason } : { event: "guest_released", userId: tx.userId, reason: tx.reason };
3237
+ this.emitAuthState(change);
3238
+ return { status: "applied", held: false };
3239
+ }
3240
+ /** Row 21 — a refresh of the CURRENT token answered with a mismatching claim: persist the rotation (token CAS) and withhold. */
3241
+ commitWithhold(tx) {
3242
+ if (this.hooks.storage.get(REFRESH_KEY) !== tx.sent) return { status: "dropped" };
3243
+ const expiresAt = Date.now() + tx.expiresIn * 1e3;
3244
+ this.writeTokens(tx.accessToken, expiresAt, tx.refreshToken);
3245
+ this.enterWithheld(tx.user, tx.accessToken, tx.refreshToken, true);
3246
+ return { status: "withheld" };
3247
+ }
3248
+ /** Enter `withheld`: in-memory only (ZERO storage writes), no cron, analytics quarantined; emits `session_withheld` only for an in-process transition. */
3249
+ enterWithheld(user, accessToken, refreshToken, emit = false) {
3250
+ const expected = this.hooks.expectedProjectId ?? "";
3251
+ const actual = this.claimProject(accessToken) ?? (user.project === void 0 ? null : user.project);
3252
+ this.user = null;
3253
+ this.accessToken = null;
3254
+ this.accessExpiresAt = 0;
3255
+ this.withheld = { reason: "project_mismatch", expectedProjectId: expected, actualProjectId: actual, userId: user.id, isAnonymous: user.isAnonymous === true };
3256
+ this.withheldRefresh = refreshToken;
2158
3257
  this.stopProactiveRefreshCron();
2159
- await this.hooks.onAnonymousWipe(reason);
2160
- if (reason !== "replaced") {
2161
- this.emitAuthState({ event: "signed_out", user: null, reason });
3258
+ try {
3259
+ this.hooks.onIdentityWithheld?.(this.withheld);
3260
+ } catch (err) {
3261
+ if (this.hooks.debug) console.warn("[Sendora] onIdentityWithheld hook threw", err);
3262
+ }
3263
+ if (emit) {
3264
+ this.emitAuthState({ event: "session_withheld", reason: "project_mismatch", expectedProjectId: expected, actualProjectId: actual, userId: user.id });
3265
+ }
3266
+ }
3267
+ /** Row 14 — `discardWithheld()` / `signOut()` in `withheld`: LOCAL ONLY, keeps held slots + queue + `user_id`. */
3268
+ commitDiscardWithheld() {
3269
+ if (!this.withheld) return { status: "dropped" };
3270
+ const onDisk = this.hooks.storage.get(REFRESH_KEY);
3271
+ if (this.withheldRefresh !== null && onDisk !== null && onDisk !== this.withheldRefresh) return { status: "dropped" };
3272
+ const refresh = onDisk ?? this.withheldRefresh;
3273
+ this.clearSessionKeys();
3274
+ this.withheld = null;
3275
+ this.withheldRefresh = null;
3276
+ if (refresh) this.revokeFireAndForget(refresh);
3277
+ this.emitAuthState({ event: "signed_out", user: null, reason: "user" });
3278
+ return { status: "applied", held: false };
3279
+ }
3280
+ /** A rotated pair for the current session when no user is known to install (pre-B17 gap-fill). Token CAS. */
3281
+ commitTokens(tx) {
3282
+ if (this.hooks.storage.get(REFRESH_KEY) !== tx.sent) return { status: "dropped" };
3283
+ const expiresAt = Date.now() + tx.expiresIn * 1e3;
3284
+ this.writeTokens(tx.accessToken, expiresAt, tx.refreshToken);
3285
+ this.accessToken = tx.accessToken;
3286
+ this.accessExpiresAt = expiresAt;
3287
+ return { status: "applied", held: false };
3288
+ }
3289
+ /** Row 22 — CAS PER SLOT: re-read the slot at write time; if it no longer holds `sent`, DROP. */
3290
+ commitSlotTokens(tx) {
3291
+ const onDisk = this.readSlot(tx.slot);
3292
+ if (!onDisk || onDisk.refreshToken !== tx.sent) return { status: "dropped" };
3293
+ this.writeSlot(tx.slot, { ...onDisk, refreshToken: tx.refreshToken });
3294
+ return { status: "applied", held: false };
3295
+ }
3296
+ /** RESTORE row: a `null` slot project judged on the refresh response records the claim it saw. */
3297
+ commitSlotProject(tx) {
3298
+ const onDisk = this.readSlot(tx.slot);
3299
+ if (!onDisk) return { status: "dropped" };
3300
+ this.writeSlot(tx.slot, { ...onDisk, project: tx.project });
3301
+ return { status: "applied", held: false };
3302
+ }
3303
+ commitHeldReport(tx) {
3304
+ const before = this.heldReportsPending.size;
3305
+ if (tx.pending) this.heldReportsPending.add(tx.userId);
3306
+ else this.heldReportsPending.delete(tx.userId);
3307
+ if (this.heldReportsPending.size !== before) this.persistPendingReports();
3308
+ return { status: "applied", held: false };
3309
+ }
3310
+ // ── Storage write helpers (called ONLY from the commit handlers above) ──
3311
+ /** Dual-write: the four frozen keys (downgrade-readable) + the `auth_session` record. */
3312
+ writeSessionRecord(user, accessToken, accessExpiresAt, refreshToken) {
3313
+ const st = this.hooks.storage;
3314
+ st.set(USER_KEY, JSON.stringify(user));
3315
+ if (accessToken) {
3316
+ st.set(TOKEN_KEY, accessToken);
3317
+ st.set(TOKEN_EXPIRES_KEY, String(accessExpiresAt));
3318
+ } else {
3319
+ st.remove(TOKEN_KEY);
3320
+ st.remove(TOKEN_EXPIRES_KEY);
3321
+ }
3322
+ st.set(REFRESH_KEY, refreshToken);
3323
+ const record = { v: 2, user, accessToken, accessExpiresAt, refreshToken };
3324
+ if (this.heldReportsPending.size > 0) record.heldReportsPending = Array.from(this.heldReportsPending);
3325
+ st.set(SESSION_KEY, JSON.stringify(record));
3326
+ }
3327
+ /** Hydrate's write-forward: ONLY the `auth_session` record (the frozen keys already hold the session untouched). */
3328
+ writeSessionRecordOnly(user, accessToken, accessExpiresAt, refreshToken) {
3329
+ const record = { v: 2, user, accessToken, accessExpiresAt, refreshToken };
3330
+ if (this.heldReportsPending.size > 0) record.heldReportsPending = Array.from(this.heldReportsPending);
3331
+ this.hooks.storage.set(SESSION_KEY, JSON.stringify(record));
3332
+ }
3333
+ /** The rotated pair only (the user is unchanged) — frozen keys + the record's token fields. */
3334
+ writeTokens(accessToken, accessExpiresAt, refreshToken) {
3335
+ const st = this.hooks.storage;
3336
+ st.set(TOKEN_KEY, accessToken);
3337
+ st.set(TOKEN_EXPIRES_KEY, String(accessExpiresAt));
3338
+ st.set(REFRESH_KEY, refreshToken);
3339
+ let record = { v: 2 };
3340
+ const raw = st.get(SESSION_KEY);
3341
+ if (raw) {
3342
+ try {
3343
+ const parsed = JSON.parse(raw);
3344
+ if (parsed && typeof parsed === "object" && parsed.v === 2) record = parsed;
3345
+ } catch {
3346
+ }
3347
+ }
3348
+ if (!record.user) {
3349
+ const legacy = st.get(USER_KEY);
3350
+ if (legacy) {
3351
+ try {
3352
+ const u = JSON.parse(legacy);
3353
+ if (isAuthUserShape(u)) record.user = u;
3354
+ } catch {
3355
+ }
3356
+ }
3357
+ }
3358
+ record.accessToken = accessToken;
3359
+ record.accessExpiresAt = accessExpiresAt;
3360
+ record.refreshToken = refreshToken;
3361
+ if (this.heldReportsPending.size > 0) record.heldReportsPending = Array.from(this.heldReportsPending);
3362
+ st.set(SESSION_KEY, JSON.stringify(record));
3363
+ }
3364
+ /** Clear EXACTLY the session keys (the four frozen + the record). Held slots, the queue and `user_id` are never touched here. */
3365
+ clearSessionKeys() {
3366
+ const st = this.hooks.storage;
3367
+ st.remove(USER_KEY);
3368
+ st.remove(TOKEN_KEY);
3369
+ st.remove(TOKEN_EXPIRES_KEY);
3370
+ st.remove(REFRESH_KEY);
3371
+ this.persistPendingReports();
3372
+ }
3373
+ /** Keep the B15 pending-report flags on disk even with no session (a stub record); remove the record when nothing is pending. */
3374
+ persistPendingReports() {
3375
+ const st = this.hooks.storage;
3376
+ if (this.hooks.storage.get(REFRESH_KEY) && this.user) {
3377
+ this.writeSessionRecord(this.user, this.accessToken, this.accessExpiresAt, this.hooks.storage.get(REFRESH_KEY));
3378
+ return;
3379
+ }
3380
+ if (this.heldReportsPending.size > 0) {
3381
+ st.set(SESSION_KEY, JSON.stringify({ v: 2, heldReportsPending: Array.from(this.heldReportsPending) }));
3382
+ } else {
3383
+ st.remove(SESSION_KEY);
2162
3384
  }
2163
3385
  }
3386
+ writeSlot(i, slot) {
3387
+ const key = HELD_SLOT_KEYS[i];
3388
+ this.held[i] = slot;
3389
+ if (slot) this.hooks.storage.set(key, JSON.stringify(slot));
3390
+ else this.hooks.storage.remove(key);
3391
+ }
2164
3392
  };
3393
+ function signedOutReasonFor(endReason) {
3394
+ switch (endReason) {
3395
+ case "revoked":
3396
+ return "revoked";
3397
+ case "deleted":
3398
+ return "deleted";
3399
+ case "evicted":
3400
+ return "evicted";
3401
+ default:
3402
+ return "session_expired";
3403
+ }
3404
+ }
2165
3405
  function isDeadRefreshError(code) {
2166
3406
  if (!code) return false;
2167
- return code === "INVALID_REFRESH_TOKEN";
3407
+ return DEAD_CODES.includes(code);
2168
3408
  }
2169
3409
  function readRotatedTokens(data) {
2170
3410
  if (!data || typeof data !== "object") return null;
@@ -2182,19 +3422,6 @@ function accessTokenExpIsFuture(token, nowMs) {
2182
3422
  if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
2183
3423
  return nowMs < exp * 1e3;
2184
3424
  }
2185
- function decodeJwtPayload(token) {
2186
- try {
2187
- const parts = token.split(".");
2188
- if (parts.length !== 3) return null;
2189
- const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
2190
- const pad = (4 - padded.length % 4) % 4;
2191
- if (typeof atob !== "function") return null;
2192
- const json = atob(padded + "===".slice(0, pad));
2193
- return JSON.parse(json);
2194
- } catch {
2195
- return null;
2196
- }
2197
- }
2198
3425
 
2199
3426
  // src/links.ts
2200
3427
  var import_react_native3 = require("react-native");
@@ -2924,11 +4151,15 @@ var EventQueue = class {
2924
4151
  }, ms);
2925
4152
  }
2926
4153
  /**
2927
- * Drain the queue FIFO. Removes an event only when the server ACCEPTED it (2xx)
2928
- * or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it — and stops
2929
- * draining — on a transient failure (timeout throw, network null, 408/429/5xx),
2930
- * then arms a capped-exponential-backoff retry. The entire post lives inside the
2931
- * try/catch, so `NetworkTimeoutError` can never surface as an unhandled rejection.
4154
+ * Drain the queue in order. Removes an event only when the server ACCEPTED it
4155
+ * (2xx) or PERMANENTLY refused it (a 4xx that a retry can't fix). Keeps it —
4156
+ * and stops draining — on a transient failure (timeout throw, network null,
4157
+ * 408/429/5xx), then arms a capped-exponential-backoff retry. ⚠ Since 1.39.1 a
4158
+ * transiently-failing HEAD is first rotated to the BACK, so one poison event
4159
+ * cannot hold everything behind it; delivery order is therefore best-effort,
4160
+ * not strict FIFO (identity travels in each event body, not by slot). The
4161
+ * entire post lives inside the try/catch, so `NetworkTimeoutError` can never
4162
+ * surface as an unhandled rejection.
2932
4163
  */
2933
4164
  async flush() {
2934
4165
  if (this.flushing || this.q.length === 0) return;
@@ -2990,14 +4221,20 @@ var ANON_KEY = "anon_id";
2990
4221
  var USER_ID_KEY = "user_id";
2991
4222
  var EVENT_QUEUE_KEY = "event_queue";
2992
4223
  var SCHEMA_VERSION_KEY = "schema_version";
2993
- var SCHEMA_VERSION = "1";
2994
- var AUTH_HYDRATE_KEYS = [
2995
- "auth_access_token",
2996
- "auth_access_expires",
2997
- "auth_refresh_token",
2998
- "auth_user"
2999
- ];
3000
- var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
4224
+ var SCHEMA_VERSION = "2";
4225
+ var SECURE_KEYS = AUTH_SECURE_KEYS;
4226
+ var ALL_SDK_KEYS = [ANON_KEY, USER_ID_KEY, SCHEMA_VERSION_KEY, EVENT_QUEUE_KEY, ...AUTH_HYDRATE_KEYS];
4227
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4228
+ function normalizeExpectedProjectId(expected, projectId) {
4229
+ if (expected === void 0) return void 0;
4230
+ if (typeof expected !== "string" || !UUID_RE.test(expected)) {
4231
+ throw new Error(`[sendora] init: expectedProjectId must be a UUID (got ${JSON.stringify(expected)}).`);
4232
+ }
4233
+ if (projectId !== void 0 && projectId !== expected) {
4234
+ throw new Error(`[sendora] init: expectedProjectId (${expected}) must equal projectId (${projectId}) when both are set.`);
4235
+ }
4236
+ return expected;
4237
+ }
3001
4238
  var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
3002
4239
  var STORAGE_PREFIX_RE = /^[A-Za-z0-9_.-]{1,64}$/;
3003
4240
  function normalizeStoragePrefix(prefix) {
@@ -3136,6 +4373,8 @@ var SendoraSDK = class {
3136
4373
  this.sessionId = "";
3137
4374
  this.autoTrackCleanup = null;
3138
4375
  this.eventQueue = null;
4376
+ /** ADR-033 [2c]: subscribers to `onIdentityWithheld`. */
4377
+ this.withheldListeners = /* @__PURE__ */ new Set();
3139
4378
  // --- Engagement time (foreground-only, Wave 75) ---
3140
4379
  this.engEnabled = false;
3141
4380
  this.engScreen = null;
@@ -3183,6 +4422,7 @@ var SendoraSDK = class {
3183
4422
  `[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
3184
4423
  );
3185
4424
  }
4425
+ const expectedProjectId = normalizeExpectedProjectId(config.expectedProjectId, config.projectId);
3186
4426
  if (config.secureStorage || config.storagePrefix !== void 0) {
3187
4427
  const prefix = normalizeStoragePrefix(config.storagePrefix);
3188
4428
  this.storage = new Storage({
@@ -3191,16 +4431,7 @@ var SendoraSDK = class {
3191
4431
  prefix
3192
4432
  });
3193
4433
  }
3194
- await this.storage.hydrate([
3195
- ANON_KEY,
3196
- USER_ID_KEY,
3197
- SCHEMA_VERSION_KEY,
3198
- EVENT_QUEUE_KEY,
3199
- ...AUTH_HYDRATE_KEYS
3200
- ]);
3201
- if (!this.storage.get(SCHEMA_VERSION_KEY)) {
3202
- this.storage.set(SCHEMA_VERSION_KEY, SCHEMA_VERSION);
3203
- }
4434
+ await this.storage.hydrate([...ALL_SDK_KEYS]);
3204
4435
  let anon = this.storage.get(ANON_KEY);
3205
4436
  if (!anon) {
3206
4437
  anon = uuid();
@@ -3226,6 +4457,22 @@ var SendoraSDK = class {
3226
4457
  apiUrl: this.config.apiUrl,
3227
4458
  publicKey: this.config.publicKey,
3228
4459
  debug: this.debug,
4460
+ expectedProjectId,
4461
+ projectId: this.config.projectId,
4462
+ autoRestoreHeldGuest: config.autoRestoreHeldGuest !== false,
4463
+ // ADR-033 [2c]: the stored session was refused by the project guard —
4464
+ // quarantine analytics by nulling the IN-MEMORY user id only. The
4465
+ // persisted `user_id` is untouched (a withheld hydrate writes nothing).
4466
+ onIdentityWithheld: (info) => {
4467
+ this.userId = null;
4468
+ for (const cb of this.withheldListeners) {
4469
+ try {
4470
+ cb(info);
4471
+ } catch (err) {
4472
+ if (this.debug) console.warn("[sendoracloud] onIdentityWithheld listener threw", err);
4473
+ }
4474
+ }
4475
+ },
3229
4476
  onIdentityChange: (userId) => {
3230
4477
  this.userId = userId;
3231
4478
  if (userId) this.storage.set(USER_ID_KEY, userId);
@@ -3242,6 +4489,9 @@ var SendoraSDK = class {
3242
4489
  }
3243
4490
  });
3244
4491
  this.auth.hydrate();
4492
+ if (this.auth.status() !== "withheld" && this.storage.get(SCHEMA_VERSION_KEY) !== SCHEMA_VERSION) {
4493
+ this.storage.set(SCHEMA_VERSION_KEY, SCHEMA_VERSION);
4494
+ }
3245
4495
  this.links = new Links({
3246
4496
  apiUrl: this.config.apiUrl,
3247
4497
  publicKey: this.config.publicKey,
@@ -3982,23 +5232,44 @@ var SendoraSDK = class {
3982
5232
  if (!this._chatbotSessionId) this._chatbotSessionId = `chat_${uuid().replace(/-/g, "").slice(0, 24)}`;
3983
5233
  return this._chatbotSessionId;
3984
5234
  }
5235
+ /**
5236
+ * ADR-033 [2c]: subscribe to the project guard refusing a stored session
5237
+ * (`auth.status() === "withheld"`). Fires once per withhold with
5238
+ * `getWithheld()`'s payload, after the SDK has nulled its in-memory
5239
+ * analytics user id. Record `info.userId` (oldSub) in your backend before
5240
+ * calling `auth.discardWithheld()`. Returns an unsubscribe function.
5241
+ */
5242
+ onIdentityWithheld(cb) {
5243
+ this.withheldListeners.add(cb);
5244
+ return () => {
5245
+ this.withheldListeners.delete(cb);
5246
+ };
5247
+ }
3985
5248
  /**
3986
5249
  * Clear identity + rotate the anonymous id. Call on logout. Awaits
3987
- * the AsyncStorage writes so a caller who kills the app immediately
3988
- * after doesn't leave stale identity on disk.
5250
+ * the storage writes so a caller who kills the app immediately after
5251
+ * doesn't leave stale identity on disk.
5252
+ *
5253
+ * Signs out through `auth` (so the server session is revoked and
5254
+ * `onAuthStateChanged` fires `signed_out`), drops the queued events, then
5255
+ * clears EXACTLY this SDK's keys in the active namespace.
3989
5256
  *
3990
- * Signs out through `auth` rather than just clearing storage: `clearAll()`
3991
- * removes the auth keys too, so doing it directly left `Auth` holding an
3992
- * in-memory user and reporting itself signed in while its refresh token was
3993
- * already gone — with the server-side session never revoked and no
3994
- * `onAuthStateChanged` emission telling the app the session had ended.
5257
+ * ⚠ KEEPS the held guests by default (ADR-033). A guest displaced by a
5258
+ * sign-in is held on the device (`auth.getHeldGuests()`), and its refresh
5259
+ * token is its only credential — wiping the slots on logout would strand a
5260
+ * guest whose progress the player may still want. Pass
5261
+ * `{ includeHeldGuests: true }` to release every slot too (locally only —
5262
+ * `guest_released{reason:'reset'}` per guest, NO server delete; use
5263
+ * `auth.discardHeldGuest(id)` for that). In `withheld` this resolves the
5264
+ * withheld session like `auth.discardWithheld()` and keeps the slots too.
3995
5265
  */
3996
- async reset() {
5266
+ async reset(opts) {
3997
5267
  await this.auth.signOut().catch(() => void 0);
3998
5268
  this.userId = null;
3999
5269
  this.sessionId = "";
4000
5270
  this.eventQueue?.clear();
4001
- await this.storage.clearAll();
5271
+ if (opts?.includeHeldGuests) this.auth.releaseAllHeldGuests();
5272
+ await this.storage.clearAll({ keys: ALL_SDK_KEYS, except: opts?.includeHeldGuests ? [] : HELD_SLOT_KEYS });
4002
5273
  this.storage.set(ANON_KEY, this.anonId);
4003
5274
  }
4004
5275
  /** Tear down auto-track listeners. Call in test harnesses. */
@@ -4058,15 +5329,21 @@ var SendoraCloud = new SendoraSDK();
4058
5329
  var index_default = SendoraCloud;
4059
5330
  // Annotate the CommonJS export names for ESM import in node:
4060
5331
  0 && (module.exports = {
5332
+ AMBIGUOUS_HELD_GUEST,
4061
5333
  AlreadyIdentifiedError,
4062
5334
  Auth,
4063
5335
  AuthError,
4064
5336
  CredentialInUseError,
4065
5337
  EmailAlreadyTakenError,
5338
+ HELD_GUEST_SLOTS,
5339
+ HeldGuestLimitError,
5340
+ HeldGuestProjectMismatch,
4066
5341
  LinkError,
4067
5342
  Links,
4068
5343
  SendoraCloud,
4069
5344
  computeDeviceFingerprint,
5345
+ decodeJwtPayload,
4070
5346
  extractShortcodeFromUrl,
4071
- getPlayInstallReferrer
5347
+ getPlayInstallReferrer,
5348
+ projectVerdict
4072
5349
  });