@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,6 +25,24 @@ export function getOxyUserId(req) {
25
25
  normalizeId(authReq.user?.id) ??
26
26
  normalizeId(authReq.user?._id));
27
27
  }
28
+ /**
29
+ * Who acted, and as whom, on this user session (issue #1520), or `null` when
30
+ * the session authority did not report it.
31
+ *
32
+ * An audit records `actorAccountId` as the actor and `effectiveAccountId`
33
+ * (equal to {@link getOxyUserId}) as the account spoken as. A bot acting with
34
+ * nobody operating it is its own actor; a person acting as an organization or
35
+ * a bot is the actor of a `delegated` chain. Never substitute the effective
36
+ * account's owner for either: when this is `null`, record the actor as unknown.
37
+ */
38
+ export function getOxyActor(req) {
39
+ const actor = req.oxyActor;
40
+ if (!actor)
41
+ return null;
42
+ // Defence in depth: a chain that does not describe the authenticated
43
+ // subject is not this request's chain.
44
+ return actor.effectiveAccountId === getOxyUserId(req) ? actor : null;
45
+ }
28
46
  export function isOxyAuthenticated(req) {
29
47
  return getOxyUserId(req) !== null;
30
48
  }
@@ -21,45 +21,6 @@ function decodeJsonSegment(segment) {
21
21
  throw new CapabilityTicketError('malformed', 'Capability ticket is not valid base64url JSON');
22
22
  }
23
23
  }
24
- function valuesAtPath(input, path) {
25
- let current = [input];
26
- for (const segment of path.split('.')) {
27
- const next = [];
28
- for (const value of current) {
29
- const records = Array.isArray(value) ? value : [value];
30
- for (const record of records) {
31
- if (typeof record !== 'object' || record === null || Array.isArray(record))
32
- continue;
33
- if (Object.prototype.hasOwnProperty.call(record, segment)) {
34
- next.push(record[segment]);
35
- }
36
- }
37
- }
38
- if (next.length === 0)
39
- return [];
40
- current = next;
41
- }
42
- return current.flatMap((value) => Array.isArray(value) ? value : [value]);
43
- }
44
- /** Enforces the signed per-action bounds before a domain handler runs. */
45
- export function inputSatisfiesCapabilityLimits(tool, input, limits) {
46
- for (const limit of limits) {
47
- if (limit.tool !== tool)
48
- return false;
49
- const actualValues = valuesAtPath(input, limit.key);
50
- if (actualValues.length === 0)
51
- return false;
52
- if (typeof limit.value === 'number') {
53
- const maximum = limit.value;
54
- if (!actualValues.every((actual) => (typeof actual === 'number' && Number.isFinite(actual) && actual <= maximum)))
55
- return false;
56
- continue;
57
- }
58
- if (!actualValues.every((actual) => actual === limit.value))
59
- return false;
60
- }
61
- return true;
62
- }
63
24
  export function issueCapabilityTicket(claims, options) {
64
25
  const ttlSeconds = options.ttlSeconds ?? 60;
65
26
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > DEFAULT_MAX_TTL_SECONDS) {
@@ -181,3 +142,4 @@ export function createCapabilityTicketMiddleware(options) {
181
142
  }
182
143
  };
183
144
  }
145
+ export { inputSatisfiesCapabilityLimits } from '@oxy.so/contracts';
@@ -19,7 +19,7 @@
19
19
  export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS } from './OxyServer.js';
