@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
@@ -25,7 +25,7 @@ export type { AccountDelegationAccess, AccountDelegationNode, } from './accountD
25
25
  export { AccountDialogController, createAccountDialogController, } from './accountDialogController';
26
26
  export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, ContextChoiceOutcome, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './accountDialogController';
27
27
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './authStateStore';
28
- export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './authStateStore';
28
+ export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
29
29
  export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
30
30
  export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
31
31
  export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
@@ -24,7 +24,8 @@
24
24
  import type { DeviceSessionState } from '@oxy.so/contracts';
25
25
  import type { OxyServices } from '../OxyServices';
26
26
  import type { AuthRefreshHandler } from '../HttpService';
27
- import type { AuthStateStore } from './authStateStore';
27
+ import { type AuthStateStore } from './authStateStore';
28
+ import type { SharedDeviceCredentialStore } from './sharedDeviceCredential';
28
29
  import type { IdentityPin } from './identityPin';
29
30
  import { type IdentityBinding } from './identitySession';
30
31
  /**
@@ -68,6 +69,10 @@ export interface RefreshDeps {
68
69
  * Commons-identity one.
69
70
  */
70
71
  identity?: IdentityBinding;
72
+ /** Native ACCOUNT join point. A live local holder always wins. */
73
+ sharedDeviceCredential?: SharedDeviceCredentialStore;
74
+ /** A replaced/unmounted provider cannot commit a pending refresh. */
75
+ isCurrent?: () => boolean;
71
76
  }
72
77
  /**
73
78
  * The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
@@ -127,9 +132,13 @@ export type DeviceSecretMintOutcome = {
127
132
  *
128
133
  * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
129
134
  * the access token; a failed durable persist yields `persist-failed` WITHOUT
130
- * planting. This function performs NO store mutation on failure — the caller
135
+ * planting. Apart from the account-mode logout marker below, failure mutations
136
+ * belong to the caller, which
131
137
  * applies the drop/clear policy (which differs web vs native) from the returned
132
- * status.
138
+ * status. A native account store with a suppression marker records a current
139
+ * `no_active_session`, or an invalid secret with prior authenticated session
140
+ * history, records durable ACCOUNT logout intent; it never deletes that holder.
141
+ * Error classification uses the existing 401 message markers.
133
142
  *
134
143
  * `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
135
144
  * `accountId` (so the server mints that account's token without touching
@@ -142,16 +151,32 @@ export declare function refreshDeviceSecretArm(deps: {
142
151
  store: AuthStateStore;
143
152
  /** The identity pin, when this client is identity-bound. */
144
153
  pin?: IdentityPin | null;
154
+ isCurrent?: () => boolean;
155
+ }): Promise<DeviceSecretMintOutcome>;
156
+ /**
157
+ * Prove a shared native ACCOUNT holder before replacing local bytes. Callers
158
+ * may replace a local credential only after its explicit mint rejection. The
159
+ * optional guarded storage seam makes custom stores fail closed on adoption.
160
+ * This lane never clears explicit sign-out intent or uses an identity key.
161
+ */
162
+ export declare function refreshSharedDeviceArm(deps: {
163
+ oxy: OxyServices;
164
+ store: AuthStateStore;
165
+ shared: SharedDeviceCredentialStore;
166
+ rejectedLocalHolder?: boolean;
167
+ isCurrent?: () => boolean;
145
168
  }): Promise<DeviceSecretMintOutcome>;
