@oxy.so/core 7.19.1 → 7.21.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.
@@ -34,6 +34,23 @@
34
34
  * `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
35
35
  * a session, never through delegation.
36
36
  *
37
+ * ## An MCP tool call
38
+ *
39
+ * An MCP request carries no session: its bearer is an access token Oxy issued
40
+ * for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
41
+ * live before the tool ran. Pass the call's `extra` (`context.request` in a
42
+ * catalog handler or `authorize`) the same way:
43
+ *
44
+ * ```ts
45
+ * const held = await oxy.appPermissionsFor(context.request, { fresh: true });
46
+ * ```
47
+ *
48
+ * The SDK presents that token, with THIS process's service token, to
49
+ * `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
50
+ * resource this application registered, only about this application, and for
51
+ * the person who authorized the token — whichever account the connection is
52
+ * acting as. Same answer type, cache, `fresh` and errors as a session.
53
+ *
37
54
  * ## Caching, and how soon a revocation lands
38
55
  *
39
56
  * Answers are cached per (application, session, user) for
@@ -47,6 +64,7 @@
47
64
  * Node only; reachable solely from `@oxy.so/core/server`.
48
65
  */
49
66
 
67
+ import { createHash } from 'node:crypto';
50
68
  import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
51
69
  import { logger } from '../logger';
