@oxy.so/core 4.1.0 → 4.2.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 +23 -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 +21 -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 +9 -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 +19 -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
@@ -1,6 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ServerReputationApi = exports.ServerAgencyApi = exports.ServerLinkedAccountsApi = exports.ServerNotificationsApi = exports.ServerAssetsApi = void 0;
4
+ /**
5
+ * The server-side halves of the namespaces: what exists only with a service
6
+ * credential. `OxyServer` returns these from its namespace getters, so
7
+ * `server.assets.metadataByIds(...)` sits beside every client method of
8
+ * `oxy.assets`.
9
+ */
10
+ const contracts_1 = require("@oxy.so/contracts");
4
11
  const assets_1 = require("../api/assets");
5
12
  const notifications_1 = require("../api/notifications");
6
13
  const linkedAccounts_1 = require("../api/linkedAccounts");
@@ -185,7 +192,57 @@ class ServerLinkedAccountsApi extends linkedAccounts_1.LinkedAccountsApi {
185
192
  }
186
193
  }
187
194
  exports.ServerLinkedAccountsApi = ServerLinkedAccountsApi;
195
+ function agencyAuthorityOptions(signal) {
196
+ signal?.throwIfAborted();
197
+ return { cache: false, deduplicate: false, retry: false, skipAuth: true, timeout: 5000, ...(signal ? { signal } : {}) };
198
+ }
188
199
  class ServerAgencyApi extends agency_1.AgencyApi {
200
+ /** Live registry discovery with this service's existing capability scopes. */
201
+ async serviceCatalogs(input = {}) {
202
+ const path = `/capabilities/catalogs${input.appId === undefined ? '' : `?appId=${encodeURIComponent(input.appId)}`}`;
203
+ const result = await lane(this.ctx).request('GET', path, undefined, agencyAuthorityOptions(input.signal));
204
+ return result.registrations;
205
+ }
206
+ /** Existing Oxy execution authority is required; this call creates none. */
207
+ async issueCapabilityTicket(input, options = {}) {
208
+ return lane(this.ctx).request('POST', '/capabilities/tickets', contracts_1.capabilityTicketRequestSchema.parse(input), agencyAuthorityOptions(options.signal));
209
+ }
210
+ /** Receiving app authenticates live introspection using its own service credential. */
211
+ async introspectCapabilityTicket(ticket, options = {}) {
212
+ if (ticket.trim() === '')
213
+ throw new Error('A capability ticket is required');
214
+ return lane(this.ctx).request('POST', '/capabilities/tickets/introspect', { ticket }, agencyAuthorityOptions(options.signal));
215
+ }
216
+ /**
217
+ * Authenticates the existing user-authority route with the requester's bearer.
218
+ * The service bearer and attribution headers cannot substitute for that user.
219
+ * The token goes only to this client's configured Oxy authority; never retry.
220
+ */
221
+ async createExecutionAuthorization(input, options) {
222
+ if (options.requesterToken === '' || /\s/.test(options.requesterToken))
223
+ throw new Error('A requester bearer is required');
224
+ const result = await this.ctx.request('POST', '/capabilities/execution-authorizations', input, {
225
+ ...agencyAuthorityOptions(options.signal),
226
+ headers: { Authorization: `Bearer ${options.requesterToken}` },
227
+ });
228
+ return result.authorization;
229
+ }
230
+ /** Revoke a direct approval on its canonical requester-authenticated lane. */
231
+ async revokeExecutionAuthorization(authorizationId, options) {
232
+ options.signal?.throwIfAborted();
233
+ if (!authorizationId || authorizationId.length > 255 || /\s/.test(authorizationId))
234
+ throw new Error('An execution authorization id is required');
235
+ if (!options.requesterToken || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
236
+ throw new Error('A requester bearer is required');
237
+ await this.ctx.request('DELETE', `/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`, undefined, { ...agencyAuthorityOptions(options.signal), headers: { Authorization: `Bearer ${options.requesterToken}` } });
238
+ }
239
+ /** Independent service proof plus present requester bearer, sent only to Oxy. */
240
+ async createForegroundExecutionAuthorization(input, options) {
241
+ if (options.requesterToken === '' || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
242
+ throw new Error('A requester bearer is required');
243
+ const result = await lane(this.ctx).request('POST', '/capabilities/foreground-execution-authorizations', { ...contracts_1.foregroundExecutionAuthorizationInputSchema.parse(input), subjectToken: options.requesterToken }, agencyAuthorityOptions(options.signal));
244
+ return result.authorization;
245
+ }
189
246
  /**
190
247
  * Product backend → Oxy: trade the signed-in requester's access token for a
191
248
  * one-use assertion naming `agentId` (ADR 0025), authenticated with this
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = void 0;
4
+ exports.hasBoundedServiceTokenLifetime = hasBoundedServiceTokenLifetime;
5
+ /** Approved I03 ceiling shared by issuance and both JWT verifiers. */
6
+ exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = 300;
7
+ /** Call only after signature verification; cache checks alone confer no authority. */
8
+ function hasBoundedServiceTokenLifetime(claims, nowSeconds = Math.floor(Date.now() / 1000)) {
9
+ return typeof claims.iat === 'number' && Number.isSafeInteger(claims.iat)
10
+ && typeof claims.exp === 'number' && Number.isSafeInteger(claims.exp)
11
+ && claims.iat <= nowSeconds && claims.exp > claims.iat
12
+ && claims.exp - claims.iat <= exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS;
13
+ }
@@ -4,6 +4,7 @@ exports.SessionClient = void 0;
4
4
  const contracts_1 = require("@oxy.so/contracts");
5
5
  const logger_1 = require("../logger");
6
6
  const cacheKey_1 = require("../utils/cacheKey");
7
+ const tokenClaims_1 = require("../utils/tokenClaims");
7
8
  const deviceDirectory_1 = require("./deviceDirectory");
8
9
  const socketLoader_1 = require("./socketLoader");
9
10
  /**
@@ -37,6 +38,8 @@ class SessionClient {
37
38
  this.socket = null;
38
39
  this.tokenUnsub = null;
39
40
  this.started = false;
41
+ this.lifecycleGeneration = 0;
42
+ this.fullSignOutPersistence = null;
40
43
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
41
44
  this.channel = null;
42
45
  /** App-facing subscriptions to named server-pushed socket events. */
@@ -44,6 +47,35 @@ class SessionClient {
44
47
  /** Event names already bound on the CURRENT socket instance. */
45
48
  this.boundServerEvents = new Set();
46
49
  }
50
+ persistFullSignOut() {
51
+ const state = this.state;
52
+ if (!state || !this.options.onFullExplicitSignOut)
53
+ return Promise.resolve();
54
+ if (this.fullSignOutPersistence?.state === state)
55
+ return this.fullSignOutPersistence.pending;
56
+ const pending = this.options.onFullExplicitSignOut();
57
+ this.fullSignOutPersistence = { state, pending };
58
+ void pending.catch(() => {
59
+ if (this.fullSignOutPersistence?.pending === pending)
60
+ this.fullSignOutPersistence = null;
61
+ });
62
+ return pending;
63
+ }
64
+ async requestDevice(method, url, data, requestOptions) {
65
+ const generation = this.lifecycleGeneration;
66
+ const response = await this.host.makeRequest(method, url, data, requestOptions);
67
+ if (generation !== this.lifecycleGeneration)
68
+ throw new Error('Device session request was superseded');
69
+ return response;
70
+ }
71
+ /** Stop and discard only this client's local device projection; no server revocation. */
72
+ resetLocalState() {
73
+ this.lifecycleGeneration += 1;
74
+ this.stop();
75
+ this.state = null;
76
+ this.directory = null;
77
+ this.notify();
78
+ }
47
79
  getState() {
48
80
  return this.state;
49
81
  }
@@ -166,6 +198,7 @@ class SessionClient {
166
198
  * cross-device push, a cold mint), not just the initial "no bearer yet" case.
167
199
  */
168
200
  applyState(raw, origin = 'push', activeToken) {
201
+ const generation = this.lifecycleGeneration;
169
202
  const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
170
203
  if (!next) {
171
204
  logger_1.logger.warn('[SessionClient] discarded invalid session state');
@@ -221,6 +254,8 @@ class SessionClient {
221
254
  next.accounts.length > 0 &&
222
255
  (activeAccountId === null || (0, cacheKey_1.computeIdentityTag)(this.host.getAccessToken()) !== activeAccountId);
223
256
  const publish = () => {
257
+ if (generation !== this.lifecycleGeneration || this.state !== next)
258
+ return;
224
259
  this.notify();
225
260
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
226
261
  try {
@@ -239,6 +274,8 @@ class SessionClient {
239
274
  // (and this stays synchronous) for every client that never read a
240
275
  // directory, i.e. the whole account lane.
241
276
  const finishApply = () => {
277
+ if (generation !== this.lifecycleGeneration || this.state !== next)
278
+ return;
242
279
  const settling = this.settleDirectory(next);
243
280
  if (settling === null) {
244
281
  publish();
@@ -246,8 +283,38 @@ class SessionClient {
246
283
  }
247
284
  void settling.then(publish);
248
285
  };
286
+ // A sibling's validated full removal is logout here too. Persist the local
287
+ // account-mode recovery suppression before publishing signed-out or running
288
+ // the empty-state refresh (which could otherwise challenge Commons again).
289
+ // Keep the push credential intact; a marker is not a destructive wipe.
290
+ // Unknown initial empty state, stale revisions and identity pins do not
291
+ // turn into logout intent. A newer state/lifecycle suppresses this publish.
292
+ const priorBearer = this.host.getAccessToken();
293
+ const bearerAccount = priorBearer ? (0, tokenClaims_1.decodeTokenClaims)(priorBearer)?.userId : null;
294
+ // Warm cold boot may fetch authoritative empty state before it ever had a
295
+ // local projection. Its authenticated holder is history too. Decoding only
296
+ // identifies local history; authority remains the validated REST response.
297
+ const warmHolderEnded = previousState === null && origin === 'request' &&
298
+ typeof bearerAccount === 'string' && bearerAccount.length > 0 &&
299
+ this.host.getDeviceCredential()?.deviceId === next.deviceId;
300
+ if (((previousState?.deviceId === next.deviceId && previousState.accounts.length > 0) || warmHolderEnded) &&
301
+ next.accounts.length === 0 && pinnedAccountId === null && this.options.onFullExplicitSignOut) {
302
+ try {
303
+ void this.persistFullSignOut().then(finishApply).catch((error) => {
304
+ logger_1.logger.error('[SessionClient] failed to persist received full sign-out', error);
305
+ finishApply();
306
+ });
307
+ }
308
+ catch (error) {
309
+ logger_1.logger.error('[SessionClient] failed to persist received full sign-out', error);
310
+ finishApply();
311
+ }
312
+ return true;
313
+ }
249
314
  if (needsMintBeforeNotify) {
250
315
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
316
+ if (generation !== this.lifecycleGeneration)
317
+ return;
251
318
  logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
252
319
  // Do NOT notify under a mismatched bearer. Revert to the last applied
253
320
  // state so subscribers keep observing the account whose token is planted.
@@ -271,7 +338,7 @@ class SessionClient {
271
338
  * the token still needs to be planted. The account-match guard rejects a stale response for an
272
339
  * account that is no longer active.
273
340
  */
274
- applySync(raw) {
341
+ async applySync(raw) {
275
342
  const sync = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionSyncSchema, raw);
276
343
  if (!sync) {
277
344
  const parsed = contracts_1.deviceSessionSyncSchema.safeParse(raw);
@@ -288,6 +355,8 @@ class SessionClient {
288
355
  return;
289
356
  }
290
357
  this.commitSync(sync);
358
+ if (this.fullSignOutPersistence?.state === this.state)
359
+ await this.fullSignOutPersistence.pending;
291
360
  }
292
361
  /**
293
362
  * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
@@ -365,7 +434,7 @@ class SessionClient {
365
434
  }
366
435
  /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
367
436
  async fetchDirectory() {
368
- const res = await this.host.makeRequest('GET', '/session/device/directory', undefined, { cache: false });
437
+ const res = await this.requestDevice('GET', '/session/device/directory', undefined, { cache: false });
369
438
  return this.applyDirectory(res);
370
439
  }
371
440
  /**
@@ -474,8 +543,8 @@ class SessionClient {
474
543
  }
475
544
  }
476
545
  async bootstrap() {
477
- const res = await this.host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
478
- this.applySync(res);
546
+ const res = await this.requestDevice('GET', '/session/device/state', undefined, { cache: false });
547
+ await this.applySync(res);
479
548
  }
480
549
  /**
481
550
  * Adopt server-authoritative state already returned by another session
@@ -518,7 +587,7 @@ class SessionClient {
518
587
  * compatibility path for callers still keyed on account ids.
519
588
  */
520
589
  async activateContext(contextId) {
521
- const res = await this.host.makeRequest('POST', '/session/device/activate', { contextId }, { cache: false });
590
+ const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
522
591
  const activation = (0, contracts_1.safeParseContract)(contracts_1.deviceActivateResponseSchema, res);
523
592
  if (!activation) {
524
593
  logger_1.logger.warn('[SessionClient] discarded invalid activation response');
@@ -544,8 +613,8 @@ class SessionClient {
544
613
  }
545
614
  }
546
615
  async switchAccount(accountId) {
547
- const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
548
- this.applySync(res);
616
+ const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
617
+ await this.applySync(res);
549
618
  this.postCommitPing();
550
619
  }
551
620
  /**
@@ -560,9 +629,11 @@ class SessionClient {
560
629
  * {@link signOutPrincipal} for the two that can tell those apart.
561
630
  */
562
631
  async signOut(target) {
563
- const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
564
- this.applySync(res);
632
+ const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
633
+ await this.applySync(res);
565
634
  this.postCommitPing();
635
+ if (this.state?.accounts.length === 0)
636
+ await this.persistFullSignOut();
566
637
  }
567
638
  /**
568
639
  * Remove ONE `principal → account` pair, and only that pair.
@@ -601,7 +672,7 @@ class SessionClient {
601
672
  * applied this revision.
602
673
  */
603
674
  async removeFromDevice(target) {
604
- const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
675
+ const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
605
676
  const removal = (0, contracts_1.safeParseContract)(contracts_1.deviceDirectorySyncSchema, res);
606
677
  if (!removal) {
607
678
  logger_1.logger.warn('[SessionClient] discarded invalid device removal response');
@@ -609,6 +680,8 @@ class SessionClient {
609
680
  }
610
681
  const directoryApplied = this.applyDirectory(removal.directory);
611
682
  const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
683
+ if (this.fullSignOutPersistence?.state === this.state)
684
+ await this.fullSignOutPersistence.pending;
612
685
  // `commitSync` publishes whenever the flat state moved. When only the
613
686
  // directory did — a socket push already applied this revision — the
614
687
  // directory half would otherwise never reach a subscriber.
@@ -616,10 +689,12 @@ class SessionClient {
616
689
  this.notify();
617
690
  }
618
691
  this.postCommitPing();
692
+ if (this.state?.accounts.length === 0)
693
+ await this.persistFullSignOut();
619
694
  }
620
695
  async addCurrentAccount() {
621
- const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
622
- this.applySync(res);
696
+ const res = await this.requestDevice('POST', '/session/device/add', undefined, { cache: false });
697
+ await this.applySync(res);
623
698
  this.postCommitPing();
624
699
  }
625
700
  /**
@@ -721,6 +796,7 @@ class SessionClient {
721
796
  const deviceCredential = this.host.getDeviceCredential();
722
797
  if (!token && !deviceCredential)
723
798
  return;
799
+ const generation = this.lifecycleGeneration;
724
800
  const socket = io(this.host.getBaseURL(), {
725
801
  transports: ['websocket'],
726
802
  autoConnect: true,
@@ -745,6 +821,8 @@ class SessionClient {
745
821
  socket.on('session_state', (payload) => {
746
822
  // A socket broadcast is a `push`-origin — potentially transient, so an
747
823
  // empty state here must not erase the durable device credential.
824
+ if (generation !== this.lifecycleGeneration)
825
+ return;
748
826
  const applied = this.applyState(payload, 'push');
749
827
  if (!applied)
750
828
  return;
@@ -18,11 +18,19 @@
18
18
  * ESM-safe (no `require()`).
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
21
+ exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
22
+ exports.matchesAuthState = matchesAuthState;
22
23
  exports.createMemoryAuthStateStore = createMemoryAuthStateStore;
23
24
  exports.createWebAuthStateStore = createWebAuthStateStore;
24
25
  exports.createNativeAuthStateStore = createNativeAuthStateStore;
25
26
  const logger_1 = require("../logger");
27
+ function matchesAuthState(actual, expected) {
28
+ if (!actual || !expected)
29
+ return actual === expected;
30
+ return actual.deviceId === expected.deviceId && actual.deviceSecret === expected.deviceSecret &&
31
+ actual.sessionId === expected.sessionId && actual.userId === expected.userId &&
32
+ actual.accessToken === expected.accessToken && actual.expiresAt === expected.expiresAt;
33
+ }
26
34
  /**
27
35
  * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
28
36
  * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) — never the large JWT
@@ -41,6 +49,7 @@ const logger_1 = require("../logger");
41
49
  * so upgrading users are not signed out; the next `save()` splits them apart.
42
50
  */
43
51
  exports.AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
52
+ exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
44
53
  /**
45
54
  * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
46
55
  * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
@@ -204,6 +213,12 @@ function createMemoryAuthStateStore() {
204
213
  current = state;
205
214
  return true;
206
215
  },
216
+ saveIfCurrent: async (state, guard) => {
217
+ if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState))
218
+ return false;
219
+ current = state;
220
+ return true;
221
+ },
207
222
  clear: async () => {
208
223
  current = null;
209
224
  },
@@ -331,8 +346,110 @@ function createNativeAuthStateStore(storage) {
331
346
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
332
347
  // must not silently lose the session for the app's lifetime.
333
348
  let sessionMirror;
349
+ let suppressedMirror;
350
+ let pending = Promise.resolve();
351
+ const serialize = (operation) => {
352
+ const result = pending.then(operation);
353
+ pending = result.then(() => undefined, () => undefined);
354
+ return result;
355
+ };
356
+ const readCurrent = async () => {
357
+ if (sessionMirror !== undefined)
358
+ return sessionMirror;
359
+ const [durable, warm] = await Promise.all([
360
+ storage.getItem(exports.AUTH_STATE_STORAGE_KEY), storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY),
361
+ ]);
362
+ return composeState(durable, warm);
363
+ };
364
+ const persistState = async (state) => {
365
+ sessionMirror = state;
366
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
367
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
368
+ // only reliable proof. The mirror keeps the session live for this app run,
369
+ // but a failed DURABLE write means the mint credential will NOT survive a
370
+ // cold restart — surface it, never swallow.
371
+ let durablePersisted = false;
372
+ try {
373
+ const durableJson = serializeDurable(state);
374
+ await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
375
+ durablePersisted = (await storage.getItem(exports.AUTH_STATE_STORAGE_KEY)) === durableJson;
376
+ if (!durablePersisted) {
377
+ logger_1.logger.error('[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', undefined, { component: 'authStateStore' });
378
+ }
379
+ }
380
+ catch (error) {
381
+ logger_1.logger.error('[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', error, { component: 'authStateStore' });
382
+ }
383
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
384
+ // durable credential re-mints a fresh token) and must never abort or
385
+ // corrupt the durable write above.
386
+ try {
387
+ const warmJson = serializeWarmToken(state);
388
+ if (warmJson) {
389
+ await storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
390
+ }
391
+ else {
392
+ await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
393
+ }
394
+ }
395
+ catch {
396
+ // Locked / oversize keychain — non-fatal warm-boot loss only.
397
+ }
398
+ // Report ONLY the durable-credential landing; the warm-token outcome above
399
+ // is intentionally excluded (it is a best-effort optimization).
400
+ return durablePersisted;
401
+ };
334
402
  return {
335
- load: async () => {
403
+ isAutomaticIdentitySignInSuppressed: () => serialize(async () => {
404
+ if (suppressedMirror !== undefined)
405
+ return suppressedMirror;
406
+ try {
407
+ return (await storage.getItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY)) !== null;
408
+ }
409
+ catch {
410
+ return true; // Unknown storage must not start a new key sign-in.
411
+ }
412
+ }),
413
+ setAutomaticIdentitySignInSuppressed: (suppressed, guard) => serialize(async () => {
414
+ if (guard) {
415
+ let current = sessionMirror;
416
+ if (current === undefined) {
417
+ try {
418
+ const [durable, warm] = await Promise.all([
419
+ storage.getItem(exports.AUTH_STATE_STORAGE_KEY), storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY),
420
+ ]);
421
+ current = composeState(durable, warm);
422
+ }
423
+ catch {
424
+ suppressedMirror = true;
425
+ return false;
426
+ }
427
+ }
428
+ const expected = guard.expectedState;
429
+ if (!guard.isCurrent() || !current ||
430
+ current.deviceId !== expected.deviceId || current.deviceSecret !== expected.deviceSecret ||
431
+ current.sessionId !== expected.sessionId || current.userId !== expected.userId ||
432
+ current.accessToken !== expected.accessToken || current.expiresAt !== expected.expiresAt)
433
+ return false;
434
+ }
435
+ suppressedMirror = suppressed;
436
+ try {
437
+ if (suppressed)
438
+ await storage.setItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, '1');
439
+ else
440
+ await storage.removeItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
441
+ const actual = await storage.getItem(exports.AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
442
+ const verified = suppressed ? actual === '1' : actual === null;
443
+ if (!verified)
444
+ suppressedMirror = true;
445
+ return verified;
446
+ }
447
+ catch {
448
+ suppressedMirror = true;
449
+ return false;
450
+ }
451
+ }),
452
+ load: () => serialize(async () => {
336
453
  if (sessionMirror !== undefined) {
337
454
  return sessionMirror;
338
455
  }
@@ -346,46 +463,20 @@ function createNativeAuthStateStore(storage) {
346
463
  catch {
347
464
  return null;
348
465
  }
349
- },
350
- save: async (state) => {
351
- sessionMirror = state;
352
- // Durable credential FIRST, then VERIFY. On Android SecureStore an
353
- // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
354
- // only reliable proof. The mirror keeps the session live for this app run,
355
- // but a failed DURABLE write means the mint credential will NOT survive a
356
- // cold restart — surface it, never swallow.
357
- let durablePersisted = false;
466
+ }),
467
+ save: (state) => serialize(() => persistState(state)),
468
+ saveIfCurrent: (state, guard) => serialize(async () => {
358
469
  try {
359
- const durableJson = serializeDurable(state);
360
- await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
361
- durablePersisted = (await storage.getItem(exports.AUTH_STATE_STORAGE_KEY)) === durableJson;
362
- if (!durablePersisted) {
363
- logger_1.logger.error('[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', undefined, { component: 'authStateStore' });
364
- }
365
- }
366
- catch (error) {
367
- logger_1.logger.error('[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', error, { component: 'authStateStore' });
368
- }
369
- // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
370
- // durable credential re-mints a fresh token) and must never abort or
371
- // corrupt the durable write above.
372
- try {
373
- const warmJson = serializeWarmToken(state);
374
- if (warmJson) {
375
- await storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
376
- }
377
- else {
378
- await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
379
- }
470
+ const current = await readCurrent();
471
+ if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState))
472
+ return false;
380
473
  }
381
474
  catch {
382
- // Locked / oversize keychain — non-fatal warm-boot loss only.
475
+ return false;
383
476
  }
384
- // Report ONLY the durable-credential landing; the warm-token outcome above
385
- // is intentionally excluded (it is a best-effort optimization).
386
- return durablePersisted;
387
- },
388
- clear: async () => {
477
+ return persistState(state);
478
+ }),
479
+ clear: () => serialize(async () => {
389
480
  sessionMirror = null;
390
481
  try {
391
482
  await storage.removeItem(exports.AUTH_STATE_STORAGE_KEY);
@@ -399,6 +490,6 @@ function createNativeAuthStateStore(storage) {
399
490
  catch {
400
491
  // Non-fatal.
401
492
  }
402
- },
493
+ }),
403
494
  };
404
495
  }