146
169
  /**
147
170
  * Re-mint the persisted session and return the fresh access token, or `null`
148
171
  * when no arm could produce one.
149
172
  *
150
173
  * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
151
- * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
152
- * or the device has no live session: drop the secret so the mint lane stops (or
153
- * clear the store on web, where there is no fallback), then fall to arm 2 on
154
- * native. A transient error — or a durable-persist failure — leaves the store and
174
+ * from the persisted `deviceId` + `deviceSecret`. An authenticated native
175
+ * holder's invalid-secret or `no_active_session` verdict suppresses key recovery
176
+ * while permitting a separately proved new shared holder. The latter verdict
177
+ * retains that holder and ends ACCOUNT recovery without a new key challenge.
178
+ * Classification still uses the existing 401 message markers, not a new parser.
179
+ * A transient error — or a durable-persist failure — leaves the store and
155
180
  * returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
156
181
  *
157
182
  * Arm 2 (native Commons identity): when the secret is absent or was just
@@ -189,7 +189,9 @@ export declare function publishProvenDeviceCredential(deps: {
189
189
  * seam, no lane left out, and no new call site to forget.
190
190
  * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
191
191
  * a storage primitive, and would run on every `load()`. Adoption is an
192
- * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
192
+ * explicit cold-boot/resume recovery instead; an existing local holder must
193
+ * first be rejected by the server, and a new shared holder must mint before
194
+ * conditional persistence.
193
195
  *
194
196
  * `clear()` deliberately does NOT clear the shared slot. This app signing out is
195
197
  * not authority over the device-wide join point: other apps may still be signed
@@ -1,12 +1,8 @@
1
- /**
2
- * Official first-party web origin allowlist — shared by OAuth redirect
3
- * validation and the server-side trusted-origin checks.
4
- */
1
+ export { isLoopbackOrigin } from '@oxy.so/contracts';
5
2
  /**
6
3
  * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
7
4
  * or `[::1]` on any port, http or https).
8
5
  */
9
- export declare function isLoopbackOrigin(origin: string): boolean;
10
6
  /** Whether an origin belongs to the official Oxy web ecosystem. */
11
7
  export declare function isOfficialWebOrigin(origin: string): boolean;
12
8
  /** @deprecated Use {@link isOfficialWebOrigin}. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -179,8 +179,8 @@
179
179
  "@oxy.so/telemetry": "^1.2.0",
180
180
  "@noble/ciphers": "^1.3.0",
181
181
  "@noble/hashes": "^1.8.0",
182
- "@oxy.so/contracts": "^4.7.0",
183
- "@oxy.so/protocol": "^1.2.1",
182
+ "@oxy.so/contracts": "^4.9.1",
183
+ "@oxy.so/protocol": "^1.2.2",
184
184
  "@scure/bip39": "^1.6.0",
185
185
  "buffer": "^6.0.3",
186
186
  "express-rate-limit": "^8.6.0",
@@ -192,9 +192,9 @@
192
192
  "zod": "^3.25.64"
193
193
  },
194
194
  "peerDependencies": {
195
- "@react-native-async-storage/async-storage": "*",
196
- "expo-crypto": "*",
197
- "expo-secure-store": "*",
195
+ "@react-native-async-storage/async-storage": "<3.0.0",
196
+ "expo-crypto": "<58.0.0",
197
+ "expo-secure-store": "<58.0.0",
198
198
  "express": "^4.0.0"
199
199
  },
200
200
  "peerDependenciesMeta": {
@@ -37,20 +37,54 @@ export type AccessTokenProvider = () => string | null;
37
37
  */
38
38
  export type AnonymousAuthProvider = () => Promise<string | null>;
39
39
 
