@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
@@ -1,5 +1,6 @@
1
1
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import type { AuthMiddlewareOptions, OxyAuthRefusal, OxyMiddleware } from './middleware';
3
+ import type { AccountActorChain } from '@oxy.so/contracts';
3
4
  import { logger } from '../logger';
4
5
  import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
5
6
 
@@ -43,6 +44,12 @@ export interface OxyAuthRequest extends Request {
43
44
  sessionId?: string | null;
44
45
  serviceApp?: OxyServiceAppContext;
45
46
  serviceActingAs?: OxyServiceActingAsContext;
47
+ /**
48
+ * Who acted, and as whom, on a USER session — read off the session row by the
49
+ * Oxy API, never off a header. `null`/absent when the request is not a user
50
+ * session or the API did not report it. Read it with {@link getOxyActor}.
51
+ */
52
+ oxyActor?: AccountActorChain | null;
46
53
  /**
47
54
  * Why the presented credential was refused, when one was and the request
48
55
  * still reached the handler (the optional path). Read it with
@@ -95,6 +102,24 @@ export function getOxyUserId(req: Request): string | null {
95
102
  );
96
103
  }
97
104
 
105
+ /**
106
+ * Who acted, and as whom, on this user session (issue #1520), or `null` when
107
+ * the session authority did not report it.
108
+ *
109
+ * An audit records `actorAccountId` as the actor and `effectiveAccountId`
110
+ * (equal to {@link getOxyUserId}) as the account spoken as. A bot acting with
111
+ * nobody operating it is its own actor; a person acting as an organization or
112
+ * a bot is the actor of a `delegated` chain. Never substitute the effective
113
+ * account's owner for either: when this is `null`, record the actor as unknown.
114
+ */
115
+ export function getOxyActor(req: Request): AccountActorChain | null {
116
+ const actor = (req as OxyAuthRequest).oxyActor;
117
+ if (!actor) return null;
118
+ // Defence in depth: a chain that does not describe the authenticated
119
+ // subject is not this request's chain.
120
+ return actor.effectiveAccountId === getOxyUserId(req) ? actor : null;
121
+ }
122
+
98
123
  export function isOxyAuthenticated(req: Request): req is OxyAuthenticatedRequest {
99
124
  return getOxyUserId(req) !== null;
100
125
  }
@@ -7,7 +7,6 @@ import {
7
7
  import {
8
8
  capabilityTicketClaimsSchema,
9
9
  type CapabilityTicketClaims,
10
- type GrantLimit,
11
10
  type PolicyDecision,
12
11
  } from '@oxy.so/contracts';
13
12
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
@@ -74,47 +73,6 @@ function decodeJsonSegment(segment: string): unknown {
74
73
  }
75
74
  }
76
75
 
77
- function valuesAtPath(input: Record<string, unknown>, path: string): unknown[] {
78
- let current: unknown[] = [input];
79
- for (const segment of path.split('.')) {
80
- const next: unknown[] = [];
81
- for (const value of current) {
82
- const records = Array.isArray(value) ? value : [value];
83
- for (const record of records) {
84
- if (typeof record !== 'object' || record === null || Array.isArray(record)) continue;
85
- if (Object.prototype.hasOwnProperty.call(record, segment)) {
86
- next.push((record as Record<string, unknown>)[segment]);
87
- }
88
- }
89
- }
90
- if (next.length === 0) return [];
91
- current = next;
92
- }
93
- return current.flatMap((value) => Array.isArray(value) ? value : [value]);
94
- }
95
-
96
- /** Enforces the signed per-action bounds before a domain handler runs. */
97
- export function inputSatisfiesCapabilityLimits(
98
- tool: string,
99
- input: Record<string, unknown>,
100
- limits: readonly GrantLimit[],
101
- ): boolean {
102
- for (const limit of limits) {
103
- if (limit.tool !== tool) return false;
104
- const actualValues = valuesAtPath(input, limit.key);
105
- if (actualValues.length === 0) return false;
106
- if (typeof limit.value === 'number') {
107
- const maximum = limit.value;
108
- if (!actualValues.every((actual) => (
109
- typeof actual === 'number' && Number.isFinite(actual) && actual <= maximum
110
- ))) return false;
111
- continue;
112
- }
113
- if (!actualValues.every((actual) => actual === limit.value)) return false;
114
- }
115
- return true;
116
- }
117
-
118
76
  export function issueCapabilityTicket(
119
77
  claims: UnsignedCapabilityTicketClaims,
120
78
  options: CapabilityTicketSigningOptions,
@@ -256,3 +214,5 @@ export function createCapabilityTicketMiddleware(
256
214
  }
257
215
  };
258
216
  }