20
20
  export { ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
21
21
  export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware.js';
22
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
22
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyActor, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
23
23
  export { createOxyRateLimit } from './rateLimit.js';
24
24
  // SSRF-safe upstream fetch + URL validation (Node-only).
25
25
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch.js';
@@ -40,6 +40,7 @@ export { CapabilityTicketError, createCapabilityTicketMiddleware, inputSatisfies
40
40
  export { createOxyJwksKeyResolver, createOxyRequesterAssertionAuth, OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER, OXY_REQUESTER_ASSERTION_HEADER, OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS, OXY_REQUESTER_ASSERTION_TYPE, OxyRequesterAssertionError, oxyRequesterAssertionClaimsSchema, readOxyRequesterAssertionKeyId, signOxyRequesterAssertion, verifyOxyRequesterAssertion, } from './requesterAssertion.js';
41
41
  // Constant-time secret comparison.
42
42
  export { verifySecret } from './verifySecret.js';
43
+ export { createLiveCapabilityTicketVerifier } from './liveCapabilityTicket.js';
43
44
  // Cross-service user-invalidation signal: oxy-api publishes when identity
44
45
  // changes, every consuming backend sweeps its caches instead of waiting out a TTL.
45
46
  export { createOxyUserInvalidationHandler, publishOxyUserInvalidation, } from './userInvalidation.js';
@@ -62,3 +63,5 @@ export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp.j
62
63
  * container credentials endpoint.
63
64
  */
64
65
  export { canAttestWorkloadIdentity, requestWorkloadServiceToken } from './workloadIdentity.js';
66
+ export { OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS, hasBoundedServiceTokenLifetime } from './serviceTokenLifetime.js';
67
+ export { signInAgentAccount } from './agentAccount.js';
@@ -0,0 +1,45 @@
1
+ import { canonicalCapabilityJson, capabilityTicketClaimsSchema } from '@oxy.so/contracts';
2
+ import { verifyCapabilityTicket } from './capabilityTicket.js';
3
+ /** Local signature + live receiving-service introspection; no cache or retry. */
4
+ export function createLiveCapabilityTicketVerifier(options) {
5
+ if (typeof options.issuer !== 'string' || options.issuer.trim() === '')
6
+ throw new Error('A trusted Capability issuer is required');
7
+ const timeoutMs = options.timeoutMs ?? 5000;
8
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) {
9
+ throw new Error('Capability introspection timeout must be between 1 and 60000ms');
10
+ }
11
+ return async (ticket, input = {}) => {
12
+ input.signal?.throwIfAborted();
13
+ const signed = verifyCapabilityTicket(ticket, options);
14
+ if (!signed.catalog)
15
+ throw new Error('Internal MCP requires a signed catalogue binding');
16
+ const controller = new AbortController();
17
+ const abort = () => controller.abort(input.signal?.reason);
18
+ input.signal?.addEventListener('abort', abort, { once: true });
19
+ const timer = setTimeout(() => controller.abort(new Error('Capability introspection timed out')), timeoutMs);
20
+ let rejectAbort;
21
+ try {
22
+ const interrupted = new Promise((_resolve, reject) => {
23
+ rejectAbort = () => reject(controller.signal.reason ?? new Error('Capability introspection aborted'));
24
+ controller.signal.addEventListener('abort', rejectAbort, { once: true });
25
+ if (controller.signal.aborted)
26
+ rejectAbort();
27
+ });
28
+ const result = await Promise.race([options.introspect(ticket, { signal: controller.signal }), interrupted]);
29
+ controller.signal.throwIfAborted();
30
+ if (result.active !== true)
31
+ throw new Error('Capability ticket is inactive');
32
+ const live = capabilityTicketClaimsSchema.parse(result.claims);
33
+ if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed))
34
+ throw new Error('Capability introspection claims mismatch');
35
+ // An authority response delayed past expiry cannot extend the signed grant.
36
+ return verifyCapabilityTicket(ticket, options);
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ input.signal?.removeEventListener('abort', abort);
41
+ if (rejectAbort)
42
+ controller.signal.removeEventListener('abort', rejectAbort);
43
+ }
44
+ };
45
+ }
@@ -1,3 +1,5 @@
1
+ import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime.js';
2
+ import { accountActorChainSchema } from '@oxy.so/contracts';
1
3
  import { jwtDecode } from 'jwt-decode';
2
4
  import { loadNodeCrypto } from '@oxy.so/protocol';
3
5
  import { logger } from '../logger/index.js';
@@ -405,22 +407,18 @@ export function createOxyMiddleware(host) {
405
407
  return onError(error);
406
408
  return res.status(401).json(error);
407
409
  }
