@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
@@ -2,10 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.REMINT_AFTER_EXPIRY_MS = exports.TOKEN_REFRESH_LEAD_MS = void 0;
4
4
  exports.refreshDeviceSecretArm = refreshDeviceSecretArm;
5
+ exports.refreshSharedDeviceArm = refreshSharedDeviceArm;
5
6
  exports.refreshPersistedSession = refreshPersistedSession;
6
7
  exports.createAuthRefreshHandler = createAuthRefreshHandler;
7
8
  exports.installAuthRefreshHandler = installAuthRefreshHandler;
8
9
  exports.startTokenRefreshScheduler = startTokenRefreshScheduler;
10
+ const authStateStore_1 = require("./authStateStore");
9
11
  const identitySession_1 = require("./identitySession");
10
12
  const platform_1 = require("../utils/platform");
11
13
  const errorUtils_1 = require("../utils/errorUtils");
@@ -65,9 +67,13 @@ exports.REMINT_AFTER_EXPIRY_MS = 2000;
65
67
  *
66
68
  * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
67
69
  * the access token; a failed durable persist yields `persist-failed` WITHOUT
68
- * planting. This function performs NO store mutation on failure — the caller
70
+ * planting. Apart from the account-mode logout marker below, failure mutations
71
+ * belong to the caller, which
69
72
  * applies the drop/clear policy (which differs web vs native) from the returned
70
- * status.
73
+ * status. A native account store with a suppression marker records a current
74
+ * `no_active_session`, or an invalid secret with prior authenticated session
75
+ * history, records durable ACCOUNT logout intent; it never deletes that holder.
76
+ * Error classification uses the existing 401 message markers.
71
77
  *
