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