52
70
  import {
@@ -54,6 +72,7 @@ import {
54
72
  CallerAuthorityCache,
55
73
  errorStatus,
56
74
  permissionSet,
75
+ isBearerToken,
57
76
  readCallerSession,
58
77
  type OxyCallerRequest,
59
78
  } from './callerAuthority';
@@ -67,7 +86,8 @@ export type OxyAppPermissionDenyStatus = 403 | 404;
67
86
  /**
68
87
  * - `APP_PERMISSION_DENIED` — a person in a session, without the permission.
69
88
  * - `APP_PERMISSION_SESSION_REQUIRED` — no user session on the request: anonymous,
70
- * or authenticated by a service token (delegated or not).
89
+ * or authenticated by a service token (delegated or not); for an MCP call, no
90
+ * access token, or one Oxy refused (revoked, expired, another app's resource).
71
91
  * - `APP_PERMISSION_UNAVAILABLE` — Oxy could not be asked or gave no usable
72
92
  * answer. Fails closed; answered 503.
73
93
  * - `APP_PERMISSION_NOT_CONFIGURED` — this server knows no application id, or
@@ -107,6 +127,8 @@ export interface OxyAppPermissions {
107
127
  * The HUMAN Oxy resolved from the session — record THIS as the actor in an
108
128
  * audit log. It differs from `req.userId` when a person acts as an
109
129
  * organization (the session speaks as the org; the operator is the person).
130
+ * For an MCP call it is the person who authorized the token, which may differ
131
+ * from the account the connection is acting as.
110
132
  */
111
133
  readonly operatorId: string;
112
134
  /** The held keys, sorted. */
@@ -117,7 +139,11 @@ export interface OxyAppPermissions {
117
139
  export interface AppPermissionsOptions {
118
140
  /** Skip the cache and ask Oxy (the answer still refreshes the cache). Use on writes. */
119
141
  fresh?: boolean;
120
- /** Ask about another application than the server's configured one. */
142
+ /**
143
+ * Ask about another application than the server's configured one. Not for an
144
+ * MCP call: Oxy answers it only about the application that registered the
145
+ * token's resource, and any other id is `APP_PERMISSION_NOT_CONFIGURED`.
146
+ */
121
147
  applicationId?: string;
122
148
  }
123
149
 
@@ -143,6 +169,22 @@ export interface AppPermissionsRequest extends OxyCallerRequest {
143
169
  oxyAppPermissions?: OxyAppPermissions;
144
170
  }
145
171
 
172
+ /**
173
+ * An MCP tool call: the `extra` (`RequestHandlerExtra`) an MCP handler receives,
174
+ * whose `authInfo` `@oxy.so/mcp`'s transport set after Oxy introspected the
175
+ * access token live. `CatalogInvocationContext.request` is one.
176
+ */
177
+ export interface OxyMcpCallerRequest {
178
+ readonly authInfo?: { readonly token: string };
179
+ }
180
+
181
+ /** What `appPermissionsFor` reads: a request after `middleware.auth()`, or an MCP tool call. */
182
+ export type AppPermissionsSource = AppPermissionsRequest | OxyMcpCallerRequest;
183
+
184
+ function isMcpCallerRequest(source: AppPermissionsSource): source is OxyMcpCallerRequest {
185
+ return (source as OxyMcpCallerRequest).authInfo !== undefined;
186
+ }
187
+
146
188
  /** The status the middleware answers an error with, under `deny`. */
147
189
  export function appPermissionErrorStatus(
148
190
  error: OxyAppPermissionError,
@@ -184,6 +226,11 @@ function heldPermissions(
184
226
  export interface AppPermissionsHost {
185
227
  /** `GET /applications/:appId/roles/me` with this bearer, uncached. Rejects with the API error. */
186
228
  fetchRolesMe(applicationId: string, accessToken: string): Promise<unknown>;
229
+ /**
230
+ * `POST /auth/mcp/oauth/app-permissions` with this MCP access token, as this
231
+ * server's service, uncached. Rejects with the API error.
232
+ */
233
+ fetchMcpAppPermissions(mcpAccessToken: string): Promise<unknown>;
187
234
  /** The configured application id, or the one this server's service token names. */
188
235
  defaultApplicationId(): Promise<string | null>;
189
236
  readonly ttlMs: number;
@@ -204,14 +251,16 @@ export class OxyAppPermissionsResolver {
204
251
  }
205
252
 
206
253
  /**
207
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
254
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
208
255
  * never answers "nothing held" for a failure.
209
256
  */
210
257
  async resolve(
211
- req: AppPermissionsRequest,
258
+ source: AppPermissionsSource,
212
259
  options: AppPermissionsOptions = {},
213
260
  ): Promise<OxyAppPermissions> {
214
- const caller = readCallerSession(req);
261
+ if (isMcpCallerRequest(source)) return this.resolveMcp(source, options);
262
+
263
+ const caller = readCallerSession(source);
215
264
  if (!caller.ok) {
216
265
  throw new OxyAppPermissionError(
217
266
  'APP_PERMISSION_SESSION_REQUIRED',
@@ -221,6 +270,49 @@ export class OxyAppPermissionsResolver {
221
270
  );
222
271
  }
223
272
 
273
+ const appId = await this.applicationIdFor(options);
274
+ return this.cache.read([appId], caller.session, options.fresh === true, () =>
275
+ this.lookup(appId, caller.session.accessToken),
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Forget cached answers: every one, or those about one person (matched as
281
+ * either the session's user or the resolved operator).
282
+ */
283
+ invalidate(filter: { userId?: string } = {}): void {
284
+ this.cache.invalidate(filter);
285
+ }
286
+
287
+ /**
288
+ * An MCP call: Oxy names the application (the one that registered the token's
289
+ * resource) and the person (who authorized the token); the answer is refused
290
+ * unless the application is the one this server expects.
291
+ */
292
+ private async resolveMcp(
293
+ source: OxyMcpCallerRequest,
294
+ options: AppPermissionsOptions,
295
+ ): Promise<OxyAppPermissions> {
296
+ const token = source.authInfo?.token;
297
+ if (typeof token !== 'string' || !isBearerToken(token)) {
298
+ throw new OxyAppPermissionError(
299
+ 'APP_PERMISSION_SESSION_REQUIRED',
300
+ 'No verified MCP access token on this call',
301
+ );
302
+ }
303
+ const appId = await this.applicationIdFor(options);
304
+ // The person is known only once Oxy answers; the token's digest keys the
305
+ // entry, so the bearer itself is never held as a cache key.
306
+ const cacheKey = {
307
+ sessionId: `mcp:${createHash('sha256').update(token).digest('base64url')}`,
308
+ userId: null,
309
+ };
310
+ return this.cache.read([appId], cacheKey, options.fresh === true, () =>
311
+ this.lookupMcp(appId, token),
312
+ );
313
+ }
314
+
315
+ private async applicationIdFor(options: AppPermissionsOptions): Promise<string> {
224
316
  let applicationId = options.applicationId ?? null;
225
317
  if (!applicationId) {
226
318
  try {
@@ -239,19 +331,69 @@ export class OxyAppPermissionsResolver {
239
331
  'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }',
240
332
  );
241
333
  }
242
-
243
- const appId = applicationId;
244
- return this.cache.read([appId], caller.session, options.fresh === true, () =>
245
- this.lookup(appId, caller.session.accessToken),
246
- );
334
+ return applicationId;
247
335
  }
248
336
 
249
- /**
250
- * Forget cached answers: every one, or those about one person (matched as
251
- * either the session's user or the resolved operator).
252
- */
253
- invalidate(filter: { userId?: string } = {}): void {
254
- this.cache.invalidate(filter);
337
+ private async lookupMcp(
338
+ applicationId: string,
339
+ mcpAccessToken: string,
340
+ ): Promise<OxyAppPermissions> {
341
+ let answer: unknown;
342
+ try {
343
+ answer = await this.host.fetchMcpAppPermissions(mcpAccessToken);
344
+ } catch (error) {
345
+ const status = errorStatus(error);
346
+ if (status === 401 && (error as { code?: unknown }).code === 'invalid_grant') {
347
+ throw new OxyAppPermissionError(
348
+ 'APP_PERMISSION_SESSION_REQUIRED',
349
+ 'Oxy refused the MCP access token while resolving application permissions',
350
+ { cause: error },
351
+ );
352
+ }
353
+ if (status === 403) {
354
+ throw new OxyAppPermissionError(
355
+ 'APP_PERMISSION_DENIED',
356
+ 'Oxy refused this application’s permissions for the MCP access token',
357
+ { cause: error },
358
+ );
359
+ }
360
+ // Anything else — this service's own credential refused (401
361
+ // `invalid_client`), an Oxy without the route, a 5xx, a timeout — is not
362
+ // an answer about the person.
363
+ logger.warn('[oxy.appPermissions] Could not resolve application permissions for MCP', {
364
+ component: 'auth',
365
+ method: 'appPermissions',
366
+ applicationId,
367
+ status,
368
+ });
369
+ throw new OxyAppPermissionError(
370
+ 'APP_PERMISSION_UNAVAILABLE',
371
+ 'Could not resolve application permissions from Oxy',
372
+ { cause: error },
373
+ );
374
+ }
375
+
376
+ const parsed = applicationRolesMeSchema.safeParse(answer);
377
+ if (!parsed.success) {
378
+ throw new OxyAppPermissionError(
379
+ 'APP_PERMISSION_UNAVAILABLE',
380
+ 'Oxy answered with a malformed permission set',
381
+ );
382
+ }
383
+ if (parsed.data.applicationId !== applicationId) {
384
+ // Oxy answers about the application behind this server's service token;
385
+ // a different one means the configured id names another application.
386
+ logger.error(
387
+ `[oxy.appPermissions] Oxy answered for application ${parsed.data.applicationId}, not ${applicationId}; check OxyServerConfig.applicationId`,
388
+ undefined,
389
+ { component: 'auth', method: 'appPermissions' },
390
+ );
391
+ throw new OxyAppPermissionError(
392
+ 'APP_PERMISSION_NOT_CONFIGURED',
393
+ 'An MCP access token answers only for the application behind this server’s service token',
394
+ );
395
+ }
396
+ return heldPermissions(applicationId, parsed.data.userId, parsed.data.permissions);
255
397
  }
256
398
 
257
399
  private async lookup(applicationId: string, accessToken: string): Promise<OxyAppPermissions> {
@@ -42,6 +42,17 @@ export interface OxyCallerSession {
42
42
  readonly accessToken: string;
43
43
  }
44
44
 
45
+ /**
46
+ * Whose answer a cache entry is: the session (or other credential) it was read
47
+ * with, and the person, when known before asking. A caller whose person only
48
+ * Oxy's answer names (an MCP access token) passes `userId: null`; the reader's
49
+ * `isAbout` still matches the answer for {@link CallerAuthorityCache.invalidate}.
50
+ */
51
+ export interface OxyCallerCacheKey {
52
+ readonly sessionId: string;
53
+ readonly userId: string | null;
54
+ }
55
+
45
56
  /**
46
57
  * The person behind `req`, or why there is none: `service` for a request
47
58
  * authenticated by a service token (delegated or not — its bearer is the
@@ -175,7 +186,7 @@ export function accessFailure(error: unknown, what: string): OxyAccessError {
175
186
 
176
187
  interface CacheEntry<T> {
177
188
  value: T;
178
- userId: string;
189
+ userId: string | null;
179
190
  startedAt: number;
180
191
  expiresAt: number;
181
192
  }
@@ -204,7 +215,7 @@ export class CallerAuthorityCache<T> {
204
215
 
205
216
  async read(
206
217
  resourceKey: readonly string[],
207
- session: OxyCallerSession,
218
+ session: OxyCallerCacheKey,
208
219
  fresh: boolean,
209
220
  lookup: () => Promise<T>,
210
221
  ): Promise<T> {
@@ -244,7 +255,13 @@ export class CallerAuthorityCache<T> {
244
255
  }
245
256
  }
246
257
 
247
- private remember(key: string, userId: string, value: T, startedAt: number, ttl: number): void {
258
+ private remember(
259
+ key: string,
260
+ userId: string | null,
261
+ value: T,
262
+ startedAt: number,
263
+ ttl: number,
264
+ ): void {
248
265
  const existing = this.entries.get(key);
249
266
  if (existing && existing.startedAt > startedAt) return;
250
267
  this.entries.delete(key);
@@ -97,6 +97,8 @@ export {
97
97
  } from './appPermissions';
98
98
  export type {
99
99
  AppPermissionsOptions,
100
+ AppPermissionsSource,
101
+ OxyMcpCallerRequest,
100
102
  OxyAppPermissionDenyStatus,
101
103
  OxyAppPermissionErrorCode,
102
104
  OxyAppPermissions,
@@ -24,6 +24,8 @@ import {
24
24
  messageAccessInputSchema,
25
25
  messagingSessionsInputSchema,
26
26
  messagingSessionsResponseSchema,
27
+ notificationAccessBatchInputSchema,
28
+ notificationAccessBatchResponseSchema,
27
29
  profileAccessBatchInputSchema,
28
30
  profileAccessBatchResponseSchema,
29
31
  profileAccessDecisionSchema,
@@ -46,6 +48,7 @@ import {
46
48
  type MessageAccessBatchInput,
47
49
  type MessageAccessInput,
48
50
  type MessagingSessionsInput,
51
+ type NotificationAccessBatchInput,
49
52
  type ProfileAccessBatchInput,
50
53
  type ProfileAccessInput,
51
54
  type PublicationPoliciesInput,
@@ -116,6 +119,20 @@ export class ServerSafetyApi extends SafetyApi {
116
119
  );
117
120
  }
118
121
 
122
+ /**
123
+ * Whether each actor may notify each recipient — blocks either way,
124
+ * availability and supervised contact approval — for background delivery
125
+ * with no human proof. Reviewed `safety:notification-delivery:v1` authority.
126
+ */
127
+ async notificationAccessBatch(input: NotificationAccessBatchInput) {
128
+ return notificationAccessBatchResponseSchema.parse(
129
+ await this.service(
130
+ 'notification-access-batch',
131
+ notificationAccessBatchInputSchema.parse(input),
132
+ ),
133
+ );
134
+ }
135
+
119
136
  async contactAccess(input: ContactAccessInput, options: SafetyRequester) {
120
137
  return contactAccessResponseSchema.parse(
121
138
  await this.decision('contact-access', contactAccessInputSchema.parse(input), options),