@oxy.so/core 4.1.0 → 4.3.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.
Files changed (95) hide show
  1. package/dist/cjs/HttpService.js +142 -33
  2. package/dist/cjs/api/accounts.js +49 -10
  3. package/dist/cjs/api/apps.js +2 -2
  4. package/dist/cjs/api/auth.js +24 -3
  5. package/dist/cjs/api/billing.js +35 -0
  6. package/dist/cjs/boot/sessionColdBoot.js +28 -57
  7. package/dist/cjs/inference/OxyInferenceClient.js +34 -15
  8. package/dist/cjs/server/OxyServer.js +107 -51
  9. package/dist/cjs/server/agentAccount.js +19 -0
  10. package/dist/cjs/server/auth.js +19 -0
  11. package/dist/cjs/server/capabilityTicket.js +3 -41
  12. package/dist/cjs/server/index.js +10 -2
  13. package/dist/cjs/server/liveCapabilityTicket.js +48 -0
  14. package/dist/cjs/server/middleware.js +60 -18
  15. package/dist/cjs/server/namespaces.js +57 -0
  16. package/dist/cjs/server/serviceTokenLifetime.js +13 -0
  17. package/dist/cjs/session/SessionClient.js +90 -12
  18. package/dist/cjs/session/authStateStore.js +130 -39
  19. package/dist/cjs/session/refresh.js +152 -30
  20. package/dist/cjs/session/sharedDeviceCredential.js +17 -1
  21. package/dist/cjs/utils/officialOrigins.js +7 -18
  22. package/dist/esm/HttpService.js +143 -34
  23. package/dist/esm/api/accounts.js +38 -0
  24. package/dist/esm/api/apps.js +2 -2
  25. package/dist/esm/api/auth.js +23 -3
  26. package/dist/esm/api/billing.js +35 -0
  27. package/dist/esm/boot/sessionColdBoot.js +29 -58
  28. package/dist/esm/inference/OxyInferenceClient.js +32 -13
  29. package/dist/esm/server/OxyServer.js +107 -51
  30. package/dist/esm/server/agentAccount.js +16 -0
  31. package/dist/esm/server/auth.js +18 -0
  32. package/dist/esm/server/capabilityTicket.js +1 -39
  33. package/dist/esm/server/index.js +4 -1
  34. package/dist/esm/server/liveCapabilityTicket.js +45 -0
  35. package/dist/esm/server/middleware.js +60 -18
  36. package/dist/esm/server/namespaces.js +57 -0
  37. package/dist/esm/server/serviceTokenLifetime.js +9 -0
  38. package/dist/esm/session/SessionClient.js +90 -12
  39. package/dist/esm/session/authStateStore.js +128 -38
  40. package/dist/esm/session/refresh.js +151 -30
  41. package/dist/esm/session/sharedDeviceCredential.js +17 -1
  42. package/dist/esm/utils/officialOrigins.js +2 -13
  43. package/dist/types/HttpService.d.ts +26 -12
  44. package/dist/types/api/accounts.d.ts +38 -0
  45. package/dist/types/api/agency.d.ts +3 -1
  46. package/dist/types/api/apps.d.ts +3 -1
  47. package/dist/types/api/auth.d.ts +12 -1
  48. package/dist/types/api/billing.d.ts +12 -0
  49. package/dist/types/api/session.d.ts +3 -1
  50. package/dist/types/boot/sessionColdBoot.d.ts +1 -1
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/inference/OxyInferenceClient.d.ts +18 -0
  53. package/dist/types/inference/index.d.ts +1 -1
  54. package/dist/types/server/OxyServer.d.ts +16 -9
  55. package/dist/types/server/agentAccount.d.ts +14 -0
  56. package/dist/types/server/auth.d.ts +18 -0
  57. package/dist/types/server/capabilityTicket.d.ts +2 -4
  58. package/dist/types/server/index.d.ts +7 -1
  59. package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
  60. package/dist/types/server/middleware.d.ts +15 -6
  61. package/dist/types/server/namespaces.d.ts +78 -2
  62. package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
  63. package/dist/types/session/SessionClient.d.ts +8 -0
  64. package/dist/types/session/authStateStore.d.ts +15 -0
  65. package/dist/types/session/index.d.ts +1 -1
  66. package/dist/types/session/refresh.d.ts +32 -7
  67. package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
  68. package/dist/types/utils/officialOrigins.d.ts +1 -5
  69. package/package.json +6 -6
  70. package/src/HttpService.ts +138 -50
  71. package/src/api/accounts.ts +22 -0
  72. package/src/api/agency.ts +3 -1
  73. package/src/api/apps.ts +2 -2
  74. package/src/api/auth.ts +31 -2
  75. package/src/api/billing.ts +71 -9
  76. package/src/api/session.ts +3 -1
  77. package/src/boot/sessionColdBoot.ts +27 -72
  78. package/src/index.ts +1 -1
  79. package/src/inference/OxyInferenceClient.ts +35 -5
  80. package/src/inference/index.ts +1 -0
  81. package/src/server/OxyServer.ts +114 -53
  82. package/src/server/agentAccount.ts +28 -0
  83. package/src/server/auth.ts +25 -0
  84. package/src/server/capabilityTicket.ts +2 -42
  85. package/src/server/index.ts +13 -0
  86. package/src/server/liveCapabilityTicket.ts +47 -0
  87. package/src/server/middleware.ts +72 -25
  88. package/src/server/namespaces.ts +99 -2
  89. package/src/server/serviceTokenLifetime.ts +13 -0
  90. package/src/session/SessionClient.ts +85 -12
  91. package/src/session/authStateStore.ts +130 -46
  92. package/src/session/index.ts +1 -0
  93. package/src/session/refresh.ts +144 -28
  94. package/src/session/sharedDeviceCredential.ts +14 -2
  95. package/src/utils/officialOrigins.ts +2 -13
