@opengeni/api-router 0.5.2 → 0.5.3

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.
package/dist/app.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  validateToolRefs,
15
15
  withDefaultEnabledCapabilityMcpTools,
16
16
  workflowIdForSession
17
- } from "./chunk-YY6OAEL6.js";
17
+ } from "./chunk-3HIA43CC.js";
18
18
  export {
19
19
  allowedCorsOrigin,
20
20
  createApp,
@@ -2423,10 +2423,22 @@ function canonicalProviderDomain(value) {
2423
2423
 
2424
2424
  // src/integrations/oauth-client.ts
2425
2425
  var oauthStateTtlMs = 10 * 60 * 1e3;
2426
+ var OAuthCallbackStageError = class extends Error {
2427
+ constructor(stage2, reason, cause) {
2428
+ super(errorMessage(cause));
2429
+ this.stage = stage2;
2430
+ this.reason = reason;
2431
+ this.cause = cause;
2432
+ this.name = "OAuthCallbackStageError";
2433
+ }
2434
+ stage;
2435
+ reason;
2436
+ cause;
2437
+ };
2426
2438
  async function startMcpOAuth(deps, context) {
2427
2439
  const { db, settings } = deps;
2428
- const resource = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
2429
- const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(resource).hostname);
2440
+ const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
2441
+ const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
2430
2442
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
2431
2443
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
2432
2444
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -2435,7 +2447,8 @@ async function startMcpOAuth(deps, context) {
2435
2447
  if (context.payload.connectionId && !existing) {
2436
2448
  throw new HTTPException6(404, { message: "connection not found" });
2437
2449
  }
2438
- const discovery = await discoverMcpOAuth(resource, settings);
2450
+ const discovery = await discoverMcpOAuth(mcpUrl, settings);
2451
+ const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
2439
2452
  const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
2440
2453
  const verifier = randomPkceVerifier();
2441
2454
  const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
@@ -2445,6 +2458,7 @@ async function startMcpOAuth(deps, context) {
2445
2458
  workspaceId: context.workspaceId,
2446
2459
  subjectId: context.subjectId,
2447
2460
  providerDomain,
2461
+ mcpUrl,
2448
2462
  resource,
2449
2463
  requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
2450
2464
  authorizeScopes,
@@ -2474,24 +2488,33 @@ async function startMcpOAuth(deps, context) {
2474
2488
  });
2475
2489
  }
2476
2490
  async function completeMcpOAuthCallback(deps, input) {
2477
- const { db, settings } = deps;
2491
+ const { db, settings, observability } = deps;
2492
+ let state = null;
2478
2493
  if (!input.state) {
2479
- throw new HTTPException6(400, { message: "missing OAuth state" });
2494
+ const error = new OAuthCallbackStageError("state_verify", "state_invalid", new Error("missing OAuth state"));
2495
+ logOAuthCallbackFailure(observability, error, state);
2496
+ return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
2480
2497
  }
2481
- const state = readOAuthState(input.state, settings);
2482
- if (!input.code) {
2483
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
2484
- }
2485
- const consumed = await consumeIntegrationOAuthStateNonce(db, {
2486
- accountId: state.accountId,
2487
- workspaceId: state.workspaceId,
2488
- subjectId: state.subjectId,
2489
- nonce: state.nonce,
2490
- expiresAt: new Date(state.iat * 1e3 + oauthStateTtlMs),
2491
- now: /* @__PURE__ */ new Date()
2492
- });
2493
- if (!consumed) {
2494
- throw new HTTPException6(400, { message: "OAuth state has already been used" });
2498
+ try {
2499
+ state = readOAuthState(input.state, settings);
2500
+ if (!input.code) {
2501
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
2502
+ }
2503
+ const consumed = await consumeIntegrationOAuthStateNonce(db, {
2504
+ accountId: state.accountId,
2505
+ workspaceId: state.workspaceId,
2506
+ subjectId: state.subjectId,
2507
+ nonce: state.nonce,
2508
+ expiresAt: new Date(state.iat * 1e3 + oauthStateTtlMs),
2509
+ now: /* @__PURE__ */ new Date()
2510
+ });
2511
+ if (!consumed) {
2512
+ throw new HTTPException6(400, { message: "OAuth state has already been used" });
2513
+ }
2514
+ } catch (error) {
2515
+ const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
2516
+ logOAuthCallbackFailure(observability, staged, state);
2517
+ return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", { reason: staged.reason }) };
2495
2518
  }
2496
2519
  try {
2497
2520
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl);
@@ -2499,28 +2522,30 @@ async function completeMcpOAuthCallback(deps, input) {
2499
2522
  const key = requireEnvironmentEncryption2(settings);
2500
2523
  const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
2501
2524
  const client = await clientForState(db, settings, state);
2502
- const token = await exchangeAuthorizationCode(settings, {
2525
+ const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
2503
2526
  code: input.code,
2504
2527
  verifier,
2505
2528
  redirectUri,
2506
2529
  resource: state.resource,
2507
2530
  tokenEndpoint: state.tokenEndpoint,
2508
2531
  client
2509
- });
2510
- const tools = await verifyMcpToolsList(settings, state.resource, token);
2532
+ }));
2533
+ const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
2511
2534
  const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
2512
2535
  const credential = credentialBundle(token, state, client);
2513
2536
  const metadata = {
2514
2537
  resource: state.resource,
2538
+ mcpUrl: state.mcpUrl,
2515
2539
  authorizationServer: state.authorizationServer,
2516
2540
  authorizationServerIssuer: state.issuer,
2517
2541
  tokenEndpoint: state.tokenEndpoint,
2518
2542
  clientId: client.clientId,
2519
2543
  clientRegistrationMethod: state.clientRegistrationMethod,
2520
- mcpTools: tools
2544
+ mcpToolsVerification: verification.metadata,
2545
+ ...verification.tools ? { mcpTools: verification.tools } : {}
2521
2546
  };
2522
2547
  const credentialEncrypted = encryptEnvironmentValue3(key, JSON.stringify(credential));
2523
- const connection = state.connectionId ? await updateConnection(db, {
2548
+ const connection = await stage("persist", "persist_failed", () => state.connectionId ? updateConnection(db, {
2524
2549
  workspaceId: state.workspaceId,
2525
2550
  connectionId: state.connectionId,
2526
2551
  visibleToSubjectId: state.subjectId,
@@ -2533,7 +2558,7 @@ async function completeMcpOAuthCallback(deps, input) {
2533
2558
  expiresAt: token.expiresAt,
2534
2559
  metadata,
2535
2560
  updatedBySubjectId: state.subjectId
2536
- }) : await createConnection(db, {
2561
+ }) : createConnection(db, {
2537
2562
  accountId: state.accountId,
2538
2563
  workspaceId: state.workspaceId,
2539
2564
  subjectId: null,
@@ -2544,16 +2569,21 @@ async function completeMcpOAuthCallback(deps, input) {
2544
2569
  expiresAt: token.expiresAt,
2545
2570
  metadata,
2546
2571
  createdBySubjectId: state.subjectId
2547
- });
2572
+ }));
2548
2573
  if (!connection) {
2549
2574
  throw new HTTPException6(409, { message: "connection changed during OAuth reconnect; start again" });
2550
2575
  }
2551
- return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id, providerDomain: connection.providerDomain }) };
2576
+ return {
2577
+ redirectTo: callbackReturnPath(state.returnPath, "success", {
2578
+ connectionId: connection.id,
2579
+ providerDomain: connection.providerDomain,
2580
+ ...verification.metadata.status === "failed" ? { verification: "failed" } : {}
2581
+ })
2582
+ };
2552
2583
  } catch (error) {
2553
- if (error instanceof HTTPException6 && error.status >= 400 && error.status < 500) {
2554
- throw error;
2555
- }
2556
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "oauth_callback_failed" }) };
2584
+ const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("persist", "persist_failed", error);
2585
+ logOAuthCallbackFailure(observability, staged, state);
2586
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
2557
2587
  }
