@opengeni/api-router 0.22.2 → 0.26.1

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 (124) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/browser-controller-authority.d.ts +43 -0
  5. package/dist/browser-state-authority.d.ts +27 -0
  6. package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
  7. package/dist/chunk-JIKNR5YL.js.map +1 -0
  8. package/dist/codemode.d.ts +23 -0
  9. package/dist/editable-artifact-live-hints.d.ts +11 -0
  10. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  11. package/dist/editable-artifact-office-import.d.ts +22 -0
  12. package/dist/editable-artifact-production.d.ts +29 -0
  13. package/dist/editable-artifact-websocket.d.ts +49 -0
  14. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  15. package/dist/github-browser-flow.d.ts +6 -0
  16. package/dist/http/cors.d.ts +1 -0
  17. package/dist/http/sse.d.ts +2 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.js +1824 -30
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/api-integrations.d.ts +24 -0
  22. package/dist/integrations/atlassian.d.ts +176 -0
  23. package/dist/integrations/github-skill-source.d.ts +5 -0
  24. package/dist/integrations/google-drive.d.ts +85 -0
  25. package/dist/integrations/oauth-client.d.ts +30 -1
  26. package/dist/integrations/provider-oauth.d.ts +19 -0
  27. package/dist/integrations/slack-bot.d.ts +4 -0
  28. package/dist/integrations/slack-interactions.d.ts +14 -2
  29. package/dist/integrations/social-api.d.ts +2 -1
  30. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  31. package/dist/mcp/editable-artifacts.d.ts +13 -0
  32. package/dist/mcp/receipts.d.ts +28 -0
  33. package/dist/mcp/scheduled-task-view.d.ts +518 -0
  34. package/dist/mcp/server.d.ts +14 -3
  35. package/dist/memory-slack-delivery.d.ts +9 -0
  36. package/dist/routes/api-integrations.d.ts +8 -0
  37. package/dist/routes/browser-identities.d.ts +5 -0
  38. package/dist/routes/browser-sessions.d.ts +6 -0
  39. package/dist/routes/company-profile.d.ts +3 -0
  40. package/dist/routes/computer-sessions.d.ts +6 -0
  41. package/dist/routes/editable-artifacts.d.ts +44 -0
  42. package/dist/routes/integration-features.d.ts +3 -0
  43. package/dist/routes/memory-slack-publications.d.ts +6 -0
  44. package/dist/routes/plugins.d.ts +8 -0
  45. package/dist/routes/sessions.d.ts +17 -2
  46. package/dist/routes/skills.d.ts +6 -0
  47. package/dist/routes/video-generation.d.ts +3 -0
  48. package/dist/sandbox/auth-callout.d.ts +2 -0
  49. package/dist/sandbox/channel-a.d.ts +59 -2
  50. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  51. package/dist/sandbox/viewer.d.ts +4 -2
  52. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  53. package/package.json +19 -14
  54. package/src/app.ts +277 -41
  55. package/src/auth/managed-auth.ts +0 -16
  56. package/src/browser-controller-authority.ts +137 -0
  57. package/src/browser-state-authority.ts +236 -0
  58. package/src/codemode.ts +186 -0
  59. package/src/editable-artifact-live-hints.ts +64 -0
  60. package/src/editable-artifact-native-kernel.ts +659 -0
  61. package/src/editable-artifact-office-import.ts +230 -0
  62. package/src/editable-artifact-production.ts +419 -0
  63. package/src/editable-artifact-websocket.ts +311 -0
  64. package/src/editable-artifact-workspace-files.ts +186 -0
  65. package/src/github-browser-flow.ts +35 -6
  66. package/src/http/auth.ts +2 -0
  67. package/src/http/cors.ts +3 -0
  68. package/src/http/sse.ts +101 -6
  69. package/src/index.ts +147 -23
  70. package/src/integrations/api-integrations.ts +350 -0
  71. package/src/integrations/atlassian.ts +1621 -0
  72. package/src/integrations/github-skill-source.ts +142 -0
  73. package/src/integrations/google-drive.ts +1000 -64
  74. package/src/integrations/oauth-client.ts +159 -89
  75. package/src/integrations/provider-oauth.ts +777 -0
  76. package/src/integrations/slack-bot.ts +31 -2
  77. package/src/integrations/slack-interactions.ts +610 -42
  78. package/src/integrations/social-api.ts +11 -0
  79. package/src/mcp/documents.ts +74 -26
  80. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  81. package/src/mcp/editable-artifacts.ts +448 -0
  82. package/src/mcp/receipts.ts +95 -0
  83. package/src/mcp/scheduled-task-view.ts +642 -0
  84. package/src/mcp/server.ts +1718 -310
  85. package/src/memory-slack-delivery.ts +209 -0
  86. package/src/observability.ts +3 -3
  87. package/src/routes/api-integrations.ts +407 -0
  88. package/src/routes/api-keys.ts +7 -1
  89. package/src/routes/browser-identities.ts +136 -0
  90. package/src/routes/browser-sessions.ts +2543 -0
  91. package/src/routes/codex.ts +7 -4
  92. package/src/routes/company-profile.ts +255 -0
  93. package/src/routes/computer-sessions.ts +1247 -0
  94. package/src/routes/connections.ts +358 -102
  95. package/src/routes/documents.ts +22 -3
  96. package/src/routes/editable-artifacts.ts +1159 -0
  97. package/src/routes/enrollments.ts +54 -12
  98. package/src/routes/environments.ts +60 -11
  99. package/src/routes/files.ts +277 -65
  100. package/src/routes/github.ts +18 -2
  101. package/src/routes/install.ts +38 -2
  102. package/src/routes/integration-features.ts +258 -0
  103. package/src/routes/machines.ts +1 -1
  104. package/src/routes/memory-slack-publications.ts +216 -0
  105. package/src/routes/packs.ts +437 -7
  106. package/src/routes/plugins.ts +751 -0
  107. package/src/routes/rigs.ts +77 -20
  108. package/src/routes/scheduled-tasks.ts +94 -42
  109. package/src/routes/sessions.ts +475 -234
  110. package/src/routes/skills.ts +174 -0
  111. package/src/routes/transcription-recordings.ts +65 -33
  112. package/src/routes/video-generation.ts +132 -0
  113. package/src/routes/workspaces.ts +46 -24
  114. package/src/sandbox/auth-callout.ts +16 -4
  115. package/src/sandbox/channel-a.ts +809 -85
  116. package/src/sandbox/enrollment.ts +13 -3
  117. package/src/sandbox/machines.ts +1 -1
  118. package/src/sandbox/metrics-ingestion.ts +121 -3
  119. package/src/sandbox/rematerialize.ts +35 -47
  120. package/src/sandbox/viewer.ts +58 -29
  121. package/src/temporal-schedule-cleanup.ts +135 -0
  122. package/dist/chunk-HWXJW5C7.js.map +0 -1
  123. package/dist/mcp/toolspace.d.ts +0 -62
  124. package/src/mcp/toolspace.ts +0 -1186