@@ -13,6 +13,7 @@ import {
13
13
  } from '@oxy.so/contracts';
14
14
  import { logger } from '../logger';
15
15
  import { computeIdentityTag } from '../utils/cacheKey';
16
+ import { decodeTokenClaims } from '../utils/tokenClaims';
16
17
  import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
17
18
  import { getSocketIO } from './socketLoader';
18
19
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
@@ -55,6 +56,8 @@ export interface SessionClientHost {
55
56
  }
56
57
 
57
58
  export interface SessionClientOptions {
59
+ /** Await durable local logout intent after an explicit removal or an applied nonempty→empty device transition. */
60
+ onFullExplicitSignOut?: () => Promise<void>;
58
61
  transport?: TokenTransport;
59
62
  /**
60
63
  * Invoked when an APPLIED state has zero accounts — i.e. a device
@@ -142,6 +145,8 @@ export class SessionClient {
142
145
  protected socket: MinimalSocket | null = null;
143
146
  private tokenUnsub: (() => void) | null = null;
144
147
  private started = false;
148
+ private lifecycleGeneration = 0;
149
+ private fullSignOutPersistence: { state: DeviceSessionState; pending: Promise<void> } | null = null;
145
150
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
146
151
  private channel: SessionBroadcastChannel | null = null;
147
152
  /** App-facing subscriptions to named server-pushed socket events. */
@@ -154,6 +159,34 @@ export class SessionClient {
154
159
  protected readonly options: SessionClientOptions = {},
155
160
  ) {}
156
161
 
162
+ private persistFullSignOut(): Promise<void> {
163
+ const state = this.state;
164
+ if (!state || !this.options.onFullExplicitSignOut) return Promise.resolve();
165
+ if (this.fullSignOutPersistence?.state === state) return this.fullSignOutPersistence.pending;
166
+ const pending = this.options.onFullExplicitSignOut();
167
+ this.fullSignOutPersistence = { state, pending };
168
+ void pending.catch(() => {
169
+ if (this.fullSignOutPersistence?.pending === pending) this.fullSignOutPersistence = null;
170
+ });
171
+ return pending;
172
+ }
173
+
174
+ private async requestDevice<T>(method: 'GET' | 'POST', url: string, data?: unknown, requestOptions?: { cache?: boolean }): Promise<T> {
175
+ const generation = this.lifecycleGeneration;
176
+ const response = await this.host.makeRequest<T>(method, url, data, requestOptions);
177
+ if (generation !== this.lifecycleGeneration) throw new Error('Device session request was superseded');
178
+ return response;
179
+ }
180
+
181
+ /** Stop and discard only this client's local device projection; no server revocation. */
182
+ resetLocalState(): void {
183
+ this.lifecycleGeneration += 1;
184
+ this.stop();
185
+ this.state = null;
186
+ this.directory = null;
187
+ this.notify();
188
+ }
189
+
157
190
  getState(): DeviceSessionState | null {
158
191
  return this.state;
159
192
  }
@@ -280,6 +313,7 @@ export class SessionClient {
280
313
  * cross-device push, a cold mint), not just the initial "no bearer yet" case.
281
314
  */
282
315
  protected applyState(raw: unknown, origin: SessionStateOrigin = 'push', activeToken?: string): boolean {
316
+ const generation = this.lifecycleGeneration;
283
317
  const next = safeParseContract(deviceSessionStateSchema, raw);
284
318
  if (!next) {
285
319
  logger.warn('[SessionClient] discarded invalid session state');
@@ -341,6 +375,7 @@ export class SessionClient {
341
375
  (activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
342
376
 
343
377
  const publish = (): void => {
378
+ if (generation !== this.lifecycleGeneration || this.state !== next) return;
344
379
  this.notify();
345
380
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
346
381
  try {
@@ -359,6 +394,7 @@ export class SessionClient {
359
394
  // (and this stays synchronous) for every client that never read a
360
395
  // directory, i.e. the whole account lane.
361
396
  const finishApply = (): void => {
397
+ if (generation !== this.lifecycleGeneration || this.state !== next) return;
362
398
  const settling = this.settleDirectory(next);
363
399
  if (settling === null) {
364
400
  publish();
@@ -367,8 +403,39 @@ export class SessionClient {
367
403
  void settling.then(publish);
368
404
  };
369
405
 
406
+ // A sibling's validated full removal is logout here too. Persist the local
407
+ // account-mode recovery suppression before publishing signed-out or running
408
+ // the empty-state refresh (which could otherwise challenge Commons again).
409
+ // Keep the push credential intact; a marker is not a destructive wipe.
410
+ // Unknown initial empty state, stale revisions and identity pins do not
411
+ // turn into logout intent. A newer state/lifecycle suppresses this publish.
412
+ const priorBearer = this.host.getAccessToken();
413
+ const bearerAccount = priorBearer ? decodeTokenClaims(priorBearer)?.userId : null;
414
+ // Warm cold boot may fetch authoritative empty state before it ever had a
415
+ // local projection. Its authenticated holder is history too. Decoding only
416
+ // identifies local history; authority remains the validated REST response.
417
+ const warmHolderEnded = previousState === null && origin === 'request' &&
418
+ typeof bearerAccount === 'string' && bearerAccount.length > 0 &&
419
+ this.host.getDeviceCredential()?.deviceId === next.deviceId;
420
+ if (
421
+ ((previousState?.deviceId === next.deviceId && previousState.accounts.length > 0) || warmHolderEnded) &&
422
+ next.accounts.length === 0 && pinnedAccountId === null && this.options.onFullExplicitSignOut
423
+ ) {
424
+ try {
425
+ void this.persistFullSignOut().then(finishApply).catch((error) => {
426
+ logger.error('[SessionClient] failed to persist received full sign-out', error);
427
+ finishApply();
428
+ });
429
+ } catch (error) {
430
+ logger.error('[SessionClient] failed to persist received full sign-out', error);
431
+ finishApply();
432
+ }
433
+ return true;
434
+ }
435
+
370
436
  if (needsMintBeforeNotify) {
371
437
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
438
+ if (generation !== this.lifecycleGeneration) return;
372
439
  logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
373
440
  // Do NOT notify under a mismatched bearer. Revert to the last applied
374
441
  // state so subscribers keep observing the account whose token is planted.
@@ -392,7 +459,7 @@ export class SessionClient {
392
459
  * the token still needs to be planted. The account-match guard rejects a stale response for an
393
460
  * account that is no longer active.
394
461
  */
395
- private applySync(raw: unknown): void {
462
+ private async applySync(raw: unknown): Promise<void> {
396
463
  const sync = safeParseContract(deviceSessionSyncSchema, raw);
397
464
  if (!sync) {
398
465
  const parsed = deviceSessionSyncSchema.safeParse(raw);
@@ -411,6 +478,7 @@ export class SessionClient {
411
478
  return;
412
479
  }
413
480
  this.commitSync(sync);
481
+ if (this.fullSignOutPersistence?.state === this.state) await this.fullSignOutPersistence.pending;
414
482
  }
415
483
 
416
484
  /**
@@ -495,7 +563,7 @@ export class SessionClient {
495
563
 
496
564
  /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
497
565
  private async fetchDirectory(): Promise<boolean> {
498
- const res = await this.host.makeRequest<unknown>('GET', '/session/device/directory', undefined, { cache: false });
566
+ const res = await this.requestDevice<unknown>('GET', '/session/device/directory', undefined, { cache: false });
499
567
  return this.applyDirectory(res);
500
568
  }
501
569
 
@@ -611,8 +679,8 @@ export class SessionClient {
611
679
  }
612
680
 
613
681
  async bootstrap(): Promise<void> {
614
- const res = await this.host.makeRequest<unknown>('GET', '/session/device/state', undefined, { cache: false });
615
- this.applySync(res);
682
+ const res = await this.requestDevice<unknown>('GET', '/session/device/state', undefined, { cache: false });
683
+ await this.applySync(res);
616
684
  }
617
685
 
618
686
  /**
@@ -658,7 +726,7 @@ export class SessionClient {
658
726
  * compatibility path for callers still keyed on account ids.
659
727
  */
660
728
  async activateContext(contextId: string): Promise<void> {
661
- const res = await this.host.makeRequest<unknown>('POST', '/session/device/activate', { contextId }, { cache: false });
729
+ const res = await this.requestDevice<unknown>('POST', '/session/device/activate', { contextId }, { cache: false });
662
730
  const activation = safeParseContract(deviceActivateResponseSchema, res);
663
731
  if (!activation) {
664
732
  logger.warn('[SessionClient] discarded invalid activation response');
@@ -685,8 +753,8 @@ export class SessionClient {
685
753
  }
686
754
 
687
755
  async switchAccount(accountId: string): Promise<void> {
688
- const res = await this.host.makeRequest<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
689
- this.applySync(res);
756
+ const res = await this.requestDevice<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
757
+ await this.applySync(res);
690
758
  this.postCommitPing();
691
759
  }
692
760
 
@@ -702,9 +770,10 @@ export class SessionClient {
702
770
  * {@link signOutPrincipal} for the two that can tell those apart.
703
771
  */
704
772
  async signOut(target: { accountId: string } | { all: true }): Promise<void> {
705
- const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
706
- this.applySync(res);
773
+ const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, { cache: false });
774
+ await this.applySync(res);
707
775
  this.postCommitPing();
776
+ if (this.state?.accounts.length === 0) await this.persistFullSignOut();
708
777
  }
709
778
 
710
779
  /**
@@ -746,7 +815,7 @@ export class SessionClient {
746
815
  * applied this revision.
747
816
  */
748
817
  private async removeFromDevice(target: { contextId: string } | { principalId: string }): Promise<void> {
749
- const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
818
+ const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, { cache: false });
750
819
  const removal = safeParseContract(deviceDirectorySyncSchema, res);
751
820
  if (!removal) {
752
821
  logger.warn('[SessionClient] discarded invalid device removal response');
@@ -754,6 +823,7 @@ export class SessionClient {
754
823
  }
755
824
  const directoryApplied = this.applyDirectory(removal.directory);
756
825
  const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
826
+ if (this.fullSignOutPersistence?.state === this.state) await this.fullSignOutPersistence.pending;
757
827
  // `commitSync` publishes whenever the flat state moved. When only the
758
828
  // directory did — a socket push already applied this revision — the
759
829
  // directory half would otherwise never reach a subscriber.
@@ -761,11 +831,12 @@ export class SessionClient {
761
831
  this.notify();
762
832
  }
763
833
  this.postCommitPing();
834
+ if (this.state?.accounts.length === 0) await this.persistFullSignOut();
764
835
  }
765
836
 
766
837
  async addCurrentAccount(): Promise<void> {
767
- const res = await this.host.makeRequest<unknown>('POST', '/session/device/add', undefined, { cache: false });
768
- this.applySync(res);
838
+ const res = await this.requestDevice<unknown>('POST', '/session/device/add', undefined, { cache: false });
839
+ await this.applySync(res);
769
840
  this.postCommitPing();
770
841
  }
771
842
 
@@ -866,6 +937,7 @@ export class SessionClient {
866
937
  const deviceCredential = this.host.getDeviceCredential();
867
938
  if (!token && !deviceCredential) return;
868
939
 
940
+ const generation = this.lifecycleGeneration;
869
941
  const socket = io(this.host.getBaseURL(), {
870
942
  transports: ['websocket'],
871
943
  autoConnect: true,
@@ -890,6 +962,7 @@ export class SessionClient {
890
962
  socket.on('session_state', (payload: unknown) => {
891
963
  // A socket broadcast is a `push`-origin — potentially transient, so an
892
964
  // empty state here must not erase the durable device credential.
965
+ if (generation !== this.lifecycleGeneration) return;
893
966
  const applied = this.applyState(payload, 'push');
894
967
  if (!applied) return;
895
968
  // An identity-bound client tracks the pushed state but NEVER chases the
@@ -84,7 +84,28 @@ export interface AuthStateStore {
84
84
  * session built on a secret that will not survive a reload.
85
85
  */
86
86
  save(state: PersistedAuthState): Promise<boolean>;
87
+ /** Compare inside the storage queue before committing a proven shared holder. */
88
+ saveIfCurrent?(state: PersistedAuthState, guard: AuthStateWriteGuard): Promise<boolean>;
87
89
  clear(): Promise<void>;
90
+ /** Durable local intent only: never authority or a mutation of an identity key. */
91
+ isAutomaticIdentitySignInSuppressed?(): Promise<boolean>;
92
+ setAutomaticIdentitySignInSuppressed?(suppressed: boolean, guard?: {
93
+ /** Checked inside the native store queue, before mutating logout intent. */
94
+ expectedState: PersistedAuthState;
95
+ isCurrent: () => boolean;
96
+ }): Promise<boolean>;
97
+ }
98
+
99
+ export interface AuthStateWriteGuard {
100
+ expectedState: PersistedAuthState | null;
101
+ isCurrent: () => boolean;
102
+ }
103
+
104
+ export function matchesAuthState(actual: PersistedAuthState | null, expected: PersistedAuthState | null): boolean {
105
+ if (!actual || !expected) return actual === expected;
106
+ return actual.deviceId === expected.deviceId && actual.deviceSecret === expected.deviceSecret &&
107
+ actual.sessionId === expected.sessionId && actual.userId === expected.userId &&
108
+ actual.accessToken === expected.accessToken && actual.expiresAt === expected.expiresAt;
88
109
  }
89
110
 
90
111
  /**
@@ -115,6 +136,7 @@ export interface NativeKeyValueStorage {
115
136
  * so upgrading users are not signed out; the next `save()` splits them apart.
116
137
  */
117
138
  export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
139
+ export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
118
140
 
119
141
  /**
120
142
  * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
@@ -294,6 +316,11 @@ export function createMemoryAuthStateStore(): AuthStateStore {
294
316
  current = state;
295
317
  return true;
296
318
  },
319
+ saveIfCurrent: async (state, guard) => {
320
+ if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState)) return false;
321
+ current = state;
322
+ return true;
323
+ },
297
324
  clear: async () => {
298
325
  current = null;
299
326
  },
@@ -428,8 +455,100 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
428
455
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
429
456
  // must not silently lose the session for the app's lifetime.
430
457
  let sessionMirror: PersistedAuthState | null | undefined;
458
+ let suppressedMirror: boolean | undefined;
459
+ let pending: Promise<void> = Promise.resolve();
460
+ const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
461
+ const result = pending.then(operation);
462
+ pending = result.then(() => undefined, () => undefined);
463
+ return result;
464
+ };
465
+ const readCurrent = async (): Promise<PersistedAuthState | null> => {
466
+ if (sessionMirror !== undefined) return sessionMirror;
467
+ const [durable, warm] = await Promise.all([
468
+ storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
469
+ ]);
470
+ return composeState(durable, warm);
471
+ };
472
+ const persistState = async (state: PersistedAuthState): Promise<boolean> => {
473
+ sessionMirror = state;
474
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
475
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
476
+ // only reliable proof. The mirror keeps the session live for this app run,
477
+ // but a failed DURABLE write means the mint credential will NOT survive a
478
+ // cold restart — surface it, never swallow.
479
+ let durablePersisted = false;
480
+ try {
481
+ const durableJson = serializeDurable(state);
482
+ await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
483
+ durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
484
+ if (!durablePersisted) {
485
+ logger.error(
486
+ '[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart',
487
+ undefined,
488
+ { component: 'authStateStore' },
489
+ );
490
+ }
491
+ } catch (error) {
492
+ logger.error(
493
+ '[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart',
494
+ error,
495
+ { component: 'authStateStore' },
496
+ );
497
+ }
498
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
499
+ // durable credential re-mints a fresh token) and must never abort or
500
+ // corrupt the durable write above.
501
+ try {
502
+ const warmJson = serializeWarmToken(state);
503
+ if (warmJson) {
504
+ await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
505
+ } else {
506
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
507
+ }
508
+ } catch {
509
+ // Locked / oversize keychain — non-fatal warm-boot loss only.
510
+ }
511
+ // Report ONLY the durable-credential landing; the warm-token outcome above
512
+ // is intentionally excluded (it is a best-effort optimization).
513
+ return durablePersisted;
514
+ };
431
515
  return {
432
- load: async () => {
516
+ isAutomaticIdentitySignInSuppressed: () => serialize(async () => {
517
+ if (suppressedMirror !== undefined) return suppressedMirror;
518
+ try {
519
+ return (await storage.getItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY)) !== null;
520
+ } catch {
521
+ return true; // Unknown storage must not start a new key sign-in.
522
+ }
523
+ }),
524
+ setAutomaticIdentitySignInSuppressed: (suppressed, guard) => serialize(async () => {
525
+ if (guard) {
526
+ let current = sessionMirror;
527
+ if (current === undefined) {
528
+ try {
529
+ const [durable, warm] = await Promise.all([
530
+ storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
531
+ ]);
532
+ current = composeState(durable, warm);
533
+ } catch { suppressedMirror = true; return false; }
534
+ }
535
+ const expected = guard.expectedState;
536
+ if (!guard.isCurrent() || !current ||
537
+ current.deviceId !== expected.deviceId || current.deviceSecret !== expected.deviceSecret ||
538
+ current.sessionId !== expected.sessionId || current.userId !== expected.userId ||
539
+ current.accessToken !== expected.accessToken || current.expiresAt !== expected.expiresAt) return false;
540
+ }
541
+ suppressedMirror = suppressed;
542
+ try {
543
+ if (suppressed) await storage.setItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, '1');
544
+ else await storage.removeItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
545
+ const actual = await storage.getItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
546
+ const verified = suppressed ? actual === '1' : actual === null;
547
+ if (!verified) suppressedMirror = true;
548
+ return verified;
549
+ } catch { suppressedMirror = true; return false; }
550
+ }),
551
+ load: () => serialize(async () => {
433
552
  if (sessionMirror !== undefined) {
434
553
  return sessionMirror;
435
554
  }
@@ -442,51 +561,16 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
442
561
  } catch {
443
562
  return null;
444
563
  }
445
- },
446
- save: async (state) => {
447
- sessionMirror = state;
448
- // Durable credential FIRST, then VERIFY. On Android SecureStore an
449
- // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
450
- // only reliable proof. The mirror keeps the session live for this app run,
451
- // but a failed DURABLE write means the mint credential will NOT survive a
452
- // cold restart — surface it, never swallow.
453
- let durablePersisted = false;
564
+ }),
565
+ save: (state) => serialize(() => persistState(state)),
566
+ saveIfCurrent: (state, guard) => serialize(async () => {
454
567
  try {
455
- const durableJson = serializeDurable(state);
456
- await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
457
- durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
458
- if (!durablePersisted) {
459
- logger.error(
460
- '[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart',
461
- undefined,
462
- { component: 'authStateStore' },
463
- );
464
- }
465
- } catch (error) {
466
- logger.error(
467
- '[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart',
468
- error,
469
- { component: 'authStateStore' },
470
- );
471
- }
472
- // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
473
- // durable credential re-mints a fresh token) and must never abort or
474
- // corrupt the durable write above.
475
- try {
476
- const warmJson = serializeWarmToken(state);
477
- if (warmJson) {
478
- await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
479
- } else {
480
- await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
481
- }
482
- } catch {
483
- // Locked / oversize keychain — non-fatal warm-boot loss only.
484
- }
485
- // Report ONLY the durable-credential landing; the warm-token outcome above
486
- // is intentionally excluded (it is a best-effort optimization).
487
- return durablePersisted;
488
- },
489
- clear: async () => {
568
+ const current = await readCurrent();
569
+ if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState)) return false;
570
+ } catch { return false; }
571
+ return persistState(state);
572
+ }),
573
+ clear: () => serialize(async () => {
490
574
  sessionMirror = null;
491
575
  try {
492
576
  await storage.removeItem(AUTH_STATE_STORAGE_KEY);
@@ -498,6 +582,6 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
498
582
  } catch {
499
583
  // Non-fatal.
500
584
  }
501
- },
585
+ }),
502
586
  };
503
587
  }
@@ -132,6 +132,7 @@ export {
132
132
  export type {
133
133
  PersistedAuthState,
134
134
  AuthStateStore,
135
+ AuthStateWriteGuard,
135
136
  NativeKeyValueStorage,
136
137
  } from './authStateStore';
137
138