40
+ /** Comparison only fences local async work; it never validates token authority. */
41
+ function sameTokenSession(left: string | null, right: string | null): boolean {
42
+ if (left === right) return true;
43
+ if (!left || !right) return false;
44
+ const a = decodeTokenClaims(left);
45
+ const b = decodeTokenClaims(right);
46
+ if (!a || !b || !(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub)) return false;
47
+ // A refresh may rotate exp/iat/jti while preserving its account and session.
48
+ // Opaque/malformed context fields cannot establish that equivalence.
49
+ return ['userId', 'id', 'sub', 'sessionId', 'operatorId', 'clientId', 'appId', 'authMethodId'].every((key) =>
50
+ (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
51
+ }
52
+
53
+ /** Cancel this wait only; the shared mint continues for other callers. */
54
+ function awaitRequestSignal<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T> {
55
+ if (!signal) return pending;
56
+ return new Promise<T>((resolve, reject) => {
57
+ const onAbort = (): void => { reject(createCancelledError()); };
58
+ if (signal.aborted) onAbort();
59
+ else signal.addEventListener('abort', onAbort, { once: true });
60
+ // Both handlers remain attached even after cancellation, so a late shared
61
+ // failure is observed without reviving this request or becoming unhandled.
62
+ pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
63
+ });
64
+ }
65
+
66
+ /** A trusted platform transport, for example Expo's streaming fetch. */
67
+ export type ResponseTransport = (input: string, init: RequestInit) => Promise<Response>;
68
+
40
69
  /**
41
- * A low-level authenticated request whose response body remains unread.
42
- *
43
- * This is intended for streaming protocols such as SSE. The body must already
44
- * be serialised so it can be replayed once after an access-token refresh.
70
+ * A low-level same-origin request whose response body remains unread.
71
+ * Only absent/string request bodies can be replayed after a 401. Multipart,
72
+ * binary and stream bodies are sent once, even if their platform can replay them.
73
+ * The caller owns response parsing and the AbortSignal for its full lifetime.
45
74
  */
46
- export interface AuthenticatedResponseRequest {
75
+ export interface ResponseRequest {
47
76
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
48
77
  url: string;
49
- body?: string;
50
- headers?: Record<string, string>;
78
+ body?: BodyInit;
79
+ headers?: HeadersInit;
51
80
  signal?: AbortSignal;
81
+ /** Must honour the supplied URL, headers, signal and redirect policy. */
82
+ fetch?: ResponseTransport;
52
83
  }
53
84
 
85
+ /** Same response contract, but rejects locally when the SDK has no bearer. */
86
+ export type AuthenticatedResponseRequest = ResponseRequest;
87
+
54
88
  /**
55
89
  * Structural type that captures the multipart-write surface every supported
56
90
  * FormData implementation exposes (browser, React Native, Node `form-data`
@@ -464,8 +498,7 @@ export class HttpService {
464
498
 
465
499
  if (providedToken) {
466
500
  if (providedToken !== currentToken) {
467
- this.tokenStore.setTokens(providedToken);
468
- this.notifyTokenChange();
501
+ this.setTokens(providedToken);
469
502
  }
470
503
  return providedToken;
471
504
  }
@@ -1282,8 +1315,12 @@ export class HttpService {
1282
1315
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1283
1316
  .then((newToken) => {
1284
1317
  if (epoch !== this.sessionEpoch) {
1285
- // The session ended while this re-mint was in flight. Not a failure
1286
- // (no cooldown), and nothing to plant.
1318
+ // A guarded refresh handler can itself adopt a new active context.
1319
+ // Acknowledge only an already-installed, exact result; never plant
1320
+ // a stale return value over a concurrent switch/logout.
1321
+ if (newToken && !this.sessionEnded && this.tokenStore.getAccessToken() === newToken) {
1322
+ return newToken;
1323
+ }
1287
1324
  this.logger.debug('Discarded a token refresh that outlived its session');
1288
1325
  return null;
1289
1326
  }
@@ -1294,8 +1331,7 @@ export class HttpService {
1294
1331
  // A token is planted again, so there is a session again.
1295
1332
  this.sessionEnded = false;
1296
1333
  if (this.tokenStore.getAccessToken() !== newToken) {
1297
- this.tokenStore.setTokens(newToken);
1298
- this.notifyTokenChange();
1334
+ this.setTokens(newToken);
1299
1335
  }
1300
1336
  this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1301
1337
  // A success clears the failure timestamp so the next refresh is never
@@ -1463,56 +1499,101 @@ export class HttpService {
1463
1499
  * abort signal defines the lifetime of a healthy stream.
1464
1500
  */
1465
1501
  async requestAuthenticatedResponse(config: AuthenticatedResponseRequest): Promise<Response> {
1466
- const authHeader = await this.getAuthHeader();
1467
- if (!authHeader) {
1468
- throw new OxyAuthenticationError(
1469
- 'An active Oxy session is required for this request',
1470
- 'AUTH_REQUIRED',
1471
- );
1472
- }
1502
+ return this.requestRawResponse(config, true);
1503
+ }
1473
1504
 
1474
- return this.requestAuthenticatedResponseAttempt(config, authHeader, false);
1505
+ /**
1506
+ * Raw response for public or authenticated app endpoints. The SDK supplies
1507
+ * its current bearer when available; caller headers never supply authority.
1508
+ * No caching, envelope transformation or transport-error retry is performed.
1509
+ */
1510
+ async requestResponse(config: ResponseRequest): Promise<Response> {
1511
+ return this.requestRawResponse(config, false);
1475
1512
  }
1476
1513
 
1477
- private async requestAuthenticatedResponseAttempt(
1478
- config: AuthenticatedResponseRequest,
1479
- authHeader: string,
1514
+ private async requestRawResponse(config: ResponseRequest, requireAuth: boolean): Promise<Response> {
1515
+ // Validate before obtaining a bearer: an unrelated origin must not trigger
1516
+ // an auth mint, even when the trusted transport accepts absolute URLs.
1517
+ const target = new URL(this.buildURL(config.url));
1518
+ const base = new URL(this.baseURL);
1519
+ if (target.origin !== base.origin || !['http:', 'https:'].includes(target.protocol)
1520
+ || target.username || target.password) {
1521
+ throw new Error('Raw response requests must target the configured API origin');
1522
+ }
1523
+ if (config.signal?.aborted) throw createCancelledError();
1524
+ // Snapshot caller-owned options once, including headers, before any await.
1525
+ const request = { ...config, headers: new Headers(config.headers) };
1526
+ const contextToken = this.syncAccessTokenFromProvider();
1527
+ const beforeAuthEpoch = this.sessionEpoch;
1528
+ const authHeader = await awaitRequestSignal(this.getAuthHeader(), config.signal);
1529
+ if (beforeAuthEpoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
1530
+ throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1531
+ }
1532
+ if (config.signal?.aborted) throw createCancelledError();
1533
+ if (requireAuth && !authHeader) {
1534
+ throw new OxyAuthenticationError('An active Oxy session is required for this request', 'AUTH_REQUIRED');
1535
+ }
1536
+ return this.requestResponseAttempt(request, target.href, authHeader, false, this.sessionEpoch, contextToken);
1537
+ }
1538
+
1539
+ private async requestResponseAttempt(
1540
+ config: ResponseRequest,
1541
+ fullUrl: string,
1542
+ authHeader: string | null,
1480
1543
  isAuthRetry: boolean,
1544
+ epoch: number,
1545
+ contextToken: string | null,
1481
1546
  ): Promise<Response> {
1547
+ const assertCurrent = (): void => {
1548
+ this.syncAccessTokenFromProvider();
1549
+ if (epoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
1550
+ throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1551
+ }
1552
+ };
1482
1553
  const startTime = Date.now();
1554
+ if (config.signal?.aborted) throw createCancelledError();
1555
+ assertCurrent();
1483
1556
  const headers = new Headers(config.headers);
1484
- if (!headers.has('Accept')) {
1485
- headers.set('Accept', 'application/json');
1486
- }
1487
- // Authentication is owned by this SDK instance. A caller cannot replace
1488
- // the bearer with a different session or leak one across linked apps.
1489
- headers.set('Authorization', authHeader);
1490
- // Never awaited: the PoP is a hint, and waiting on its trace fetch put up
1491
- // to a second in front of requests.
1557
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json');
1558
+ // Never accept ambient credentials or another authority from a caller.
1559
+ for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization']) headers.delete(name);
1560
+ if (authHeader) headers.set('Authorization', authHeader);
1492
1561
  const edgeRegionHeader = peekBrowserEdgeRegionHeader();
1493
1562
  for (const [name, value] of Object.entries(edgeRegionHeader)) headers.set(name, value);
1494
1563
  const activityIdHeader = getBrowserActivityIdHeader();
1495
1564
  for (const [name, value] of Object.entries(activityIdHeader)) headers.set(name, value);
1496
1565
 
1497
1566
  try {
1498
- const fullUrl = this.buildURL(config.url);
1499
- const response = await fetch(fullUrl, {
1567
+ const body = config.method === 'GET' ? undefined : config.body;
1568
+ const init: RequestInit & { duplex?: 'half' } = {
1500
1569
  method: config.method,
1501
1570
  headers,
1502
- body: config.method === 'GET' ? undefined : config.body,
1571
+ body,
1503
1572
  signal: config.signal,
1504
- credentials: this.getCredentialsMode(fullUrl),
1505
- });
1506
-
1507
- if (response.status === 401 && !isAuthRetry) {
1508
- const refreshed = await this.refreshAccessToken('response-401');
1573
+ credentials: 'omit',
1574
+ redirect: 'error',
1575
+ };
1576
+ // Node fetch requires this flag for streaming uploads; ordinary and
1577
+ // multipart bodies keep the platform's existing body serialization.
1578
+ if (body && typeof body === 'object' && 'getReader' in body) init.duplex = 'half';
1579
+ const response = await (config.fetch ?? globalThis.fetch)(fullUrl, init);
1580
+ try { assertCurrent(); } catch (error) { await response.body?.cancel(); throw error; }
1581
+ const replayable = body === undefined || typeof body === 'string';
1582
+ if (response.status === 401 && !isAuthRetry && authHeader && replayable && !config.signal?.aborted) {
1583
+ let refreshed: string | null;
1584
+ try {
1585
+ refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), config.signal);
1586
+ } catch (error) {
1587
+ void response.body?.cancel().catch(() => undefined);
1588
+ throw error;
1589
+ }
1590
+ try { assertCurrent(); } catch (error) { await response.body?.cancel(); throw error; }
1509
1591
  if (refreshed) {
1510
1592
  await response.body?.cancel();
1511
- return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
1593
+ if (config.signal?.aborted) throw createCancelledError();
1594
+ return this.requestResponseAttempt(config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken);
1512
1595
  }
1513
-
1514
- this.tokenStore.clearTokens();
1515
- this.notifyTokenChange();
1596
+ this.clearTokens();
1516
1597
  }
1517
1598
 
1518
1599
  const duration = Date.now() - startTime;
@@ -1523,8 +1604,11 @@ export class HttpService {
1523
1604
  const duration = Date.now() - startTime;
1524
1605
  this.updateMetrics(false, duration);
1525
1606
  this.config.onRequestEnd?.(config.url, config.method, duration, false);
1526
- const normalizedError = error instanceof Error ? error : new Error(String(error));
1607
+ const normalizedError = config.signal?.aborted || isCancelledError(error)
1608
+ ? createCancelledError()
1609
+ : error instanceof Error ? error : new Error(String(error));
1527
1610
  this.config.onRequestError?.(config.url, config.method, normalizedError);
1611
+ if (isCancelledError(normalizedError) || normalizedError instanceof OxyAuthenticationError) throw normalizedError;
1528
1612
  throw handleHttpError(normalizedError);
1529
1613
  }
1530
1614
  }
@@ -1552,6 +1636,8 @@ export class HttpService {
1552
1636
 
1553
1637
  // Token management
1554
1638
  setTokens(accessToken: string): void {
1639
+ const current = this.tokenStore.getAccessToken();
1640
+ if (current && !sameTokenSession(current, accessToken)) this.sessionEpoch += 1;
1555
1641
  this.sessionEnded = false;
1556
1642
  this.tokenStore.setTokens(accessToken);
1557
1643
  this.notifyTokenChange();
@@ -1561,18 +1647,19 @@ export class HttpService {
1561
1647
  * End the local session: clear the bearer and abandon every re-mint already
1562
1648
  * in flight, so none of them can plant a token after the sign-out.
1563
1649
  *
1564
- * {@link clearTokens} alone only drops the bearer, which is right for a
1565
- * mirror (a linked client following its parent) but not for a sign-out: a
1566
- * refresh started a moment earlier would finish and plant a new one.
1650
+ * Unlike {@link clearTokens}, this also marks the session as ended and
1651
+ * invalidates pending re-mints even when no bearer is currently present.
1567
1652
  */
1568
1653
  endSession(): void {
1569
- this.sessionEpoch += 1;
1654
+ if (!this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
1570
1655
  this.sessionEnded = true;
1571
1656
  this.clearTokens();
1572
1657
  }
1573
1658
 
1574
1659
  /**
1575
- * The current session epoch — see {@link endSession}. A re-mint lane that
1660
+ * The current context epoch: account/session switches and bearer removal
1661
+ * invalidate pending work; rotation within the same context preserves it.
1662
+ * A re-mint lane that
1576
1663
  * plants tokens itself (the device-secret arm) compares it across its awaits.
1577
1664
  */
1578
1665
  getSessionEpoch(): number {
@@ -1604,6 +1691,7 @@ export class HttpService {
1604
1691
  }
1605
1692
 
1606
1693
  clearTokens(): void {
1694
+ if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
1607
1695
  this.tokenStore.clearTokens();
1608
1696
  // Drop the response cache on logout. The cache is identity-scoped, so a
1609
1697
  // different user could never read these entries, but a logged-out client
@@ -17,6 +17,8 @@
17
17
  * no per-request "acting-as" header. Identity is carried by the session/token,
18
18
  * so a switch propagates through reload and cross-domain exactly like a login.
19
19
  */
20
+ import { agentKeyOperationSchema, executeAgentKeyOperationSchema, agentKeyOperationChallengeSchema,
21
+ agentKeyOperationResultSchema, agentKeyListResponseSchema, type AgentKeyOperation, type AgentKeyOperationProof } from '@oxy.so/contracts';
20
22
  import type { User } from '../models/interfaces';
21
23
  import type { AccountCategoryId, AccountKind, ChildAccountKind } from '@oxy.so/contracts';
22
24
  import type { SessionLoginResponse } from '../models/session';
@@ -413,12 +415,32 @@ export class AccountMembersApi {
413
415
  }
414
416
  }
415
417
 
418
+ /** Current account governors and autonomous rotation use action-bound proofs. */
419
+ export class AccountAgentKeysApi {
420
+ constructor(private readonly ctx: OxyContext) {}
421
+ async list(accountId: string) {
422
+ return agentKeyListResponseSchema.parse(await this.ctx.request<unknown>('GET', `/accounts/${enc(accountId)}/agent-keys`, undefined, { cache: false }));
423
+ }
424
+ async requestChallenge(accountId: string, operation: AgentKeyOperation) {
425
+ const response = await this.ctx.request<unknown>('POST', `/accounts/${enc(accountId)}/agent-keys/challenge`,
426
+ agentKeyOperationSchema.parse(operation), { cache: false });
427
+ return agentKeyOperationChallengeSchema.parse(response);
428
+ }
429
+ async execute(accountId: string, operation: AgentKeyOperation, proof: AgentKeyOperationProof) {
430
+ const response = await this.ctx.request<unknown>('POST', `/accounts/${enc(accountId)}/agent-keys/execute`,
431
+ executeAgentKeyOperationSchema.parse({ operation, proof }), { cache: false });
432
+ return agentKeyOperationResultSchema.parse(response);
433
+ }
434
+ }
435
+
416
436
  export class AccountsApi {
417
437
  /** Who can act on an account, and with what role. */
418
438
  readonly members: AccountMembersApi;
439
+ readonly agentKeys: AccountAgentKeysApi;
419
440
 
420
441
  constructor(private readonly ctx: OxyContext) {
421
442
  this.members = new AccountMembersApi(ctx);
443
+ this.agentKeys = new AccountAgentKeysApi(ctx);
422
444
  }
423
445
 
424
446
  /**
package/src/api/agency.ts CHANGED
@@ -83,7 +83,9 @@ export interface CapabilityExecutionAuthorization {
83
83
  ownerAccountId: string;
84
84
  coordinatorApplicationId: string;
85
85
  coordinatorCredentialId: string;
86
- actorType: 'alia' | 'agent';
86
+ actorType: 'alia' | 'agent' | 'requester';
87
+ requesterSessionId?: string | null;
88
+ requesterSessionBindingDigest?: string | null;
87
89
  actorAccountId: string | null;
88
90
  resourceApp: string;
89
91
  effectiveAccountId: string;
package/src/api/apps.ts CHANGED
@@ -565,13 +565,13 @@ export class AppsApi {
565
565
  * consent, authorize, and device-flow approval screens before any session
566
566
  * exists.
567
567
  */
568
- async getPublic(clientId: string): Promise<PublicApplication> {
568
+ async getPublic(clientId: string, options: { cache?: boolean } = {}): Promise<PublicApplication> {
569
569
  const res = await this.ctx.request<{ application: PublicApplication }>(
570
570
  'GET',
571
571
  `/auth/oauth/client/${enc(clientId)}`,
572
572
  undefined,
573
573
  // Public client metadata (pre-session consent UI) — skip the bearer preflight.
574
- { cache: true, cacheTTL: MEDIUM_TTL, skipAuth: true },
574
+ { cache: options.cache ?? true, cacheTTL: MEDIUM_TTL, skipAuth: true },
575
575
  );
576
576
  return res.application;
577
577
  }
package/src/api/auth.ts CHANGED
@@ -18,6 +18,11 @@
18
18
  import type { User } from '../models/interfaces';
19
19
  import type { CommonsDenyReason, DeviceProof, LoginResult } from '@oxy.so/contracts';
20
20
  import {
21
+ agentProofClaimsSchema,
22
+ agentChallengeRequestSchema,
23
+ agentVerifyRequestSchema,
24
+ type AgentProofClaims,
25
+ type AgentSignature,
21
26
  emailSignInLinkResponseSchema,
22
27
  emailSignInPendingSchema,
23
28
  emailSignInStartResponseSchema,
@@ -693,12 +698,13 @@ export class AuthOAuthApi {
693
698
  * out, so the app can run the OAuth flow again. It never degrades into a
694
699
  * session that looks alive and cannot refresh.
695
700
  */
701
+ /** `plantTokens: false` defers bearer installation to a validated provider commit. */
696
702
  async exchangeCode(params: {
697
703
  code: string;
698
704
  clientId: string;
699
705
  redirectUri: string;
700
706
  codeVerifier: string;
701
- }): Promise<OAuthTokenExchangeResult> {
707
+ }, options: { plantTokens?: boolean } = {}): Promise<OAuthTokenExchangeResult> {
702
708
  const form = new URLSearchParams({
703
709
  grant_type: 'authorization_code',
704
710
  code: params.code,
@@ -735,7 +741,9 @@ export class AuthOAuthApi {
735
741
  const expiresInSec =
736
742
  typeof record.expires_in === 'number' ? record.expires_in : DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
737
743
  const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
738
- if (accessToken) {
744
+ // Provider completion validates the expected subject before committing.
745
+ // Direct callers retain the historical token-planting default.
746
+ if (accessToken && options.plantTokens !== false) {
739
747
  this.ctx.oxy.session.setAccessToken(accessToken);
740
748
  }
741
749
  if (!deviceId || !deviceSecret) {
@@ -1158,6 +1166,25 @@ export class AuthCommonsApi {
1158
1166
 
1159
1167
  }
1160
1168
 
1169
+ /** Bot entry uses caller-owned keys and the normal account session lane. */
1170
+ export class AuthAgentApi {
1171
+ constructor(private readonly ctx: OxyContext) {}
1172
+
1173
+ async requestChallenge(publicKey: string): Promise<AgentProofClaims> {
1174
+ const body = agentChallengeRequestSchema.parse({ publicKey });
1175
+ const response = await this.ctx.request<unknown>('POST', '/auth/agent/challenge', body,
1176
+ { cache: false, skipAuth: true });
1177
+ return agentProofClaimsSchema.parse(response);
1178
+ }
1179
+
1180
+ async verify(publicKey: string, proof: AgentSignature): Promise<LoginResult> {
1181
+ const body = agentVerifyRequestSchema.parse({ publicKey, ...proof });
1182
+ const response = await this.ctx.request<unknown>('POST', '/auth/agent/verify', body,
1183
+ { cache: false, skipAuth: true });
1184
+ return plantSession(this.ctx, response, 'auth/agent/verify');
1185
+ }
1186
+ }
1187
+
1161
1188
  export class AuthApi {
1162
1189
  /** Email sign-in and email confirmation codes. */
1163
1190
  readonly email: AuthEmailApi;
@@ -1169,6 +1196,7 @@ export class AuthApi {
1169
1196
  readonly oauth: AuthOAuthApi;
1170
1197
  /** "Sign in with Oxy" handoff to Commons. */
1171
1198
  readonly commons: AuthCommonsApi;
1199
+ readonly agent: AuthAgentApi;
1172
1200
 
1173
1201
  constructor(private readonly ctx: OxyContext) {
1174
1202
  this.email = new AuthEmailApi(ctx);
@@ -1176,6 +1204,7 @@ export class AuthApi {
1176
1204
  this.totp = new AuthTotpApi(ctx);
1177
1205
  this.oauth = new AuthOAuthApi(ctx);
1178
1206
  this.commons = new AuthCommonsApi(ctx);
1207
+ this.agent = new AuthAgentApi(ctx);
1179
1208
  }
1180
1209
 
1181
1210
  /**