217
+
218
+ export { inputSatisfiesCapabilityLimits } from '@oxy.so/contracts';
@@ -26,6 +26,12 @@ export {
26
26
  ServerNotificationsApi,
27
27
  ServerReputationApi,
28
28
  } from './namespaces';
29
+ export type {
30
+ CapabilityTicketGrant,
31
+ CapabilityTicketIntrospection,
32
+ CreateExecutionAuthorizationInput,
33
+ ServiceCapabilityCatalog,
34
+ } from './namespaces';
29
35
  export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware';
30
36
  export type {
31
37
  AuthMiddlewareOptions,
@@ -40,6 +46,7 @@ export type {
40
46
  export {
41
47
  createOptionalOxyAuth,
42
48
  createOxyAuthMiddleware,
49
+ getOxyActor,
43
50
  getOxyAuthRefusal,
44
51
  getOxyBillingPrincipal,
45
52
  getOxyDelegatedUserId,
@@ -163,6 +170,8 @@ export type {
163
170
 
164
171
  // Constant-time secret comparison.
165
172
  export { verifySecret } from './verifySecret';
173
+ export { createLiveCapabilityTicketVerifier } from './liveCapabilityTicket';
174
+ export type { LiveCapabilityTicketVerifierOptions } from './liveCapabilityTicket';
166
175
 
167
176
  // Cross-service user-invalidation signal: oxy-api publishes when identity
168
177
  // changes, every consuming backend sweeps its caches instead of waiting out a TTL.
@@ -200,3 +209,7 @@ export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp';
200
209
  */
201
210
  export { canAttestWorkloadIdentity, requestWorkloadServiceToken } from './workloadIdentity';
202
211
  export type { WorkloadServiceToken, WorkloadServiceTokenOptions } from './workloadIdentity';
212
+
213
+ export { OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS, hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
214
+ export { signInAgentAccount } from './agentAccount';
215
+ export type { AgentAccountSigner } from './agentAccount';
@@ -0,0 +1,47 @@
1
+ import { canonicalCapabilityJson, capabilityTicketClaimsSchema, type CapabilityTicketClaims } from '@oxy.so/contracts';
2
+ import { verifyCapabilityTicket, type CapabilityTicketVerificationOptions } from './capabilityTicket';
3
+ import type { CapabilityTicketIntrospection } from './namespaces';
4
+
5
+ export interface LiveCapabilityTicketVerifierOptions extends CapabilityTicketVerificationOptions {
6
+ issuer: string;
7
+ introspect: (ticket: string, options: { signal: AbortSignal }) => Promise<CapabilityTicketIntrospection>;
8
+ /** Network bound only; does not establish an authority freshness policy. */
9
+ timeoutMs?: number;
10
+ }
11
+
12
+ /** Local signature + live receiving-service introspection; no cache or retry. */
13
+ export function createLiveCapabilityTicketVerifier(options: LiveCapabilityTicketVerifierOptions) {
14
+ if (typeof options.issuer !== 'string' || options.issuer.trim() === '') throw new Error('A trusted Capability issuer is required');
15
+ const timeoutMs = options.timeoutMs ?? 5000;
16
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) {
17
+ throw new Error('Capability introspection timeout must be between 1 and 60000ms');
18
+ }
19
+ return async (ticket: string, input: { signal?: AbortSignal } = {}): Promise<CapabilityTicketClaims> => {
20
+ input.signal?.throwIfAborted();
21
+ const signed = verifyCapabilityTicket(ticket, options);
22
+ if (!signed.catalog) throw new Error('Internal MCP requires a signed catalogue binding');
23
+ const controller = new AbortController();
24
+ const abort = () => controller.abort(input.signal?.reason);
25
+ input.signal?.addEventListener('abort', abort, { once: true });
26
+ const timer = setTimeout(() => controller.abort(new Error('Capability introspection timed out')), timeoutMs);
27
+ let rejectAbort: (() => void) | undefined;
28
+ try {
29
+ const interrupted = new Promise<never>((_resolve, reject) => {
30
+ rejectAbort = () => reject(controller.signal.reason ?? new Error('Capability introspection aborted'));
31
+ controller.signal.addEventListener('abort', rejectAbort, { once: true });
32
+ if (controller.signal.aborted) rejectAbort();
33
+ });
34
+ const result = await Promise.race([options.introspect(ticket, { signal: controller.signal }), interrupted]);
35
+ controller.signal.throwIfAborted();
36
+ if (result.active !== true) throw new Error('Capability ticket is inactive');
37
+ const live = capabilityTicketClaimsSchema.parse(result.claims);
38
+ if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed)) throw new Error('Capability introspection claims mismatch');
39
+ // An authority response delayed past expiry cannot extend the signed grant.
40
+ return verifyCapabilityTicket(ticket, options);
41
+ } finally {
42
+ clearTimeout(timer);
43
+ input.signal?.removeEventListener('abort', abort);
44
+ if (rejectAbort) controller.signal.removeEventListener('abort', rejectAbort);
45
+ }
46
+ };
47
+ }
@@ -1,3 +1,4 @@
1
+ import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
1
2
  /**
2
3
  * The Express and Socket.IO middleware `OxyServer` mounts
3
4
  * (`server.middleware.auth()`, `.socket()`, `.service()`, `.requireScope()`),
@@ -6,6 +7,7 @@
6
7
  * Node only; reachable solely from `@oxy.so/core/server`.
7
8
  */
8
9
  import type { OxyRequestUser } from './auth';
10
+ import { accountActorChainSchema, type AccountActorChain } from '@oxy.so/contracts';
9
11
  import { jwtDecode } from 'jwt-decode';
10
12
  import type { JsonWebKey } from 'node:crypto';
11
13
  import type { ApiError, User } from '../models/interfaces';
@@ -54,6 +56,8 @@ interface JwtPayload {
54
56
  * authoritative store stays server-side.
55
57
  */
56
58
  export interface ServiceActingAsVerification {
59
+ /** Decimal durable generation, never lossy JS number. */
60
+ epoch?: string;
57
61
  authorized: boolean;
58
62
  scopes: string[];
59
63
  }
@@ -148,11 +152,10 @@ export interface ServiceApp {
148
152
  appName: string;
149
153
  scopes: string[];
150
154
  /**
151
- * `internal` when the caller is one of Oxy's own applications: app to app it
152
- * is trusted outright — {@link requireScope} passes and it may act for a user
153
- * without a delegation grant. `external` keeps every scope and grant rule.
154
- * What the USER may do (their accounts, plan, limits) is still for the
155
- * receiving service to decide. A token without the claim reads as `external`.
155
+ * Classification only. Both tiers obey scope ceilings and live offline
156
+ * delegation; neither a brand nor a header proves a user's authority.
157
+ * Resource authorization remains with the receiving service. A missing
158
+ * claim reads as external.
156
159
  */
157
160
  tier: 'internal' | 'external';
158
161
  /** The credentialId of the specific service credential that minted this token. */
@@ -299,8 +302,10 @@ export interface OxyMiddlewareHost {
299
302
  validateSession(
300
303
  sessionId: string,
301
304
  options?: { deviceFingerprint?: string; useHeaderValidation?: boolean },
302
- ): Promise<{ valid: boolean; user?: User } | null>;
303
- verifyActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
305
+ ): Promise<{ valid: boolean; user?: User; actor?: unknown } | null>;
306
+ verifyActingAs(appId: string, userId: string, options?: {
307
+ cache?: boolean; credentialId?: string; ownerAccountId?: string; environment?: string;
308
+ }): Promise<ServiceActingAsVerification | null>;
304
309
  }
305
310
 
306
311
  /** Build the middleware set for one server. See `OxyServer.middleware`. */
@@ -680,21 +685,18 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
680
685
  return res.status(401).json(error);
681
686
  }
682
687
 
683
- // One of Oxy's own applications acts for a user without a grant:
684
- // inside the ecosystem that is trust, not consent. It still acts
685
- // with the USER's authority only — what that user may do is for the
686
- // receiving service to check.
687
- if (oxyUserId && tier === 'internal') {
688
- req.userId = oxyUserId;
689
- req.user = { id: oxyUserId };
690
- req.serviceActingAs = { userId: oxyUserId, scopes: [] };
691
- } else if (oxyUserId) {
692
- // C3: an EXTERNAL service may only act as a user when an explicit
688
+ // A service credential plus a user header is never a present user
689
+ // session, even for an internal tier. Offline delegation is explicit.
690
+ if (oxyUserId) {
691
+ // C3: every service may only act as a user when an explicit
693
692
  // ServiceActingAs grant exists for that (appId, userId) pair.
694
693
  // Without the grant we MUST refuse — silently attaching
695
694
  // `req.userId = oxyUserId` would let any service impersonate
696
695
  // any user simply by setting the header.
697
- const grant = await host.verifyActingAs(appId, oxyUserId);
696
+ const grant = await host.verifyActingAs(appId, oxyUserId, {
697
+ cache: false, credentialId: decoded.credentialId as string,
698
+ ownerAccountId: decoded.ownerAccountId as string, environment: decoded.environment as string,
699
+ });
698
700
  if (!grant || !grant.authorized) {
699
701
  logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
700
702
  component: 'auth',
@@ -925,7 +927,44 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
925
927
  return res.status(401).json(error);
926
928
  }
927
929
 
930
+ // Who acted, and as whom — from the session authority's answer
931
+ // (#1520), never from a header or this undecoded token. An API that
932
+ // predates the field answers without it, and that is `null` here:
933
+ // "not reported", never a guess that the subject acted for itself.
934
+ // A chain naming a DIFFERENT effective account than the session
935
+ // resolved to has no benign cause and is refused like a user
936
+ // mismatch.
937
+ const actor = readActorChain(validationResult.actor);
938
+ if (actor === 'invalid' || (actor && actor.effectiveAccountId !== validatedUserId)) {
939
+ logger.warn('[oxy.auth] Session rejected — actor chain does not describe the session', {
940
+ component: 'auth',
941
+ method: 'auth',
942
+ validatedUserId,
943
+ });
944
+ recordRefusal(req, {
945
+ code: 'SESSION_ACTOR_MISMATCH',
946
+ stage: 'session',
947
+ reason: 'Session actor chain does not describe the validated session',
948
+ status: 401,
949
+ });
950
+ if (optional) {
951
+ req.userId = null;
952
+ req.user = null;
953
+ return next();
954
+ }
955
+
956
+ const error = {
957
+ error: 'SESSION_ACTOR_MISMATCH',
958
+ message: 'Session actor does not match the session',
959
+ code: 'SESSION_ACTOR_MISMATCH',
960
+ status: 401
961
+ };
962
+ if (onError) return onError(error);
963
+ return res.status(401).json(error);
964
+ }
965
+
928
966
  req.userId = validatedUserId;
967
+ req.oxyActor = actor;
929
968
  req.accessToken = token;
930
969
  req.sessionId = sessionId;
931
970
  // Session validation already returned the full user, so `loadUser`
@@ -1216,13 +1255,6 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
1216
1255
  return;
1217
1256
  }
1218
1257
 
1219
- // Oxy's own applications are trusted app to app; scopes are the
1220
- // external lane.
1221
- if (req.serviceApp.tier === 'internal') {
1222
- next();
1223
- return;
1224
- }
1225
-
1226
1258
  const appHasScope = appScopes.includes(scope);
1227
1259
  const delegationHasScope = delegatedScopes.includes(scope);
1228
1260
  const hasRequiredScope = req.serviceActingAs
@@ -1468,6 +1500,9 @@ function verifyServiceTokenClaims(
1468
1500
  decoded: JwtPayload,
1469
1501
  expected: { audience: string; issuer: string },
1470
1502
  ): void {
1503
+ if (!hasBoundedServiceTokenLifetime(decoded)) {
1504
+ throw new ServiceTokenClaimError('Service token lifetime must be positive and at most 300 seconds');
1505
+ }
1471
1506
  if (decoded.type !== 'service') {
1472
1507
  throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
1473
1508
  }
@@ -1484,6 +1519,17 @@ function verifyServiceTokenClaims(
1484
1519
  }
1485
1520
  }
1486
1521
 
1522
+ /**
1523
+ * Parse the session authority's actor chain. `null` when the API did not send
1524
+ * one; `'invalid'` when it sent something that is not a chain, which the caller
1525
+ * refuses rather than ignores.
1526
+ */
1527
+ function readActorChain(raw: unknown): AccountActorChain | null | 'invalid' {
1528
+ if (raw === undefined || raw === null) return null;
1529
+ const parsed = accountActorChainSchema.safeParse(raw);
1530
+ return parsed.success ? parsed.data : 'invalid';
1531
+ }
1532
+
1487
1533
  // ---------------------------------------------------------------------------
1488
1534
  // Local request/response/socket typing
1489
1535
  //
@@ -1506,6 +1552,7 @@ interface AuthReq {
1506
1552
  sessionId?: string | null;
1507
1553
  serviceApp?: ServiceApp;
1508
1554
  serviceActingAs?: { userId: string; scopes: string[] };
1555
+ oxyActor?: AccountActorChain | null;
1509
1556
  oxyAuthRefusal?: OxyAuthRefusal;
1510
1557
  }
1511
1558
 
@@ -4,7 +4,8 @@
4
4
  * `server.assets.metadataByIds(...)` sits beside every client method of
5
5
  * `oxy.assets`.
6
6
  */
7
- import type { CreateOxyNotificationRequest, AwardReputationInput, ReputationTransaction, ServiceLinkedAccountListResponse } from '@oxy.so/contracts';
7
+ import { capabilityTicketRequestSchema, foregroundExecutionAuthorizationInputSchema, type ForegroundExecutionAuthorizationInput, type ActorRef, type AppCapabilityCatalog, type AutonomyLevel, type CapabilityTicketClaims, type CapabilityTicketRequest, type GrantLimit, type PolicyDecision, type ResourceRef, type CreateOxyNotificationRequest, type AwardReputationInput, type ReputationTransaction, type ServiceLinkedAccountListResponse } from '@oxy.so/contracts';
8
+ import type { RequestOptions } from '../HttpService';
8
9
  import type { OxyContext, ServiceLane } from '../client/context';
9
10
  import type {
10
11
  Notification,
@@ -15,7 +16,7 @@ import type {
15
16
  import { AssetsApi } from '../api/assets';
16
17
  import { NotificationsApi } from '../api/notifications';
17
18
  import { LinkedAccountsApi } from '../api/linkedAccounts';
18
- import { AgencyApi, type RequesterAssertionGrant, type RequesterAssertionIntrospection } from '../api/agency';
19
+ import { AgencyApi, type CapabilityExecutionAuthorization, type RequesterAssertionGrant, type RequesterAssertionIntrospection } from '../api/agency';
19
20
  import { ReputationApi } from '../api/reputation';
20
21
  import { ServiceAssetMetadataError, ServiceLinkedDownloadUrlError } from '../OxyServices.errors';
21
22
  import { extractErrorStatus } from '../utils/errorUtils';
@@ -218,7 +219,103 @@ export class ServerLinkedAccountsApi extends LinkedAccountsApi {
218
219
  }
219
220
  }
220
221
 
222
+ /** Projection of the existing service-authenticated registry response. */
223
+ export interface ServiceCapabilityCatalog {
224
+ id: string;
225
+ appSlug: string;
226
+ version: string;
227
+ digest: string;
228
+ audience: string;
229
+ catalog: AppCapabilityCatalog;
230
+ }
231
+
232
+ export interface CapabilityTicketGrant {
233
+ decision: PolicyDecision;
234
+ ticket?: string;
235
+ claims?: CapabilityTicketClaims;
236
+ }
237
+
238
+ export interface CapabilityTicketIntrospection {
239
+ active: boolean;
240
+ claims?: CapabilityTicketClaims;
241
+ decision?: PolicyDecision;
242
+ error?: string;
243
+ }
244
+
245
+ interface ExecutionAuthorizationTerms {
246
+ ownerAccountId: string;
247
+ coordinatorApplicationId: string;
248
+ coordinatorCredentialId: string;
249
+ actor: ActorRef;
250
+ resource: ResourceRef;
251
+ tool: string;
252
+ limits?: GrantLimit[];
253
+ expiresAt: string;
254
+ }
255
+
256
+ export type CreateExecutionAuthorizationInput = ExecutionAuthorizationTerms & (
257
+ | { kind: 'direct_request'; runId: string; stepId?: string; automationId?: never; maximumAutonomy: Exclude<AutonomyLevel, 'autonomous'> }
258
+ | { kind: 'automation'; automationId: string; runId?: never; stepId?: never; maximumAutonomy: AutonomyLevel }
259
+ );
260
+
261
+ function agencyAuthorityOptions(signal?: AbortSignal): RequestOptions {
262
+ signal?.throwIfAborted();
263
+ return { cache: false, deduplicate: false, retry: false, skipAuth: true, timeout: 5000, ...(signal ? { signal } : {}) };
264
+ }
265
+
221
266
  export class ServerAgencyApi extends AgencyApi {
267
+ /** Live registry discovery with this service's existing capability scopes. */
268
+ async serviceCatalogs(input: { appId?: string; signal?: AbortSignal } = {}): Promise<ServiceCapabilityCatalog[]> {
269
+ const path = `/capabilities/catalogs${input.appId === undefined ? '' : `?appId=${encodeURIComponent(input.appId)}`}`;
270
+ const result = await lane(this.ctx).request<{ registrations: ServiceCapabilityCatalog[] }>('GET', path, undefined, agencyAuthorityOptions(input.signal));
271
+ return result.registrations;
272
+ }
273
+
274
+ /** Existing Oxy execution authority is required; this call creates none. */
275
+ async issueCapabilityTicket(input: CapabilityTicketRequest, options: { signal?: AbortSignal } = {}): Promise<CapabilityTicketGrant> {
276
+ return lane(this.ctx).request<CapabilityTicketGrant>('POST', '/capabilities/tickets', capabilityTicketRequestSchema.parse(input), agencyAuthorityOptions(options.signal));
277
+ }
278
+
279
+ /** Receiving app authenticates live introspection using its own service credential. */
280
+ async introspectCapabilityTicket(ticket: string, options: { signal?: AbortSignal } = {}): Promise<CapabilityTicketIntrospection> {
281
+ if (ticket.trim() === '') throw new Error('A capability ticket is required');
282
+ return lane(this.ctx).request<CapabilityTicketIntrospection>('POST', '/capabilities/tickets/introspect', { ticket }, agencyAuthorityOptions(options.signal));
283
+ }
284
+
285
+ /**
286
+ * Authenticates the existing user-authority route with the requester's bearer.
287
+ * The service bearer and attribution headers cannot substitute for that user.
288
+ * The token goes only to this client's configured Oxy authority; never retry.
289
+ */
290
+ async createExecutionAuthorization(input: CreateExecutionAuthorizationInput, options: { requesterToken: string; signal?: AbortSignal }): Promise<CapabilityExecutionAuthorization> {
291
+ if (options.requesterToken === '' || /\s/.test(options.requesterToken)) throw new Error('A requester bearer is required');
292
+ const result = await this.ctx.request<{ authorization: CapabilityExecutionAuthorization }>('POST', '/capabilities/execution-authorizations', input, {
293
+ ...agencyAuthorityOptions(options.signal),
294
+ headers: { Authorization: `Bearer ${options.requesterToken}` },
295
+ });
296
+ return result.authorization;
297
+ }
298
+
299
+ /** Revoke a direct approval on its canonical requester-authenticated lane. */
300
+ async revokeExecutionAuthorization(authorizationId: string, options: { requesterToken: string; signal?: AbortSignal }): Promise<void> {
301
+ options.signal?.throwIfAborted();
302
+ if (!authorizationId || authorizationId.length > 255 || /\s/.test(authorizationId)) throw new Error('An execution authorization id is required');
303
+ if (!options.requesterToken || options.requesterToken.length > 16_384 || /\s/.test(options.requesterToken)) throw new Error('A requester bearer is required');
304
+ await this.ctx.request<void>('DELETE', `/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`,
305
+ undefined, { ...agencyAuthorityOptions(options.signal), headers: { Authorization: `Bearer ${options.requesterToken}` } });
306
+ }
307
+
308
+ /** Independent service proof plus present requester bearer, sent only to Oxy. */
309
+ async createForegroundExecutionAuthorization(input: ForegroundExecutionAuthorizationInput, options: { requesterToken: string; signal?: AbortSignal }): Promise<CapabilityExecutionAuthorization> {
310
+ if (options.requesterToken === '' || options.requesterToken.length > 16_384 || /\s/.test(options.requesterToken)) throw new Error('A requester bearer is required');
311
+ const result = await lane(this.ctx).request<{ authorization: CapabilityExecutionAuthorization }>(
312
+ 'POST', '/capabilities/foreground-execution-authorizations',
313
+ { ...foregroundExecutionAuthorizationInputSchema.parse(input), subjectToken: options.requesterToken },
314
+ agencyAuthorityOptions(options.signal),
315
+ );
316
+ return result.authorization;
317
+ }
318
+
222
319
  /**
223
320
  * Product backend → Oxy: trade the signed-in requester's access token for a
224
321
  * one-use assertion naming `agentId` (ADR 0025), authenticated with this
@@ -0,0 +1,13 @@
1
+ /** Approved I03 ceiling shared by issuance and both JWT verifiers. */
2
+ export const OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = 300;
3
+
4
+ /** Call only after signature verification; cache checks alone confer no authority. */
5
+ export function hasBoundedServiceTokenLifetime(
6
+ claims: { iat?: unknown; exp?: unknown },
7
+ nowSeconds = Math.floor(Date.now() / 1000),
8
+ ): boolean {
9
+ return typeof claims.iat === 'number' && Number.isSafeInteger(claims.iat)
10
+ && typeof claims.exp === 'number' && Number.isSafeInteger(claims.exp)
11
+ && claims.iat <= nowSeconds && claims.exp > claims.iat
12
+ && claims.exp - claims.iat <= OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS;
13
+ }