@@ -45,8 +45,16 @@ import { canonicalProviderDomain } from "./provider-domain";
45
45
 
46
46
  export const oauthStateTtlMs = 10 * 60 * 1000;
47
47
  export const OFFICIAL_SLACK_MCP_URL = OPENGENI_PERSONAL_SLACK_MCP_URL;
48
+ export const OFFICIAL_GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1";
49
+ export const OFFICIAL_GMAIL_MCP_SCOPES = [
50
+ "https://www.googleapis.com/auth/gmail.readonly",
51
+ "https://www.googleapis.com/auth/gmail.compose",
52
+ "https://www.googleapis.com/auth/gmail.modify",
53
+ ] as const;
48
54
  const SLACK_OAUTH_ORIGIN = "https://slack.com";
49
55
  const SLACK_MCP_ORIGIN = "https://mcp.slack.com";
56
+ const GOOGLE_OAUTH_ISSUER_ORIGIN = "https://accounts.google.com";
57
+ const GOOGLE_TOKEN_ORIGIN = "https://oauth2.googleapis.com";
50
58
  export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
51
59
 
52
60
  type OAuthClientDeps = {
@@ -120,6 +128,7 @@ type OAuthStatePayload = {
120
128
  issuer: string;
121
129
  clientRegistrationMethod: OAuthClientRegistration["method"];
122
130
  tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
131
+ resourceParameterSupported: boolean;
123
132
  encryptedClientSecret?: string;
124
133
  returnPath: string;
125
134
  connectionId?: string;
@@ -282,8 +291,7 @@ export async function startMcpOAuth(
282
291
  error instanceof OAuthStartStageError
283
292
  ? error
284
293
  : new OAuthStartStageError("connection_lookup", oauthStartFailureReason(error), error);
285
- const providerDomain = safeRequestedProviderDomain(context.payload);
286
- logOAuthStartFailure(deps.observability, staged, providerDomain);
294
+ logOAuthStartFailure(deps.observability, staged);
287
295
  throw oauthStartApiError(staged);
288
296
  } finally {
289
297
  deadline.dispose();
@@ -297,21 +305,15 @@ async function startMcpOAuthWithinDeadline(
297
305
  ): Promise<OAuthStartResponse> {
298
306
  const { db, settings } = deps;
299
307
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
308
+ const officialGmailResource = mcpUrl === OFFICIAL_GMAIL_MCP_URL;
300
309
  const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
301
310
  const providerDomain = officialSlackResource
302
311
  ? "slack.com"
303
312
  : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
304
- const personalSlack = officialSlackResource || providerDomain === "slack.com";
305
- assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
306
- if (personalSlack && context.payload.ownership === "workspace") {
307
- throw new HTTPException(422, {
308
- message:
309
- "Slack's hosted MCP connection is personal; use the OpenGeni Slack bot installation for workspace access",
310
- });
311
- }
312
- const requestedOwnership: ConnectionOwnership = personalSlack
313
- ? "personal"
314
- : (context.payload.ownership ?? "workspace");
313
+ const hostedSlackMcp = officialSlackResource || providerDomain === "slack.com";
314
+ assertHostedSlackMcpOAuthStart(settings, context.payload, mcpUrl, hostedSlackMcp);
315
+ const requestedOwnership: ConnectionOwnership = context.payload.ownership ?? "workspace";
316
+ assertOfficialGmailPersonalOwnership(mcpUrl, requestedOwnership);
315
317
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
316
318
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
317
319
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -322,7 +324,8 @@ async function startMcpOAuthWithinDeadline(
322
324
  subjectId: context.subjectId,
323
325
  providerDomain,
324
326
  mcpUrl,
325
- personalSlack,
327
+ hostedSlackMcp,
328
+ exactMcpBinding: hostedSlackMcp || officialGmailResource,
326
329
  connectionId: context.payload.connectionId,
327
330
  requestedOwnership: context.payload.ownership,
328
331
  newConnectionOwnership: requestedOwnership,
@@ -336,16 +339,29 @@ async function startMcpOAuthWithinDeadline(
336
339
  : requestedOwnership;
337
340
 
338
341
  const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
339
- if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
342
+ if (hostedSlackMcp && !isLocalTestEnvironment(settings.environment)) {
340
343
  assertSlackAuthorizationServer(discovery.as);
341
344
  }
345
+ const googleAuthorizationServer = hasGoogleAuthorizationServerIdentity(discovery.as);
346
+ if (officialGmailResource || googleAuthorizationServer) {
347
+ if (!officialGmailResource || providerDomain !== "gmailmcp.googleapis.com") {
348
+ throw new HTTPException(422, {
349
+ message: `Google OAuth is allowed only for ${OFFICIAL_GMAIL_MCP_URL}`,
350
+ });
351
+ }
352
+ assertGoogleAuthorizationServer(discovery.as);
353
+ }
354
+ const resourceParameterSupported = !officialGmailResource;
342
355
  const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
343
356
  const verifier = randomPkceVerifier();
344
- const authorizeScopes = chooseAuthorizeScopes(
345
- context.payload.requestedScopes,
346
- discovery.challenge.scope,
347
- discovery.prm.scopesSupported,
348
- );
357
+ // The Gmail PRM advertises broader grants, including full-mail access. The
358
+ // reviewed connector never lets a caller widen the capability contract.
359
+ const authorizeScopes = chooseMcpAuthorizeScopes({
360
+ mcpUrl,
361
+ requested: context.payload.requestedScopes,
362
+ challenged: discovery.challenge.scope,
363
+ supported: discovery.prm.scopesSupported,
364
+ });
349
365
  const client = await deadline.run("client_registration", (signal) =>
350
366
  registerOAuthClient(
351
367
  db,
@@ -367,7 +383,9 @@ async function startMcpOAuthWithinDeadline(
367
383
  providerDomain,
368
384
  mcpUrl,
369
385
  resource,
370
- requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
386
+ requestedScopes: officialGmailResource
387
+ ? [...OFFICIAL_GMAIL_MCP_SCOPES]
388
+ : uniqueStrings(context.payload.requestedScopes ?? []),
371
389
  authorizeScopes,
372
390
  encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
373
391
  clientId: client.clientId,
@@ -376,6 +394,7 @@ async function startMcpOAuthWithinDeadline(
376
394
  issuer: client.issuer,
377
395
  clientRegistrationMethod: client.method,
378
396
  tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
397
+ resourceParameterSupported,
379
398
  ...(client.method === "manual" && client.clientSecret
380
399
  ? {
381
400
  encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret),
@@ -393,6 +412,7 @@ async function startMcpOAuthWithinDeadline(
393
412
  resource,
394
413
  verifier,
395
414
  scopes: authorizeScopes,
415
+ resourceParameterSupported,
396
416
  });
397
417
  return OAuthStartResponse.parse({
398
418
  state,
@@ -401,6 +421,18 @@ async function startMcpOAuthWithinDeadline(
401
421
  });
402
422
  }
403
423
 
424
+ export function assertOfficialGmailPersonalOwnership(
425
+ mcpUrl: string,
426
+ ownership: ConnectionOwnership,
427
+ ): void {
428
+ if (mcpUrl === OFFICIAL_GMAIL_MCP_URL && ownership !== "personal") {
429
+ throw new HTTPException(422, {
430
+ message:
431
+ "Gmail connections are personal only; each workspace member must connect their own mailbox",
432
+ });
433
+ }
434
+ }
435
+
404
436
  export async function completeMcpOAuthCallback(
405
437
  deps: OAuthClientDeps,
406
438
  input: {
@@ -446,6 +478,9 @@ async function completeMcpOAuthCallbackWithinDeadline(
446
478
  }
447
479
  try {
448
480
  state = readOAuthState(input.state, settings);
481
+ // Fence state minted by an older deployment too: a rolling update must not
482
+ // let a still-valid workspace-owned Gmail callback persist shared authority.
483
+ assertOfficialGmailPersonalOwnership(state.mcpUrl, state.ownership);
449
484
  if (!input.code) {
450
485
  return {
451
486
  redirectTo: callbackReturnPath(state.returnPath, "error", {
@@ -506,6 +541,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
506
541
  resource: state.resource,
507
542
  tokenEndpoint: state.tokenEndpoint,
508
543
  client,
544
+ resourceParameterSupported: state.resourceParameterSupported,
509
545
  signal,
510
546
  }),
511
547
  );
@@ -620,13 +656,13 @@ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload)
620
656
  }
621
657
  }
622
658
 
623
- function assertPersonalSlackOAuthStart(
659
+ function assertHostedSlackMcpOAuthStart(
624
660
  settings: Settings,
625
661
  payload: OAuthStartRequest,
626
662
  mcpUrl: string,
627
- personalSlack: boolean,
663
+ hostedSlackMcp: boolean,
628
664
  ): void {
629
- if (!personalSlack) return;
665
+ if (!hostedSlackMcp) return;
630
666
  if (payload.oauthClient) {
631
667
  throw new HTTPException(422, {
632
668
  message: "Slack OAuth client credentials are deployment-managed",
@@ -639,13 +675,12 @@ function assertPersonalSlackOAuthStart(
639
675
  }
640
676
  if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
641
677
  throw new HTTPException(422, {
642
- message: `personal Slack OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
678
+ message: `Slack MCP OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
643
679
  });
644
680
  }
645
681
  if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
646
682
  throw new HTTPException(503, {
647
- message:
648
- "personal Slack OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
683
+ message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
649
684
  });
650
685
  }
651
686
  }
@@ -665,6 +700,27 @@ export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata):
665
700
  }
666
701
  }
667
702
 
703
+ function hasGoogleAuthorizationServerIdentity(as: AuthorizationServerMetadata): boolean {
704
+ return [as.issuer, as.authorizationServer].some(
705
+ (value) => new URL(value).origin === GOOGLE_OAUTH_ISSUER_ORIGIN,
706
+ );
707
+ }
708
+
709
+ export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void {
710
+ const issuerOrigins = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
711
+ const authorizationOrigin = new URL(as.authorizationEndpoint).origin;
712
+ const tokenOrigin = new URL(as.tokenEndpoint).origin;
713
+ if (
714
+ issuerOrigins.some((origin) => origin !== GOOGLE_OAUTH_ISSUER_ORIGIN) ||
715
+ authorizationOrigin !== GOOGLE_OAUTH_ISSUER_ORIGIN ||
716
+ tokenOrigin !== GOOGLE_TOKEN_ORIGIN
717
+ ) {
718
+ throw new HTTPException(422, {
719
+ message: "Gmail MCP authorization metadata did not remain bound to Google",
720
+ });
721
+ }
722
+ }
723
+
668
724
  async function discoverMcpOAuth(
669
725
  resource: string,
670
726
  settings: Settings,
@@ -1154,7 +1210,8 @@ async function existingOAuthConnectionForStart(
1154
1210
  subjectId: string;
1155
1211
  providerDomain: string;
1156
1212
  mcpUrl: string;
1157
- personalSlack: boolean;
1213
+ hostedSlackMcp: boolean;
1214
+ exactMcpBinding: boolean;
1158
1215
  connectionId?: string | undefined;
1159
1216
  requestedOwnership?: ConnectionOwnership | undefined;
1160
1217
  newConnectionOwnership: ConnectionOwnership;
@@ -1177,7 +1234,7 @@ async function existingOAuthConnectionForStart(
1177
1234
  });
1178
1235
  }
1179
1236
  return connection.providerDomain === input.providerDomain &&
1180
- (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl)
1237
+ (!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl)
1181
1238
  ? connection
1182
1239
  : null;
1183
1240
  }
@@ -1188,9 +1245,9 @@ async function existingOAuthConnectionForStart(
1188
1245
  connection.subjectId === ownerSubjectId &&
1189
1246
  connection.kind === "oauth2" &&
1190
1247
  connection.providerDomain === input.providerDomain &&
1191
- (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl),
1248
+ (!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl),
1192
1249
  );
1193
- if (input.personalSlack) {
1250
+ if (input.hostedSlackMcp && input.newConnectionOwnership === "personal") {
1194
1251
  return selectCanonicalPersonalSlackConnection(matching);
1195
1252
  }
1196
1253
  return matching.find((connection) => connection.status === "active") ?? null;
@@ -1205,7 +1262,7 @@ function ownershipForConnection(
1205
1262
  throw new HTTPException(404, { message: "connection not found" });
1206
1263
  }
1207
1264
 
1208
- function buildAuthorizationUrl(input: {
1265
+ export function buildAuthorizationUrl(input: {
1209
1266
  endpoint: string;
1210
1267
  settings: Settings;
1211
1268
  clientId: string;
@@ -1214,6 +1271,7 @@ function buildAuthorizationUrl(input: {
1214
1271
  resource: string;
1215
1272
  verifier: string;
1216
1273
  scopes: string[];
1274
+ resourceParameterSupported: boolean;
1217
1275
  }): string {
1218
1276
  const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
1219
1277
  const url = new URL(endpoint);
@@ -1221,7 +1279,16 @@ function buildAuthorizationUrl(input: {
1221
1279
  url.searchParams.set("client_id", input.clientId);
1222
1280
  url.searchParams.set("redirect_uri", input.redirectUri);
1223
1281
  url.searchParams.set("state", input.state);
1224
- url.searchParams.set("resource", input.resource);
1282
+ if (input.resourceParameterSupported) {
1283
+ url.searchParams.set("resource", input.resource);
1284
+ } else {
1285
+ // Google's OAuth endpoints do not implement RFC 8707's `resource`
1286
+ // parameter. Explicit offline consent is required to obtain the refresh
1287
+ // token used by the durable connection broker.
1288
+ url.searchParams.set("access_type", "offline");
1289
+ url.searchParams.set("include_granted_scopes", "true");
1290
+ url.searchParams.set("prompt", "consent");
1291
+ }
1225
1292
  url.searchParams.set("code_challenge_method", "S256");
1226
1293
  url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
1227
1294
  if (input.scopes.length > 0) {
@@ -1278,6 +1345,9 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1278
1345
  ),
1279
1346
  clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
1280
1347
  tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
1348
+ // States minted before provider-specific compatibility was introduced used
1349
+ // the RFC 8707 resource parameter.
1350
+ resourceParameterSupported: payload.resourceParameterSupported !== false,
1281
1351
  ...(stringValue(payload.encryptedClientSecret)
1282
1352
  ? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! }
1283
1353
  : {}),
@@ -1380,6 +1450,7 @@ async function exchangeAuthorizationCode(
1380
1450
  verifier: string;
1381
1451
  redirectUri: string;
1382
1452
  resource: string;
1453
+ resourceParameterSupported: boolean;
1383
1454
  tokenEndpoint: string;
1384
1455
  client: OAuthClientRegistration;
1385
1456
  signal: AbortSignal;
@@ -1390,7 +1461,9 @@ async function exchangeAuthorizationCode(
1390
1461
  body.set("code", input.code);
1391
1462
  body.set("redirect_uri", input.redirectUri);
1392
1463
  body.set("code_verifier", input.verifier);
1393
- body.set("resource", input.resource);
1464
+ if (input.resourceParameterSupported) {
1465
+ body.set("resource", input.resource);
1466
+ }
1394
1467
  const headers: Record<string, string> = {
1395
1468
  "content-type": "application/x-www-form-urlencoded",
1396
1469
  accept: "application/json",
@@ -1474,31 +1547,16 @@ function throwIfCallbackAborted(signal: AbortSignal, stage: OAuthCallbackStage):
1474
1547
  function logOAuthCallbackFailure(
1475
1548
  observability: Observability | undefined,
1476
1549
  error: OAuthCallbackStageError,
1477
- state: OAuthStatePayload | null,
1550
+ _state: OAuthStatePayload | null,
1478
1551
  ): void {
1479
- observability?.error("MCP OAuth callback failed", {
1480
- "opengeni.oauth.stage": error.stage,
1481
- "opengeni.oauth.reason": error.reason,
1482
- "opengeni.oauth.provider_domain": state?.providerDomain,
1483
- "opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
1484
- "opengeni.oauth.authorization_server": state?.authorizationServer,
1485
- "opengeni.oauth.issuer": state?.issuer,
1486
- "opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
1487
- error: sanitizedError(error.cause),
1488
- });
1552
+ observability?.error("MCP OAuth callback failed", oauthPublicErrorFields(error.cause));
1489
1553
  }
1490
1554
 
1491
1555
  function logOAuthStartFailure(
1492
1556
  observability: Observability | undefined,
1493
1557
  error: OAuthStartStageError,
1494
- providerDomain: string | undefined,
1495
1558
  ): void {
1496
- observability?.warn("MCP OAuth setup failed", {
1497
- "opengeni.oauth.stage": error.stage,
1498
- "opengeni.oauth.reason": error.reason,
1499
- "opengeni.oauth.provider_domain": providerDomain,
1500
- error: sanitizedError(error.cause),
1501
- });
1559
+ observability?.warn("MCP OAuth setup failed", oauthPublicErrorFields(error.cause));
1502
1560
  }
1503
1561
 
1504
1562
  function oauthStartFailureReason(error: unknown): string {
@@ -1528,7 +1586,11 @@ function oauthCallbackFailureReason(stage: OAuthCallbackStage, error: unknown):
1528
1586
  function isDatabaseStatementTimeout(error: unknown): boolean {
1529
1587
  let current = error;
1530
1588
  for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
1531
- const candidate = current as { code?: unknown; message?: unknown; cause?: unknown };
1589
+ const candidate = current as {
1590
+ code?: unknown;
1591
+ message?: unknown;
1592
+ cause?: unknown;
1593
+ };
1532
1594
  if (
1533
1595
  candidate.code === "57014" ||
1534
1596
  (typeof candidate.message === "string" &&
@@ -1581,51 +1643,47 @@ function oauthStartStageLabel(stage: OAuthStartStage): string {
1581
1643
  function logOAuthVerificationWarning(
1582
1644
  observability: Observability | undefined,
1583
1645
  error: OAuthCallbackStageError,
1584
- state: OAuthStatePayload,
1646
+ _state: OAuthStatePayload,
1585
1647
  ): void {
1586
- observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
1587
- "opengeni.oauth.stage": error.stage,
1588
- "opengeni.oauth.reason": error.reason,
1589
- "opengeni.oauth.provider_domain": state.providerDomain,
1590
- "opengeni.oauth.resource_host": safeHost(state.resource),
1591
- "opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
1592
- "opengeni.oauth.authorization_server": state.authorizationServer,
1593
- "opengeni.oauth.issuer": state.issuer,
1594
- "opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
1595
- error: sanitizedError(error.cause),
1596
- });
1597
- }
1598
-
1599
- function sanitizedError(error: unknown): string {
1600
- if (error instanceof HTTPException) {
1601
- return `HTTPException ${error.status}: ${error.message}`;
1602
- }
1603
- if (error instanceof Error) {
1604
- return `${error.name}: ${error.message}`;
1605
- }
1606
- return String(error);
1648
+ observability?.warn(
1649
+ "MCP OAuth tools/list verification failed after token exchange",
1650
+ oauthPublicErrorFields(error.cause),
1651
+ );
1607
1652
  }
1608
1653
 
1609
- function errorMessage(error: unknown): string {
1610
- return error instanceof Error ? error.message : String(error);
1611
- }
1654
+ export type OAuthPublicErrorFields = {
1655
+ errorClass: "OAuthOperationError";
1656
+ errorCode: "oauth_operation_failed";
1657
+ status?: number;
1658
+ origin: "oauth";
1659
+ };
1612
1660
 
1613
- function safeHost(rawUrl: string): string | undefined {
1661
+ /** Allowlisted projection for public telemetry; canonical OAuth errors stay exact. */
1662
+ export function oauthPublicErrorFields(error: unknown): OAuthPublicErrorFields {
1663
+ const fields: OAuthPublicErrorFields = {
1664
+ errorClass: "OAuthOperationError",
1665
+ errorCode: "oauth_operation_failed",
1666
+ origin: "oauth",
1667
+ };
1614
1668
  try {
1615
- return new URL(rawUrl).host;
1669
+ const rawStatus =
1670
+ error instanceof HTTPException
1671
+ ? error.status
1672
+ : error && typeof error === "object"
1673
+ ? ((error as { status?: unknown; statusCode?: unknown }).status ??
1674
+ (error as { statusCode?: unknown }).statusCode)
1675
+ : undefined;
1676
+ const status = Number(rawStatus);
1677
+ if (Number.isInteger(status) && status >= 100 && status <= 599) fields.status = status;
1616
1678
  } catch {
1617
- return undefined;
1679
+ // Public telemetry is best-effort. A hostile getter/proxy must never
1680
+ // replace the exact OAuth failure with a projection failure.
1618
1681
  }
1682
+ return fields;
1619
1683
  }
1620
1684
 
1621
- function safeRequestedProviderDomain(payload: OAuthStartRequest): string | undefined {
1622
- try {
1623
- const resource = payload.mcpUrl ?? payload.resource;
1624
- if (!resource) return undefined;
1625
- return canonicalProviderDomain(payload.providerDomain ?? new URL(resource).hostname);
1626
- } catch {
1627
- return undefined;
1628
- }
1685
+ function errorMessage(error: unknown): string {
1686
+ return error instanceof Error ? error.message : String(error);
1629
1687
  }
1630
1688
 
1631
1689
  async function oauthErrorFromResponse(
@@ -1747,6 +1805,7 @@ function credentialBundle(
1747
1805
  token_type: token.tokenType,
1748
1806
  ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
1749
1807
  resource: state.resource,
1808
+ resource_parameter_supported: state.resourceParameterSupported,
1750
1809
  mcp_url: state.mcpUrl,
1751
1810
  ...(token.scopeText
1752
1811
  ? { scope: token.scopeText }
@@ -1996,6 +2055,17 @@ function chooseAuthorizeScopes(
1996
2055
  return uniqueStrings(supported);
1997
2056
  }
1998
2057
 
2058
+ export function chooseMcpAuthorizeScopes(input: {
2059
+ mcpUrl: string;
2060
+ requested: string[] | undefined;
2061
+ challenged: string[] | undefined;
2062
+ supported: string[];
2063
+ }): string[] {
2064
+ return input.mcpUrl === OFFICIAL_GMAIL_MCP_URL
2065
+ ? [...OFFICIAL_GMAIL_MCP_SCOPES]
2066
+ : chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
2067
+ }
2068
+
1999
2069
  function grantedScopes(scopeText: string | undefined, fallback: string[]): string[] {
2000
2070
  if (scopeText) {
2001
2071
  return uniqueStrings(scopeText.split(/\s+/).filter(Boolean));