2558
2588
  }
2559
2589
  function integrationBaseUrl(publicBaseUrl, requestUrl) {
@@ -2805,12 +2835,14 @@ function readOAuthState(state, settings) {
2805
2835
  if (iat === void 0 || nowSeconds - iat > oauthStateTtlMs / 1e3 || nowSeconds < iat) {
2806
2836
  throw new HTTPException6(400, { message: "invalid or expired OAuth state" });
2807
2837
  }
2838
+ const resource = requiredString(payload.resource, "state.resource");
2808
2839
  const parsed = {
2809
2840
  accountId: requiredString(payload.accountId, "state.accountId"),
2810
2841
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
2811
2842
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
2812
2843
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
2813
- resource: requiredString(payload.resource, "state.resource"),
2844
+ mcpUrl: stringValue(payload.mcpUrl) ?? resource,
2845
+ resource,
2814
2846
  requestedScopes: stringArray(payload.requestedScopes),
2815
2847
  authorizeScopes: stringArray(payload.authorizeScopes),
2816
2848
  encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
@@ -2886,7 +2918,8 @@ async function exchangeAuthorizationCode(settings, input) {
2886
2918
  }
2887
2919
  const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
2888
2920
  if (!response.ok) {
2889
- throw new Error(`OAuth token endpoint returned HTTP ${response.status}`);
2921
+ const oauthError = await oauthErrorFromResponse(response);
2922
+ throw new OAuthCallbackStageError("token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`));
2890
2923
  }
2891
2924
  const payload = await response.json();
2892
2925
  const accessToken = stringValue(payload.access_token);
@@ -2902,6 +2935,72 @@ async function exchangeAuthorizationCode(settings, input) {
2902
2935
  ...stringValue(payload.scope) ? { scopeText: stringValue(payload.scope) } : {}
2903
2936
  };
2904
2937
  }
2938
+ async function stage(stage2, fallbackReason, fn) {
2939
+ try {
2940
+ return await fn();
2941
+ } catch (error) {
2942
+ if (error instanceof OAuthCallbackStageError) {
2943
+ throw error;
2944
+ }
2945
+ throw new OAuthCallbackStageError(stage2, fallbackReason, error);
2946
+ }
2947
+ }
2948
+ function logOAuthCallbackFailure(observability, error, state) {
2949
+ observability?.error("MCP OAuth callback failed", {
2950
+ "opengeni.oauth.stage": error.stage,
2951
+ "opengeni.oauth.reason": error.reason,
2952
+ "opengeni.oauth.provider_domain": state?.providerDomain,
2953
+ "opengeni.oauth.resource_host": state ? safeHost(state.resource) : void 0,
2954
+ "opengeni.oauth.authorization_server": state?.authorizationServer,
2955
+ "opengeni.oauth.issuer": state?.issuer,
2956
+ "opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
2957
+ error: sanitizedError(error.cause)
2958
+ });
2959
+ }
2960
+ function logOAuthVerificationWarning(observability, error, state) {
2961
+ observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
2962
+ "opengeni.oauth.stage": error.stage,
2963
+ "opengeni.oauth.reason": error.reason,
2964
+ "opengeni.oauth.provider_domain": state.providerDomain,
2965
+ "opengeni.oauth.resource_host": safeHost(state.resource),
2966
+ "opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
2967
+ "opengeni.oauth.authorization_server": state.authorizationServer,
2968
+ "opengeni.oauth.issuer": state.issuer,
2969
+ "opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
2970
+ error: sanitizedError(error.cause)
2971
+ });
2972
+ }
2973
+ function sanitizedError(error) {
2974
+ if (error instanceof HTTPException6) {
2975
+ return `HTTPException ${error.status}: ${error.message}`;
2976
+ }
2977
+ if (error instanceof Error) {
2978
+ return `${error.name}: ${error.message}`;
2979
+ }
2980
+ return String(error);
2981
+ }
2982
+ function errorMessage(error) {
2983
+ return error instanceof Error ? error.message : String(error);
2984
+ }
2985
+ function safeHost(rawUrl) {
2986
+ try {
2987
+ return new URL(rawUrl).host;
2988
+ } catch {
2989
+ return void 0;
2990
+ }
2991
+ }
2992
+ async function oauthErrorFromResponse(response) {
2993
+ const contentType = response.headers.get("content-type") ?? "";
2994
+ if (!contentType.toLowerCase().includes("application/json")) {
2995
+ return null;
2996
+ }
2997
+ const payload = await response.clone().json().catch(() => null);
2998
+ const error = stringValue(payload?.error);
2999
+ if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
3000
+ return null;
3001
+ }
3002
+ return error;
3003
+ }
2905
3004
  async function verifyMcpToolsList(settings, resource, token) {
2906
3005
  await assertOAuthFetchAllowed(resource, settings);
2907
3006
  const client = new Client2({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
@@ -2922,6 +3021,29 @@ async function verifyMcpToolsList(settings, resource, token) {
2922
3021
  await client.close().catch(() => void 0);
2923
3022
  }
2924
3023
  }
3024
+ async function verifyMcpToolsListNonFatal(observability, settings, state, token) {
3025
+ try {
3026
+ const tools = await stage("tools_list", "tools_list_failed", () => verifyMcpToolsList(settings, state.mcpUrl, token));
3027
+ return {
3028
+ metadata: {
3029
+ status: "ok",
3030
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3031
+ toolCount: tools.length
3032
+ },
3033
+ tools
3034
+ };
3035
+ } catch (error) {
3036
+ const staged = error instanceof OAuthCallbackStageError ? error : new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
3037
+ logOAuthVerificationWarning(observability, staged, state);
3038
+ return {
3039
+ metadata: {
3040
+ status: "failed",
3041
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3042
+ reason: staged.reason
3043
+ }
3044
+ };
3045
+ }
3046
+ }
2925
3047
  function credentialBundle(token, state, client) {
2926
3048
  return {
2927
3049
  access_token: token.accessToken,
@@ -2929,6 +3051,7 @@ function credentialBundle(token, state, client) {
2929
3051
  token_type: token.tokenType,
2930
3052
  ...token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {},
2931
3053
  resource: state.resource,
3054
+ mcp_url: state.mcpUrl,
2932
3055
  ...token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {},
2933
3056
  token_endpoint: state.tokenEndpoint,
2934
3057
  client_id: client.clientId,
@@ -2956,6 +3079,22 @@ function canonicalMcpResource(value) {
2956
3079
  url.hash = "";
2957
3080
  return url.toString();
2958
3081
  }
3082
+ function canonicalOAuthResource(value) {
3083
+ const trimmed = value.trim();
3084
+ if (!trimmed) {
3085
+ throw new HTTPException6(422, { message: "MCP protected resource metadata advertised an invalid resource" });
3086
+ }
3087
+ try {
3088
+ const url = new URL(trimmed);
3089
+ if (url.protocol === "http:" || url.protocol === "https:") {
3090
+ url.hash = "";
3091
+ return url.toString();
3092
+ }
3093
+ return trimmed;
3094
+ } catch {
3095
+ throw new HTTPException6(422, { message: "MCP protected resource metadata advertised an invalid resource" });
3096
+ }
3097
+ }
2959
3098
  function safeReturnPath(value) {
2960
3099
  if (!value.startsWith("/") || value.startsWith("//")) {
2961
3100
  throw new HTTPException6(400, { message: "OAuth returnPath must be a relative path" });
@@ -3117,7 +3256,7 @@ function requiredString(value, field) {
3117
3256
 
3118
3257
  // src/routes/connections.ts
3119
3258
  function registerConnectionRoutes(app, deps) {
3120
- const { db, settings } = deps;
3259
+ const { db, settings, observability } = deps;
3121
3260
  function assertIntegrationsEnabled() {
3122
3261
  if (!settings.integrationsEnabled) {
3123
3262
  throw new HTTPException7(404, { message: "integrations are not enabled for this deployment" });
@@ -3210,7 +3349,7 @@ function registerConnectionRoutes(app, deps) {
3210
3349
  throw new HTTPException7(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
3211
3350
  }
3212
3351
  const payload = parsed.data;
3213
- const result = await startMcpOAuth({ db, settings }, {
3352
+ const result = await startMcpOAuth({ db, settings, observability }, {
3214
3353
  accountId: grant.accountId,
3215
3354
  workspaceId,
3216
3355
  subjectId: grant.subjectId,
@@ -3221,7 +3360,7 @@ function registerConnectionRoutes(app, deps) {
3221
3360
  });
3222
3361
  app.get("/v1/integrations/oauth/callback", async (c) => {
3223
3362
  assertIntegrationsEnabled();
3224
- const result = await completeMcpOAuthCallback({ db, settings }, {
3363
+ const result = await completeMcpOAuthCallback({ db, settings, observability }, {
3225
3364
  code: c.req.query("code"),
3226
3365
  state: c.req.query("state"),
3227
3366
  requestUrl: c.req.url
@@ -8096,4 +8235,4 @@ export {
8096
8235
  withDefaultEnabledCapabilityMcpTools,
8097
8236
  workflowIdForSession3 as workflowIdForSession
8098
8237
  };
8099
- //# sourceMappingURL=chunk-YY6OAEL6.js.map
8238
+ //# sourceMappingURL=chunk-3HIA43CC.js.map