408
- // One of Oxy's own applications acts for a user without a grant:
409
- // inside the ecosystem that is trust, not consent. It still acts
410
- // with the USER's authority only — what that user may do is for the
411
- // receiving service to check.
412
- if (oxyUserId && tier === 'internal') {
413
- req.userId = oxyUserId;
414
- req.user = { id: oxyUserId };
415
- req.serviceActingAs = { userId: oxyUserId, scopes: [] };
416
- }
417
- else if (oxyUserId) {
418
- // C3: an EXTERNAL service may only act as a user when an explicit
410
+ // A service credential plus a user header is never a present user
411
+ // session, even for an internal tier. Offline delegation is explicit.
412
+ if (oxyUserId) {
413
+ // C3: every service may only act as a user when an explicit
419
414
  // ServiceActingAs grant exists for that (appId, userId) pair.
420
415
  // Without the grant we MUST refuse — silently attaching
421
416
  // `req.userId = oxyUserId` would let any service impersonate
422
417
  // any user simply by setting the header.
423
- const grant = await host.verifyActingAs(appId, oxyUserId);
418
+ const grant = await host.verifyActingAs(appId, oxyUserId, {
419
+ cache: false, credentialId: decoded.credentialId,
420
+ ownerAccountId: decoded.ownerAccountId, environment: decoded.environment,
421
+ });
424
422
  if (!grant || !grant.authorized) {
425
423
  logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
426
424
  component: 'auth',
@@ -640,7 +638,43 @@ export function createOxyMiddleware(host) {
640
638
  return onError(error);
641
639
  return res.status(401).json(error);
642
640
  }
641
+ // Who acted, and as whom — from the session authority's answer
642
+ // (#1520), never from a header or this undecoded token. An API that
643
+ // predates the field answers without it, and that is `null` here:
644
+ // "not reported", never a guess that the subject acted for itself.
645
+ // A chain naming a DIFFERENT effective account than the session
646
+ // resolved to has no benign cause and is refused like a user
647
+ // mismatch.
648
+ const actor = readActorChain(validationResult.actor);
649
+ if (actor === 'invalid' || (actor && actor.effectiveAccountId !== validatedUserId)) {
650
+ logger.warn('[oxy.auth] Session rejected — actor chain does not describe the session', {
651
+ component: 'auth',
652
+ method: 'auth',
653
+ validatedUserId,
654
+ });
655
+ recordRefusal(req, {
656
+ code: 'SESSION_ACTOR_MISMATCH',
657
+ stage: 'session',
658
+ reason: 'Session actor chain does not describe the validated session',
659
+ status: 401,
660
+ });
661
+ if (optional) {
662
+ req.userId = null;
663
+ req.user = null;
664
+ return next();
665
+ }
666
+ const error = {
667
+ error: 'SESSION_ACTOR_MISMATCH',
668
+ message: 'Session actor does not match the session',
669
+ code: 'SESSION_ACTOR_MISMATCH',
670
+ status: 401
671
+ };
672
+ if (onError)
673
+ return onError(error);
674
+ return res.status(401).json(error);
675
+ }
643
676
  req.userId = validatedUserId;
677
+ req.oxyActor = actor;
644
678
  req.accessToken = token;
645
679
  req.sessionId = sessionId;
646
680
  // Session validation already returned the full user, so `loadUser`
@@ -902,12 +936,6 @@ export function createOxyMiddleware(host) {
902
936
  });
903
937
  return;
904
938
  }
905
- // Oxy's own applications are trusted app to app; scopes are the
906
- // external lane.
907
- if (req.serviceApp.tier === 'internal') {
908
- next();
909
- return;
910
- }
911
939
  const appHasScope = appScopes.includes(scope);
912
940
  const delegationHasScope = delegatedScopes.includes(scope);
913
941
  const hasRequiredScope = req.serviceActingAs
@@ -1123,6 +1151,9 @@ function getUserIdentityId(user) {
1123
1151
  * service token because no claim binding existed.
1124
1152
  */
1125
1153
  function verifyServiceTokenClaims(decoded, expected) {
1154
+ if (!hasBoundedServiceTokenLifetime(decoded)) {
1155
+ throw new ServiceTokenClaimError('Service token lifetime must be positive and at most 300 seconds');
1156
+ }
1126
1157
  if (decoded.type !== 'service') {
1127
1158
  throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
1128
1159
  }
@@ -1139,3 +1170,14 @@ function verifyServiceTokenClaims(decoded, expected) {
1139
1170
  throw new ServiceTokenClaimError(`Service token audience mismatch: expected '${expected.audience}', got '${String(aud)}'`);
1140
1171
  }
1141
1172
  }
1173
+ /**
1174
+ * Parse the session authority's actor chain. `null` when the API did not send
1175
+ * one; `'invalid'` when it sent something that is not a chain, which the caller
1176
+ * refuses rather than ignores.
1177
+ */
1178
+ function readActorChain(raw) {
1179
+ if (raw === undefined || raw === null)
1180
+ return null;
1181
+ const parsed = accountActorChainSchema.safeParse(raw);
1182
+ return parsed.success ? parsed.data : 'invalid';
1183
+ }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The server-side halves of the namespaces: what exists only with a service
3
+ * credential. `OxyServer` returns these from its namespace getters, so
4
+ * `server.assets.metadataByIds(...)` sits beside every client method of
5
+ * `oxy.assets`.
6
+ */
7
+ import { capabilityTicketRequestSchema, foregroundExecutionAuthorizationInputSchema } from '@oxy.so/contracts';
1
8
  import { AssetsApi } from '../api/assets.js';
2
9
  import { NotificationsApi } from '../api/notifications.js';
3
10
  import { LinkedAccountsApi } from '../api/linkedAccounts.js';
@@ -179,7 +186,57 @@ export class ServerLinkedAccountsApi extends LinkedAccountsApi {
179
186
  return lane(this.ctx).request('GET', `/linked-accounts/by-user/${encodeURIComponent(userId)}`);
180
187
  }
181
188
  }
189
+ function agencyAuthorityOptions(signal) {
190
+ signal?.throwIfAborted();
191
+ return { cache: false, deduplicate: false, retry: false, skipAuth: true, timeout: 5000, ...(signal ? { signal } : {}) };
192
+ }
182
193
  export class ServerAgencyApi extends AgencyApi {
194
+ /** Live registry discovery with this service's existing capability scopes. */
195
+ async serviceCatalogs(input = {}) {
196
+ const path = `/capabilities/catalogs${input.appId === undefined ? '' : `?appId=${encodeURIComponent(input.appId)}`}`;
197
+ const result = await lane(this.ctx).request('GET', path, undefined, agencyAuthorityOptions(input.signal));
198
+ return result.registrations;
199
+ }
200
+ /** Existing Oxy execution authority is required; this call creates none. */
201
+ async issueCapabilityTicket(input, options = {}) {
202
+ return lane(this.ctx).request('POST', '/capabilities/tickets', capabilityTicketRequestSchema.parse(input), agencyAuthorityOptions(options.signal));
203
+ }
204
+ /** Receiving app authenticates live introspection using its own service credential. */
205
+ async introspectCapabilityTicket(ticket, options = {}) {
206
+ if (ticket.trim() === '')
207
+ throw new Error('A capability ticket is required');
208
+ return lane(this.ctx).request('POST', '/capabilities/tickets/introspect', { ticket }, agencyAuthorityOptions(options.signal));
209
+ }
210
+ /**
211
+ * Authenticates the existing user-authority route with the requester's bearer.
212
+ * The service bearer and attribution headers cannot substitute for that user.
213
+ * The token goes only to this client's configured Oxy authority; never retry.
214
+ */
215
+ async createExecutionAuthorization(input, options) {
216
+ if (options.requesterToken === '' || /\s/.test(options.requesterToken))
217
+ throw new Error('A requester bearer is required');
218
+ const result = await this.ctx.request('POST', '/capabilities/execution-authorizations', input, {
219
+ ...agencyAuthorityOptions(options.signal),
220
+ headers: { Authorization: `Bearer ${options.requesterToken}` },
221
+ });
222
+ return result.authorization;
223
+ }
224
+ /** Revoke a direct approval on its canonical requester-authenticated lane. */
225
+ async revokeExecutionAuthorization(authorizationId, options) {
226
+ options.signal?.throwIfAborted();
227
+ if (!authorizationId || authorizationId.length > 255 || /\s/.test(authorizationId))
228
+ throw new Error('An execution authorization id is required');
229
+ if (!options.requesterToken || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
230
+ throw new Error('A requester bearer is required');
231
+ await this.ctx.request('DELETE', `/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`, undefined, { ...agencyAuthorityOptions(options.signal), headers: { Authorization: `Bearer ${options.requesterToken}` } });
232
+ }
233
+ /** Independent service proof plus present requester bearer, sent only to Oxy. */
234
+ async createForegroundExecutionAuthorization(input, options) {
235
+ if (options.requesterToken === '' || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
236
+ throw new Error('A requester bearer is required');
237
+ const result = await lane(this.ctx).request('POST', '/capabilities/foreground-execution-authorizations', { ...foregroundExecutionAuthorizationInputSchema.parse(input), subjectToken: options.requesterToken }, agencyAuthorityOptions(options.signal));
238
+ return result.authorization;
239
+ }
183
240
  /**
184
241
  * Product backend → Oxy: trade the signed-in requester's access token for a
185
242
  * one-use assertion naming `agentId` (ADR 0025), authenticated with this
@@ -0,0 +1,9 @@
1
+ /** Approved I03 ceiling shared by issuance and both JWT verifiers. */
2
+ export const OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = 300;
3
+ /** Call only after signature verification; cache checks alone confer no authority. */
4
+ export function hasBoundedServiceTokenLifetime(claims, nowSeconds = Math.floor(Date.now() / 1000)) {
5
+ return typeof claims.iat === 'number' && Number.isSafeInteger(claims.iat)
6
+ && typeof claims.exp === 'number' && Number.isSafeInteger(claims.exp)
7
+ && claims.iat <= nowSeconds && claims.exp > claims.iat
8
+ && claims.exp - claims.iat <= OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS;
9
+ }
@@ -1,6 +1,7 @@
1
1
  import { deviceActivateResponseSchema, deviceDirectorySchema, deviceDirectorySyncSchema, deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, SESSION_ACCOUNTS_CHANGED_EVENT, sessionAccountsChangedEventSchema, } from '@oxy.so/contracts';
2
2
  import { logger } from '../logger/index.js';
3
3
  import { computeIdentityTag } from '../utils/cacheKey.js';
4
+ import { decodeTokenClaims } from '../utils/tokenClaims.js';
4
5
  import { resolveActiveContext } from './deviceDirectory.js';
5
6
  import { getSocketIO } from './socketLoader.js';
6
7
  /**
@@ -34,6 +35,8 @@ export class SessionClient {
34
35
  this.socket = null;
35
36
  this.tokenUnsub = null;
36
37
  this.started = false;
38
+ this.lifecycleGeneration = 0;
39
+ this.fullSignOutPersistence = null;
37
40
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
38
41
  this.channel = null;
39
42
  /** App-facing subscriptions to named server-pushed socket events. */
@@ -41,6 +44,35 @@ export class SessionClient {
41
44
  /** Event names already bound on the CURRENT socket instance. */
42
45
  this.boundServerEvents = new Set();
43
46
  }
47
+ persistFullSignOut() {
48
+ const state = this.state;
49
+ if (!state || !this.options.onFullExplicitSignOut)
50
+ return Promise.resolve();
51
+ if (this.fullSignOutPersistence?.state === state)
52
+ return this.fullSignOutPersistence.pending;
53
+ const pending = this.options.onFullExplicitSignOut();
54
+ this.fullSignOutPersistence = { state, pending };
55
+ void pending.catch(() => {
56
+ if (this.fullSignOutPersistence?.pending === pending)
57
+ this.fullSignOutPersistence = null;
58
+ });
59
+ return pending;
60
+ }
61
+ async requestDevice(method, url, data, requestOptions) {
62
+ const generation = this.lifecycleGeneration;
63
+ const response = await this.host.makeRequest(method, url, data, requestOptions);
64
+ if (generation !== this.lifecycleGeneration)
65
+ throw new Error('Device session request was superseded');
66
+ return response;
67
+ }
68
+ /** Stop and discard only this client's local device projection; no server revocation. */
69
+ resetLocalState() {
70
+ this.lifecycleGeneration += 1;
71
+ this.stop();
72
+ this.state = null;
73
+ this.directory = null;
74
+ this.notify();
75
+ }
44
76
  getState() {
45
77
  return this.state;
46
78
  }
@@ -163,6 +195,7 @@ export class SessionClient {
163
195
  * cross-device push, a cold mint), not just the initial "no bearer yet" case.
164
196
  */
165
197
  applyState(raw, origin = 'push', activeToken) {
198
+ const generation = this.lifecycleGeneration;
166
199
  const next = safeParseContract(deviceSessionStateSchema, raw);
167
200
  if (!next) {
168
201
  logger.warn('[SessionClient] discarded invalid session state');
@@ -218,6 +251,8 @@ export class SessionClient {
218
251
  next.accounts.length > 0 &&
219
252
  (activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
220
253
  const publish = () => {
254
+ if (generation !== this.lifecycleGeneration || this.state !== next)
255
+ return;
221
256
  this.notify();
222
257
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
223
258
  try {
@@ -236,6 +271,8 @@ export class SessionClient {
236
271
  // (and this stays synchronous) for every client that never read a
237
272
  // directory, i.e. the whole account lane.
238
273
  const finishApply = () => {
274
+ if (generation !== this.lifecycleGeneration || this.state !== next)
275
+ return;
239
276
  const settling = this.settleDirectory(next);
240
277
  if (settling === null) {
241
278
  publish();
@@ -243,8 +280,38 @@ export class SessionClient {
243
280
  }
244
281
  void settling.then(publish);
245
282
  };
283
+ // A sibling's validated full removal is logout here too. Persist the local
284
+ // account-mode recovery suppression before publishing signed-out or running
285
+ // the empty-state refresh (which could otherwise challenge Commons again).
286
+ // Keep the push credential intact; a marker is not a destructive wipe.
287
+ // Unknown initial empty state, stale revisions and identity pins do not
288
+ // turn into logout intent. A newer state/lifecycle suppresses this publish.
289
+ const priorBearer = this.host.getAccessToken();
290
+ const bearerAccount = priorBearer ? decodeTokenClaims(priorBearer)?.userId : null;
291
+ // Warm cold boot may fetch authoritative empty state before it ever had a
292
+ // local projection. Its authenticated holder is history too. Decoding only
293
+ // identifies local history; authority remains the validated REST response.
294
+ const warmHolderEnded = previousState === null && origin === 'request' &&
295
+ typeof bearerAccount === 'string' && bearerAccount.length > 0 &&
296
+ this.host.getDeviceCredential()?.deviceId === next.deviceId;
297
+ if (((previousState?.deviceId === next.deviceId && previousState.accounts.length > 0) || warmHolderEnded) &&
298
+ next.accounts.length === 0 && pinnedAccountId === null && this.options.onFullExplicitSignOut) {
299
+ try {
300
+ void this.persistFullSignOut().then(finishApply).catch((error) => {
301
+ logger.error('[SessionClient] failed to persist received full sign-out', error);
302
+ finishApply();
303
+ });
304
+ }
305
+ catch (error) {
306
+ logger.error('[SessionClient] failed to persist received full sign-out', error);
307
+ finishApply();
308
+ }
309
+ return true;
310
+ }
246
311
  if (needsMintBeforeNotify) {
247
312
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
313
+ if (generation !== this.lifecycleGeneration)
314
+ return;
248
315
  logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
249
316
  // Do NOT notify under a mismatched bearer. Revert to the last applied
250
317
  // state so subscribers keep observing the account whose token is planted.
@@ -268,7 +335,7 @@ export class SessionClient {
268
335
  * the token still needs to be planted. The account-match guard rejects a stale response for an
269
336
  * account that is no longer active.
270
337
  */
271
- applySync(raw) {
338
+ async applySync(raw) {
272
339
  const sync = safeParseContract(deviceSessionSyncSchema, raw);
273
340
  if (!sync) {
274
341
  const parsed = deviceSessionSyncSchema.safeParse(raw);
@@ -285,6 +352,8 @@ export class SessionClient {
285
352
  return;
286
353
  }
287
354
  this.commitSync(sync);
355
+ if (this.fullSignOutPersistence?.state === this.state)
356
+ await this.fullSignOutPersistence.pending;
288
357
  }
289
358
  /**
290
359
  * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
@@ -362,7 +431,7 @@ export class SessionClient {
362
431
  }
363
432
  /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
364
433
  async fetchDirectory() {
365
- const res = await this.host.makeRequest('GET', '/session/device/directory', undefined, { cache: false });
434
+ const res = await this.requestDevice('GET', '/session/device/directory', undefined, { cache: false });
366
435
  return this.applyDirectory(res);
367
436
  }
368
437
  /**
@@ -471,8 +540,8 @@ export class SessionClient {
471
540
  }
472
541
  }
473
542
  async bootstrap() {
474
- const res = await this.host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
475
- this.applySync(res);
543
+ const res = await this.requestDevice('GET', '/session/device/state', undefined, { cache: false });
544
+ await this.applySync(res);
476
545
  }
477
546
  /**
478
547
  * Adopt server-authoritative state already returned by another session
@@ -515,7 +584,7 @@ export class SessionClient {
515
584
  * compatibility path for callers still keyed on account ids.
516
585
  */
517
586
  async activateContext(contextId) {
518
- const res = await this.host.makeRequest('POST', '/session/device/activate', { contextId }, { cache: false });
587
+ const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
519
588
  const activation = safeParseContract(deviceActivateResponseSchema, res);
520
589
  if (!activation) {
521
590
  logger.warn('[SessionClient] discarded invalid activation response');
@@ -541,8 +610,8 @@ export class SessionClient {
541
610
  }
542
611
  }
543
612
  async switchAccount(accountId) {
544
- const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
545
- this.applySync(res);
613
+ const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
614
+ await this.applySync(res);
546
615
  this.postCommitPing();
547
616
  }
548
617
  /**
@@ -557,9 +626,11 @@ export class SessionClient {
557
626
  * {@link signOutPrincipal} for the two that can tell those apart.
558
627
  */
559
628
  async signOut(target) {
560
- const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
561
- this.applySync(res);
629
+ const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
630
+ await this.applySync(res);
562
631
  this.postCommitPing();
632
+ if (this.state?.accounts.length === 0)
633
+ await this.persistFullSignOut();
563
634
  }
564
635
  /**
565
636
  * Remove ONE `principal → account` pair, and only that pair.
@@ -598,7 +669,7 @@ export class SessionClient {
598
669
  * applied this revision.
599
670
  */
600
671
  async removeFromDevice(target) {
601
- const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
672
+ const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
602
673
  const removal = safeParseContract(deviceDirectorySyncSchema, res);
603
674
  if (!removal) {
604
675
  logger.warn('[SessionClient] discarded invalid device removal response');
@@ -606,6 +677,8 @@ export class SessionClient {
606
677
  }
607
678
  const directoryApplied = this.applyDirectory(removal.directory);
608
679
  const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
680
+ if (this.fullSignOutPersistence?.state === this.state)
681
+ await this.fullSignOutPersistence.pending;
609
682
  // `commitSync` publishes whenever the flat state moved. When only the
610
683
  // directory did — a socket push already applied this revision — the
611
684
  // directory half would otherwise never reach a subscriber.
@@ -613,10 +686,12 @@ export class SessionClient {
613
686
  this.notify();
614
687
  }
615
688
  this.postCommitPing();
689
+ if (this.state?.accounts.length === 0)
690
+ await this.persistFullSignOut();
616
691
  }
617
692
  async addCurrentAccount() {
618
- const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
619
- this.applySync(res);
693
+ const res = await this.requestDevice('POST', '/session/device/add', undefined, { cache: false });
694
+ await this.applySync(res);
620
695
  this.postCommitPing();
621
696
  }
622
697
  /**
@@ -718,6 +793,7 @@ export class SessionClient {
718
793
  const deviceCredential = this.host.getDeviceCredential();
719
794
  if (!token && !deviceCredential)
720
795
  return;
796
+ const generation = this.lifecycleGeneration;
721
797
  const socket = io(this.host.getBaseURL(), {
722
798
  transports: ['websocket'],
723
799
  autoConnect: true,
@@ -742,6 +818,8 @@ export class SessionClient {
742
818
  socket.on('session_state', (payload) => {
743
819
  // A socket broadcast is a `push`-origin — potentially transient, so an
744
820
  // empty state here must not erase the durable device credential.
821
+ if (generation !== this.lifecycleGeneration)
822
+ return;
745
823
  const applied = this.applyState(payload, 'push');
746
824
  if (!applied)
747
825
  return;