72
78
  * `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
73
79
  * `accountId` (so the server mints that account's token without touching
@@ -78,7 +84,10 @@ exports.REMINT_AFTER_EXPIRY_MS = 2000;
78
84
  async function refreshDeviceSecretArm(deps) {
79
85
  const { oxy, store } = deps;
80
86
  const pin = deps.pin ?? null;
87
+ const isCurrent = deps.isCurrent ?? (() => true);
81
88
  return oxy.http.runSingleFlightDeviceSecretMint(async () => {
89
+ if (!isCurrent())
90
+ return { status: 'session-ended' };
82
91
  const epoch = oxy.http.getSessionEpoch();
83
92
  const persisted = await store.load();
84
93
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
@@ -120,10 +129,29 @@ async function refreshDeviceSecretArm(deps) {
120
129
  // transient and KEEP the credential so a later attempt self-heals. Wiping the
121
130
  // credential on an ambiguous 401 is what logged users out on every deploy,
122
131
  // ecosystem-wide.
123
- if (body.includes('invalid_device_secret'))
124
- return { status: 'invalid-secret' };
125
- if (body.includes('no_active_session'))
126
- return { status: 'no-session' };
132
+ const invalidSecret = body.includes('invalid_device_secret');
133
+ const noSession = body.includes('no_active_session');
134
+ if (invalidSecret || noSession) {
135
+ // A stopped ACCOUNT sibling cannot receive the other app's logout
136
+ // push. Its exact live holder's authoritative empty-device rejection
137
+ // must suppress automatic key recovery on this and future boots.
138
+ // A late rejection for a replaced session/local epoch is not current.
139
+ if (pin === null && store.setAutomaticIdentitySignInSuppressed && (!invalidSecret || (persisted.sessionId && persisted.userId))) {
140
+ const current = await store.load();
141
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch ||
142
+ current?.deviceId !== persisted.deviceId ||
143
+ current.deviceSecret !== persisted.deviceSecret ||
144
+ current.sessionId !== persisted.sessionId ||
145
+ current.userId !== persisted.userId ||
146
+ current.accessToken !== persisted.accessToken)
147
+ return { status: 'session-ended' };
148
+ if (!(await store.setAutomaticIdentitySignInSuppressed(true, {
149
+ expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch,
150
+ })))
151
+ return { status: 'session-ended' };
152
+ }
153
+ return { status: invalidSecret ? 'invalid-secret' : 'no-session' };
154
+ }
127
155
  // A pinned mint whose account left the device set. The secret is intact —
128
156
  // never classify this as a bad secret, or the caller would drop a healthy
129
157
  // credential over a stale identity binding.
@@ -145,24 +173,33 @@ async function refreshDeviceSecretArm(deps) {
145
173
  expiresAt: mint.expiresAt,
146
174
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
147
175
  };
176
+ if (!isCurrent())
177
+ return { status: 'session-ended' };
148
178
  if (oxy.http.getSessionEpoch() !== epoch) {
149
- // Signed out while minting. A store that still holds the presented
150
- // secret takes the server's `nextDeviceSecret` (the token-null lane keeps
151
- // the store so a reload can restore); a store the sign-out cleared must
152
- // NOT be refilled. Either way, plant nothing.
179
+ // A logout or account/session switch invalidated this mint. Preserve
180
+ // only credential rotation for an otherwise unchanged retained context;
181
+ // never transplant the old mint's account, session or bearer. Compare
182
+ // the entire snapshot inside the store queue: same holder bytes alone
183
+ // cannot distinguish an account switch, nor a queued save of that switch.
184
+ const retainedEpoch = oxy.http.getSessionEpoch();
153
185
  const current = await store.load();
154
- if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
155
- await store.save(next);
186
+ if (store.saveIfCurrent && current && (0, authStateStore_1.matchesAuthState)(current, persisted)) {
187
+ await store.saveIfCurrent({ ...current, deviceSecret: mint.nextDeviceSecret }, {
188
+ expectedState: current,
189
+ isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === retainedEpoch,
190
+ });
156
191
  }
157
192
  return { status: 'session-ended' };
158
193
  }
159
194
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
160
195
  // A failed durable persist must NOT plant.
161
- const persistedOk = await store.save(next);
196
+ const persistedOk = store.saveIfCurrent
197
+ ? await store.saveIfCurrent(next, { expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch })
198
+ : await store.save(next);
162
199
  if (!persistedOk) {
163
200
  return { status: 'persist-failed' };
164
201
  }
165
- if (oxy.http.getSessionEpoch() !== epoch) {
202
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) {
166
203
  return { status: 'session-ended' };
167
204
  }
168
205
  oxy.session.setAccessToken(mint.accessToken);
@@ -175,15 +212,76 @@ async function refreshDeviceSecretArm(deps) {
175
212
  };
176
213
  });
177
214
  }
215
+ /**
216
+ * Prove a shared native ACCOUNT holder before replacing local bytes. Callers
217
+ * may replace a local credential only after its explicit mint rejection. The
218
+ * optional guarded storage seam makes custom stores fail closed on adoption.
219
+ * This lane never clears explicit sign-out intent or uses an identity key.
220
+ */
221
+ async function refreshSharedDeviceArm(deps) {
222
+ const { oxy, store, shared } = deps;
223
+ const isCurrent = deps.isCurrent ?? (() => true);
224
+ return oxy.http.runSingleFlightDeviceSecretMint(async () => {
225
+ const epoch = oxy.http.getSessionEpoch();
226
+ if (!isCurrent())
227
+ return { status: 'session-ended' };
228
+ const before = await store.load();
229
+ if (before?.deviceId && before.deviceSecret && !deps.rejectedLocalHolder)
230
+ return { status: 'no-secret' };
231
+ const read = await shared.read();
232
+ if (read.state !== 'present' || !store.saveIfCurrent)
233
+ return { status: 'no-secret' };
234
+ const credential = read.credential;
235
+ if (credential.deviceId === before?.deviceId && credential.deviceSecret === before.deviceSecret)
236
+ return { status: 'no-secret' };
237
+ let mint;
238
+ try {
239
+ mint = await oxy.devices.mintToken(credential.deviceId, credential.deviceSecret);
240
+ }
241
+ catch (error) {
242
+ if ((0, errorUtils_1.extractErrorStatus)(error) === 429)
243
+ oxy.http.noteRefreshRateLimited();
244
+ if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
245
+ const message = error?.message;
246
+ if (typeof message === 'string' && message.includes('invalid_device_secret')) {
247
+ // There is no cross-app CAS clear in the bridge contract. An old
248
+ // rejection cannot delete a slot another app has since published.
249
+ return { status: 'invalid-secret' };
250
+ }
251
+ if (typeof message === 'string' && message.includes('no_active_session'))
252
+ return { status: 'no-session' };
253
+ }
254
+ return { status: 'transient' };
255
+ }
256
+ const bound = mint.state.accounts.find(account => account.accountId === mint.state.activeAccountId);
257
+ if (!bound || mint.state.deviceId !== credential.deviceId || !mint.accessToken || !mint.nextDeviceSecret)
258
+ return { status: 'transient' };
259
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch || !(0, authStateStore_1.matchesAuthState)(await store.load(), before))
260
+ return { status: 'session-ended' };
261
+ const next = {
262
+ deviceId: mint.state.deviceId, deviceSecret: mint.nextDeviceSecret,
263
+ sessionId: bound.sessionId, userId: bound.accountId,
264
+ accessToken: mint.accessToken, expiresAt: mint.expiresAt,
265
+ };
266
+ if (!(await store.saveIfCurrent(next, { expectedState: before, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch })))
267
+ return { status: 'persist-failed' };
268
+ if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch)
269
+ return { status: 'session-ended' };
270
+ oxy.session.setAccessToken(mint.accessToken);
271
+ return { status: 'ok', token: mint.accessToken, sessionId: bound.sessionId, userId: bound.accountId, state: mint.state };
272
+ });
273
+ }
178
274
  /**
179
275
  * Re-mint the persisted session and return the fresh access token, or `null`
180
276
  * when no arm could produce one.
181
277
  *
182
278
  * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
183
- * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
184
- * or the device has no live session: drop the secret so the mint lane stops (or
185
- * clear the store on web, where there is no fallback), then fall to arm 2 on
186
- * native. A transient error — or a durable-persist failure — leaves the store and
279
+ * from the persisted `deviceId` + `deviceSecret`. An authenticated native
280
+ * holder's invalid-secret or `no_active_session` verdict suppresses key recovery
281
+ * while permitting a separately proved new shared holder. The latter verdict
282
+ * retains that holder and ends ACCOUNT recovery without a new key challenge.
283
+ * Classification still uses the existing 401 message markers, not a new parser.
284
+ * A transient error — or a durable-persist failure — leaves the store and
187
285
  * returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
188
286
  *
189
287
  * Arm 2 (native Commons identity): when the secret is absent or was just
@@ -200,9 +298,11 @@ async function refreshDeviceSecretArm(deps) {
200
298
  */
201
299
  async function refreshPersistedSession(deps) {
202
300
  const { oxy, store } = deps;
301
+ if (deps.isCurrent && !deps.isCurrent())
302
+ return null;
203
303
  const identity = deps.identity ?? null;
204
304
  // The shared identity is never an identity-bound client's recovery path.
205
- const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? (0, platform_1.isNative)());
305
+ const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? (0, platform_1.isNative)()) && !(await store.isAutomaticIdentitySignInSuppressed?.());
206
306
  const epoch = oxy.http.getSessionEpoch();
207
307
  // Resolved per call: a re-established identity session can move the pin, and a
208
308
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
@@ -211,7 +311,7 @@ async function refreshPersistedSession(deps) {
211
311
  if (identity && !pin) {
212
312
  return recoverIdentitySession(oxy, store, identity);
213
313
  }
214
- const arm1 = await refreshDeviceSecretArm({ oxy, store, pin });
314
+ const arm1 = await refreshDeviceSecretArm({ oxy, store, pin, isCurrent: deps.isCurrent });
215
315
  switch (arm1.status) {
216
316
  case 'ok':
217
317
  return arm1.token;
@@ -229,6 +329,28 @@ async function refreshPersistedSession(deps) {
229
329
  return null;
230
330
  case 'invalid-secret':
231
331
  case 'no-session': {
332
+ if (!identity && deps.sharedDeviceCredential) {
333
+ const joined = await refreshSharedDeviceArm({ oxy, store, shared: deps.sharedDeviceCredential, rejectedLocalHolder: true, isCurrent: deps.isCurrent });
334
+ if (joined.status === 'ok')
335
+ return joined.token;
336
+ if (joined.status === 'session-ended' || joined.status === 'persist-failed')
337
+ return null;
338
+ }
339
+ if (!identity && arm1.status === 'invalid-secret' && await store.isAutomaticIdentitySignInSuppressed?.())
340
+ return null;
341
+ if (arm1.status === 'no-session' && !identity) {
342
+ if (store.setAutomaticIdentitySignInSuppressed)
343
+ return null;
344
+ // The holder is still valid, but its account session ended. Retain only
345
+ // the holder for a later deliberate/shared sign-in; never use the key
346
+ // to turn this signed-out verdict into a new account session.
347
+ const persisted = await store.load();
348
+ if (persisted?.deviceId && persisted.deviceSecret) {
349
+ await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
350
+ }
351
+ return null;
352
+ }
353
+ // Identity mode retains its separate pinned-key recovery contract.
232
354
  // 401: secret diverged or no live session. When a key-based arm 2 can still
233
355
  // recover (the native Commons identity, or an identity-bound client's own
234
356
  // primary key) drop ONLY the secret and keep the deviceId; otherwise (web) the
@@ -239,13 +361,6 @@ async function refreshPersistedSession(deps) {
239
361
  await store.save({ ...persisted, deviceSecret: undefined });
240
362
  }
241
363
  }
242
- else if (arm1.status === 'no-session' && persisted?.deviceId && persisted.deviceSecret) {
243
- // Web, a credential the server still recognises: this origin stays a
244
- // holder of the browser's device (ADR 0029 D2) — the next sign-in in any
245
- // app lands on it, and this app follows without opening the bridge
246
- // again. Only the session fields go.
247
- await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
248
- }
249
364
  else {
250
365
  await store.clear();
251
366
  }
@@ -257,6 +372,13 @@ async function refreshPersistedSession(deps) {
257
372
  logger_1.logger.debug('Pinned device-secret mint rejected: the pinned account is no longer on this device — re-establishing from the identity key', { component: 'refresh', method: 'refreshPersistedSession' });
258
373
  break;
259
374
  case 'no-secret':
375
+ if (!identity && deps.sharedDeviceCredential) {
376
+ const joined = await refreshSharedDeviceArm({ oxy, store, shared: deps.sharedDeviceCredential, isCurrent: deps.isCurrent });
377
+ if (joined.status === 'ok')
378
+ return joined.token;
379
+ if (joined.status === 'session-ended' || joined.status === 'persist-failed' || joined.status === 'transient')
380
+ return null;
381
+ }
260
382
  break;
261
383
  }
262
384
  if (identity) {
@@ -264,14 +386,14 @@ async function refreshPersistedSession(deps) {
264
386
  }
265
387
  // Never after a sign-out: the shared identity is a KEY, not a session, and
266
388
  // proving it here would sign the user straight back in.
267
- if (allowCommonsIdentityFallback && !oxy.http.hasSessionEnded()) {
389
+ if (allowCommonsIdentityFallback && (deps.isCurrent?.() ?? true) && !oxy.http.hasSessionEnded() && !(await store.isAutomaticIdentitySignInSuppressed?.())) {
268
390
  try {
269
391
  // Planted here, not by the sign-in: a sign-out that lands while the
270
392
  // challenge round-trips must win, or the identity proof signs the user
271
393
  // straight back in.
272
394
  const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false });
273
395
  if (session?.accessToken) {
274
- if (oxy.http.getSessionEpoch() !== epoch) {
396
+ if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
275
397
  return null;
276
398
  }
277
399
  // Repopulate the fast device-secret lane from the identity re-mint.
@@ -285,7 +407,7 @@ async function refreshPersistedSession(deps) {
285
407
  expiresAt: session.expiresAt,
286
408
  });
287
409
  }
288
- if (oxy.http.getSessionEpoch() !== epoch) {
410
+ if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
289
411
  return null;
290
412
  }
291
413
  oxy.session.setAccessToken(session.accessToken);
@@ -201,7 +201,9 @@ async function publishProvenDeviceCredential(deps) {
201
201
  * seam, no lane left out, and no new call site to forget.
202
202
  * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
203
203
  * a storage primitive, and would run on every `load()`. Adoption is an
204
- * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
204
+ * explicit cold-boot/resume recovery instead; an existing local holder must
205
+ * first be rejected by the server, and a new shared holder must mint before
206
+ * conditional persistence.
205
207
  *
206
208
  * `clear()` deliberately does NOT clear the shared slot. This app signing out is
207
209
  * not authority over the device-wide join point: other apps may still be signed
@@ -216,8 +218,22 @@ function createSharedMirroringAuthStateStore(deps) {
216
218
  // next launch rather than mid-process.
217
219
  let mirrored = null;
218
220
  return {
221
+ ...local,
219
222
  load: () => local.load(),
220
223
  clear: () => local.clear(),
224
+ ...(local.saveIfCurrent ? { saveIfCurrent: async (state, guard) => {
225
+ const committed = await local.saveIfCurrent?.(state, guard);
226
+ if (!committed)
227
+ return false;
228
+ const credential = readLocalDeviceCredential(state);
229
+ if (credential && guard.isCurrent()) {
230
+ try {
231
+ await publishProvenDeviceCredential({ shared, credential });
232
+ }
233
+ catch { /* Local durability already verified. */ }
234
+ }
235
+ return true;
236
+ } } : {}),
221
237
  save: async (state) => {
222
238
  // The durable local write is the contract this store owes its caller —
223
239
  // run it first and report ITS result, unchanged. The mirror is additive.
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isAllowedDeviceJoinOrigin = exports.isLoopbackOrigin = void 0;
4
+ exports.isOfficialWebOrigin = isOfficialWebOrigin;
5
+ const contracts_1 = require("@oxy.so/contracts");
6
+ var contracts_2 = require("@oxy.so/contracts");
7
+ Object.defineProperty(exports, "isLoopbackOrigin", { enumerable: true, get: function () { return contracts_2.isLoopbackOrigin; } });
2
8
  /**
3
9
  * Official first-party web origin allowlist — shared by OAuth redirect
4
10
  * validation and the server-side trusted-origin checks.
5
11
  */
6
- Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.isAllowedDeviceJoinOrigin = void 0;
8
- exports.isLoopbackOrigin = isLoopbackOrigin;
9
- exports.isOfficialWebOrigin = isOfficialWebOrigin;
10
12
  const authWebUrl_1 = require("./authWebUrl");
11
13
  const registrableApex_1 = require("./registrableApex");
12
14
  /** Official first-party registrable apexes (mirrors API trusted origins). */
@@ -26,22 +28,9 @@ const OFFICIAL_APEXES = new Set([
26
28
  * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
27
29
  * or `[::1]` on any port, http or https).
28
30
  */
29
- function isLoopbackOrigin(origin) {
30
- try {
31
- const parsed = new URL(origin);
32
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
33
- return false;
34
- }
35
- const host = parsed.hostname.toLowerCase();
36
- return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
37
- }
38
- catch {
39
- return false;
40
- }
41
- }
42
31
  /** Whether an origin belongs to the official Oxy web ecosystem. */
43
32
  function isOfficialWebOrigin(origin) {
44
- if (isLoopbackOrigin(origin)) {
33
+ if ((0, contracts_1.isLoopbackOrigin)(origin)) {
45
34
  return true;
46
35
  }
47
36
  try {
@@ -15,7 +15,7 @@
15
15
  import { TTLCache, registerCacheForCleanup, unregisterCacheFromCleanup } from './utils/cache.js';
16
16
  import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils.js';
17
17
  import { retryAsync } from './utils/asyncUtils.js';
18
- import { createCancelledError, ErrorCodes, handleHttpError, parseHttpErrorBody } from './utils/errorUtils.js';
18
+ import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils.js';
19
19
  import { decodeTokenClaims } from './utils/tokenClaims.js';
20
20
  import { isReactNative } from '@oxy.so/protocol/random';
21
21
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
@@ -24,6 +24,35 @@ import { OxyAuthenticationError } from './OxyServices.errors.js';
24
24
  import { peekBrowserEdgeRegionHeader } from './utils/edgeRegion.js';
25
25
  import { getBrowserActivityIdHeader } from './utils/activityId.js';
26
26
  import { trimLeadingSlashes, trimTrailingSlashes } from './utils/slashes.js';
27
+ /** Comparison only fences local async work; it never validates token authority. */
28
+ function sameTokenSession(left, right) {
29
+ if (left === right)
30
+ return true;
31
+ if (!left || !right)
32
+ return false;
33
+ const a = decodeTokenClaims(left);
34
+ const b = decodeTokenClaims(right);
35
+ if (!a || !b || !(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub))
36
+ return false;
37
+ // A refresh may rotate exp/iat/jti while preserving its account and session.
38
+ // Opaque/malformed context fields cannot establish that equivalence.
39
+ return ['userId', 'id', 'sub', 'sessionId', 'operatorId', 'clientId', 'appId', 'authMethodId'].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
40
+ }
41
+ /** Cancel this wait only; the shared mint continues for other callers. */
42
+ function awaitRequestSignal(pending, signal) {
43
+ if (!signal)
44
+ return pending;
45
+ return new Promise((resolve, reject) => {
46
+ const onAbort = () => { reject(createCancelledError()); };
47
+ if (signal.aborted)
48
+ onAbort();
49
+ else
50
+ signal.addEventListener('abort', onAbort, { once: true });
51
+ // Both handlers remain attached even after cancellation, so a late shared
52
+ // failure is observed without reviving this request or becoming unhandled.
53
+ pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
54
+ });
55
+ }
27
56
  /** Methods whose calls concurrent callers may share (read-only). */
28
57
  const SAFE_METHODS = new Set(['GET']);
29
58
  /** Methods safe to re-send after a transient failure (RFC 9110 §9.2.2). */
@@ -302,8 +331,7 @@ export class HttpService {
302
331
  const currentToken = this.tokenStore.getAccessToken();
303
332
  if (providedToken) {
304
333
  if (providedToken !== currentToken) {
305
- this.tokenStore.setTokens(providedToken);
306
- this.notifyTokenChange();
334
+ this.setTokens(providedToken);
307
335
  }
308
336
  return providedToken;
309
337
  }
@@ -1011,8 +1039,12 @@ export class HttpService {
1011
1039
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1012
1040
  .then((newToken) => {
1013
1041
  if (epoch !== this.sessionEpoch) {
1014
- // The session ended while this re-mint was in flight. Not a failure
1015
- // (no cooldown), and nothing to plant.
1042
+ // A guarded refresh handler can itself adopt a new active context.
1043
+ // Acknowledge only an already-installed, exact result; never plant
1044
+ // a stale return value over a concurrent switch/logout.
1045
+ if (newToken && !this.sessionEnded && this.tokenStore.getAccessToken() === newToken) {
1046
+ return newToken;
1047
+ }
1016
1048
  this.logger.debug('Discarded a token refresh that outlived its session');
1017
1049
  return null;
1018
1050
  }
@@ -1023,8 +1055,7 @@ export class HttpService {
1023
1055
  // A token is planted again, so there is a session again.
1024
1056
  this.sessionEnded = false;
1025
1057
  if (this.tokenStore.getAccessToken() !== newToken) {
1026
- this.tokenStore.setTokens(newToken);
1027
- this.notifyTokenChange();
1058
+ this.setTokens(newToken);
1028
1059
  }
1029
1060
  this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1030
1061
  // A success clears the failure timestamp so the next refresh is never
@@ -1180,23 +1211,61 @@ export class HttpService {
1180
1211
  * abort signal defines the lifetime of a healthy stream.
1181
1212
  */
1182
1213
  async requestAuthenticatedResponse(config) {
1183
- const authHeader = await this.getAuthHeader();
1184
- if (!authHeader) {
1214
+ return this.requestRawResponse(config, true);
1215
+ }
1216
+ /**
1217
+ * Raw response for public or authenticated app endpoints. The SDK supplies
1218
+ * its current bearer when available; caller headers never supply authority.
1219
+ * No caching, envelope transformation or transport-error retry is performed.
1220
+ */
1221
+ async requestResponse(config) {
1222
+ return this.requestRawResponse(config, false);
1223
+ }
1224
+ async requestRawResponse(config, requireAuth) {
1225
+ // Validate before obtaining a bearer: an unrelated origin must not trigger
1226
+ // an auth mint, even when the trusted transport accepts absolute URLs.
1227
+ const target = new URL(this.buildURL(config.url));
1228
+ const base = new URL(this.baseURL);
1229
+ if (target.origin !== base.origin || !['http:', 'https:'].includes(target.protocol)
1230
+ || target.username || target.password) {
1231
+ throw new Error('Raw response requests must target the configured API origin');
1232
+ }
1233
+ if (config.signal?.aborted)
1234
+ throw createCancelledError();
1235
+ // Snapshot caller-owned options once, including headers, before any await.
1236
+ const request = { ...config, headers: new Headers(config.headers) };
1237
+ const contextToken = this.syncAccessTokenFromProvider();
1238
+ const beforeAuthEpoch = this.sessionEpoch;
1239
+ const authHeader = await awaitRequestSignal(this.getAuthHeader(), config.signal);
1240
+ if (beforeAuthEpoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
1241
+ throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1242
+ }
1243
+ if (config.signal?.aborted)
1244
+ throw createCancelledError();
1245
+ if (requireAuth && !authHeader) {
1185
1246
  throw new OxyAuthenticationError('An active Oxy session is required for this request', 'AUTH_REQUIRED');
1186
1247
  }
1187
- return this.requestAuthenticatedResponseAttempt(config, authHeader, false);
1248
+ return this.requestResponseAttempt(request, target.href, authHeader, false, this.sessionEpoch, contextToken);
1188
1249
  }
1189
- async requestAuthenticatedResponseAttempt(config, authHeader, isAuthRetry) {
1250
+ async requestResponseAttempt(config, fullUrl, authHeader, isAuthRetry, epoch, contextToken) {
1251
+ const assertCurrent = () => {
1252
+ this.syncAccessTokenFromProvider();
1253
+ if (epoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
1254
+ throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1255
+ }
1256
+ };
1190
1257
  const startTime = Date.now();
1258
+ if (config.signal?.aborted)
1259
+ throw createCancelledError();
1260
+ assertCurrent();
1191
1261
  const headers = new Headers(config.headers);
1192
- if (!headers.has('Accept')) {
1262
+ if (!headers.has('Accept'))
1193
1263
  headers.set('Accept', 'application/json');
1194
- }
1195
- // Authentication is owned by this SDK instance. A caller cannot replace
1196
- // the bearer with a different session or leak one across linked apps.
1197
- headers.set('Authorization', authHeader);
1198
- // Never awaited: the PoP is a hint, and waiting on its trace fetch put up
1199
- // to a second in front of requests.
1264
+ // Never accept ambient credentials or another authority from a caller.
1265
+ for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization'])
1266
+ headers.delete(name);
1267
+ if (authHeader)
1268
+ headers.set('Authorization', authHeader);
1200
1269
  const edgeRegionHeader = peekBrowserEdgeRegionHeader();
1201
1270
  for (const [name, value] of Object.entries(edgeRegionHeader))
1202
1271
  headers.set(name, value);
@@ -1204,22 +1273,51 @@ export class HttpService {
1204
1273
  for (const [name, value] of Object.entries(activityIdHeader))
1205
1274
  headers.set(name, value);
1206
1275
  try {
1207
- const fullUrl = this.buildURL(config.url);
1208
- const response = await fetch(fullUrl, {
1276
+ const body = config.method === 'GET' ? undefined : config.body;
1277
+ const init = {
1209
1278
  method: config.method,
1210
1279
  headers,
1211
- body: config.method === 'GET' ? undefined : config.body,
1280
+ body,
1212
1281
  signal: config.signal,
1213
- credentials: this.getCredentialsMode(fullUrl),
1214
- });
1215
- if (response.status === 401 && !isAuthRetry) {
1216
- const refreshed = await this.refreshAccessToken('response-401');
1282
+ credentials: 'omit',
1283
+ redirect: 'error',
1284
+ };
1285
+ // Node fetch requires this flag for streaming uploads; ordinary and
1286
+ // multipart bodies keep the platform's existing body serialization.
1287
+ if (body && typeof body === 'object' && 'getReader' in body)
1288
+ init.duplex = 'half';
1289
+ const response = await (config.fetch ?? globalThis.fetch)(fullUrl, init);
1290
+ try {
1291
+ assertCurrent();
1292
+ }
1293
+ catch (error) {
1294
+ await response.body?.cancel();
1295
+ throw error;
1296
+ }
1297
+ const replayable = body === undefined || typeof body === 'string';
1298
+ if (response.status === 401 && !isAuthRetry && authHeader && replayable && !config.signal?.aborted) {
1299
+ let refreshed;
1300
+ try {
1301
+ refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), config.signal);
1302
+ }
1303
+ catch (error) {
1304
+ void response.body?.cancel().catch(() => undefined);
1305
+ throw error;
1306
+ }
1307
+ try {
1308
+ assertCurrent();
1309
+ }
1310
+ catch (error) {
1311
+ await response.body?.cancel();
1312
+ throw error;
1313
+ }
1217
1314
  if (refreshed) {
1218
1315
  await response.body?.cancel();
1219
- return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
1316
+ if (config.signal?.aborted)
1317
+ throw createCancelledError();
1318
+ return this.requestResponseAttempt(config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken);
1220
1319
  }
1221
- this.tokenStore.clearTokens();
1222
- this.notifyTokenChange();
1320
+ this.clearTokens();
1223
1321
  }
1224
1322
  const duration = Date.now() - startTime;
1225
1323
  this.updateMetrics(response.ok, duration);
@@ -1230,8 +1328,12 @@ export class HttpService {
1230
1328
  const duration = Date.now() - startTime;
1231
1329
  this.updateMetrics(false, duration);
1232
1330
  this.config.onRequestEnd?.(config.url, config.method, duration, false);
1233
- const normalizedError = error instanceof Error ? error : new Error(String(error));
1331
+ const normalizedError = config.signal?.aborted || isCancelledError(error)
1332
+ ? createCancelledError()
1333
+ : error instanceof Error ? error : new Error(String(error));
1234
1334
  this.config.onRequestError?.(config.url, config.method, normalizedError);
1335
+ if (isCancelledError(normalizedError) || normalizedError instanceof OxyAuthenticationError)
1336
+ throw normalizedError;
1235
1337
  throw handleHttpError(normalizedError);
1236
1338
  }
1237
1339
  }
@@ -1253,6 +1355,9 @@ export class HttpService {
1253
1355
  }
1254
1356
  // Token management
1255
1357
  setTokens(accessToken) {
1358
+ const current = this.tokenStore.getAccessToken();
1359
+ if (current && !sameTokenSession(current, accessToken))
1360
+ this.sessionEpoch += 1;
1256
1361
  this.sessionEnded = false;
1257
1362
  this.tokenStore.setTokens(accessToken);
1258
1363
  this.notifyTokenChange();
@@ -1261,17 +1366,19 @@ export class HttpService {
1261
1366
  * End the local session: clear the bearer and abandon every re-mint already
1262
1367
  * in flight, so none of them can plant a token after the sign-out.
1263
1368
  *
1264
- * {@link clearTokens} alone only drops the bearer, which is right for a
1265
- * mirror (a linked client following its parent) but not for a sign-out: a
1266
- * refresh started a moment earlier would finish and plant a new one.
1369
+ * Unlike {@link clearTokens}, this also marks the session as ended and
1370
+ * invalidates pending re-mints even when no bearer is currently present.
1267
1371
  */
1268
1372
  endSession() {
1269
- this.sessionEpoch += 1;
1373
+ if (!this.tokenStore.getAccessToken())
1374
+ this.sessionEpoch += 1;
1270
1375
  this.sessionEnded = true;
1271
1376
  this.clearTokens();
1272
1377
  }
1273
1378
  /**
1274
- * The current session epoch — see {@link endSession}. A re-mint lane that
1379
+ * The current context epoch: account/session switches and bearer removal
1380
+ * invalidate pending work; rotation within the same context preserves it.
1381
+ * A re-mint lane that
1275
1382
  * plants tokens itself (the device-secret arm) compares it across its awaits.
1276
1383
  */
1277
1384
  getSessionEpoch() {
@@ -1298,6 +1405,8 @@ export class HttpService {
1298
1405
  this.anonymousAuthProvider = provider;
1299
1406
  }
1300
1407
  clearTokens() {
1408
+ if (this.tokenStore.getAccessToken())
1409
+ this.sessionEpoch += 1;
1301
1410
  this.tokenStore.clearTokens();
1302
1411
  // Drop the response cache on logout. The cache is identity-scoped, so a
1303
1412
  // different user could never read these entries, but a logged-out client