@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/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-YY6OAEL6.js";
3
+ } from "./chunk-3HIA43CC.js";
4
4
 
5
5
  // src/index.ts
6
6
  import { dbSearchPath, getSettings, resolveNatsCalloutConfig, resolveNatsControlPlaneAuth, retryStartupDependency, startupRetryOptions } from "@opengeni/config";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -49,13 +49,13 @@
49
49
  "@opengeni/codex": "^0.2.1",
50
50
  "@opengeni/config": "^0.4.0",
51
51
  "@opengeni/contracts": "^0.9.0",
52
- "@opengeni/core": "^0.4.5",
52
+ "@opengeni/core": "^0.4.6",
53
53
  "@opengeni/db": "^0.6.1",
54
54
  "@opengeni/documents": "^0.2.8",
55
55
  "@opengeni/events": "^0.2.8",
56
56
  "@opengeni/github": "^0.2.8",
57
- "@opengeni/observability": "^0.2.1",
58
- "@opengeni/runtime": "^0.6.0",
57
+ "@opengeni/observability": "^0.3.0",
58
+ "@opengeni/runtime": "^0.6.1",
59
59
  "@opengeni/storage": "^0.2.8",
60
60
  "@temporalio/client": "^1.17.0",
61
61
  "better-auth": "^1.6.14",
@@ -4,6 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
5
  import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
6
6
  import { requireEnvironmentEncryption } from "@opengeni/core";
7
+ import type { Observability } from "@opengeni/observability";
7
8
  import {
8
9
  consumeIntegrationOAuthStateNonce,
9
10
  createConnection,
@@ -29,6 +30,7 @@ export const oauthStateTtlMs = 10 * 60 * 1000;
29
30
  type OAuthClientDeps = {
30
31
  db: Database;
31
32
  settings: Settings;
33
+ observability?: Observability | undefined;
32
34
  };
33
35
 
34
36
  export type OAuthStartContext = {
@@ -81,6 +83,7 @@ type OAuthStatePayload = {
81
83
  workspaceId: string;
82
84
  subjectId: string;
83
85
  providerDomain: string;
86
+ mcpUrl: string;
84
87
  resource: string;
85
88
  requestedScopes: string[];
86
89
  authorizeScopes: string[];
@@ -107,13 +110,26 @@ type TokenResponse = {
107
110
  raw: Record<string, unknown>;
108
111
  };
109
112
 
113
+ type OAuthCallbackStage = "state_verify" | "token_exchange" | "tools_list" | "persist";
114
+
115
+ class OAuthCallbackStageError extends Error {
116
+ constructor(
117
+ readonly stage: OAuthCallbackStage,
118
+ readonly reason: string,
119
+ readonly cause: unknown,
120
+ ) {
121
+ super(errorMessage(cause));
122
+ this.name = "OAuthCallbackStageError";
123
+ }
124
+ }
125
+
110
126
  export async function startMcpOAuth(
111
127
  deps: OAuthClientDeps,
112
128
  context: OAuthStartContext,
113
129
  ): Promise<OAuthStartResponse> {
114
130
  const { db, settings } = deps;
115
- const resource = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
116
- const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(resource).hostname);
131
+ const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
132
+ const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
117
133
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
118
134
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
119
135
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -125,7 +141,8 @@ export async function startMcpOAuth(
125
141
  throw new HTTPException(404, { message: "connection not found" });
126
142
  }
127
143
 
128
- const discovery = await discoverMcpOAuth(resource, settings);
144
+ const discovery = await discoverMcpOAuth(mcpUrl, settings);
145
+ const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
129
146
  const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
130
147
  const verifier = randomPkceVerifier();
131
148
  const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
@@ -135,6 +152,7 @@ export async function startMcpOAuth(
135
152
  workspaceId: context.workspaceId,
136
153
  subjectId: context.subjectId,
137
154
  providerDomain,
155
+ mcpUrl,
138
156
  resource,
139
157
  requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
140
158
  authorizeScopes,
@@ -168,24 +186,33 @@ export async function completeMcpOAuthCallback(
168
186
  deps: OAuthClientDeps,
169
187
  input: { code?: string | undefined; state?: string | undefined; requestUrl: string },
170
188
  ): Promise<OAuthCallbackResult> {
171
- const { db, settings } = deps;
189
+ const { db, settings, observability } = deps;
190
+ let state: OAuthStatePayload | null = null;
172
191
  if (!input.state) {
173
- throw new HTTPException(400, { message: "missing OAuth state" });
174
- }
175
- const state = readOAuthState(input.state, settings);
176
- if (!input.code) {
177
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
178
- }
179
- const consumed = await consumeIntegrationOAuthStateNonce(db, {
180
- accountId: state.accountId,
181
- workspaceId: state.workspaceId,
182
- subjectId: state.subjectId,
183
- nonce: state.nonce,
184
- expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
185
- now: new Date(),
186
- });
187
- if (!consumed) {
188
- throw new HTTPException(400, { message: "OAuth state has already been used" });
192
+ const error = new OAuthCallbackStageError("state_verify", "state_invalid", new Error("missing OAuth state"));
193
+ logOAuthCallbackFailure(observability, error, state);
194
+ return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
195
+ }
196
+ try {
197
+ state = readOAuthState(input.state, settings);
198
+ if (!input.code) {
199
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
200
+ }
201
+ const consumed = await consumeIntegrationOAuthStateNonce(db, {
202
+ accountId: state.accountId,
203
+ workspaceId: state.workspaceId,
204
+ subjectId: state.subjectId,
205
+ nonce: state.nonce,
206
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
207
+ now: new Date(),
208
+ });
209
+ if (!consumed) {
210
+ throw new HTTPException(400, { message: "OAuth state has already been used" });
211
+ }
212
+ } catch (error) {
213
+ const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
214
+ logOAuthCallbackFailure(observability, staged, state);
215
+ return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", { reason: staged.reason }) };
189
216
  }
190
217
 
191
218
  try {
@@ -194,29 +221,31 @@ export async function completeMcpOAuthCallback(
194
221
  const key = requireEnvironmentEncryption(settings);
195
222
  const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
196
223
  const client = await clientForState(db, settings, state);
197
- const token = await exchangeAuthorizationCode(settings, {
198
- code: input.code,
224
+ const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
225
+ code: input.code!,
199
226
  verifier,
200
227
  redirectUri,
201
228
  resource: state.resource,
202
229
  tokenEndpoint: state.tokenEndpoint,
203
230
  client,
204
- });
205
- const tools = await verifyMcpToolsList(settings, state.resource, token);
231
+ }));
232
+ const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
206
233
  const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
207
234
  const credential = credentialBundle(token, state, client);
208
235
  const metadata = {
209
236
  resource: state.resource,
237
+ mcpUrl: state.mcpUrl,
210
238
  authorizationServer: state.authorizationServer,
211
239
  authorizationServerIssuer: state.issuer,
212
240
  tokenEndpoint: state.tokenEndpoint,
213
241
  clientId: client.clientId,
214
242
  clientRegistrationMethod: state.clientRegistrationMethod,
215
- mcpTools: tools,
243
+ mcpToolsVerification: verification.metadata,
244
+ ...(verification.tools ? { mcpTools: verification.tools } : {}),
216
245
  };
217
246
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
218
- const connection = state.connectionId
219
- ? await updateConnection(db, {
247
+ const connection = await stage("persist", "persist_failed", () => state.connectionId
248
+ ? updateConnection(db, {
220
249
  workspaceId: state.workspaceId,
221
250
  connectionId: state.connectionId,
222
251
  visibleToSubjectId: state.subjectId,
@@ -230,7 +259,7 @@ export async function completeMcpOAuthCallback(
230
259
  metadata,
231
260
  updatedBySubjectId: state.subjectId,
232
261
  })
233
- : await createConnection(db, {
262
+ : createConnection(db, {
234
263
  accountId: state.accountId,
235
264
  workspaceId: state.workspaceId,
236
265
  subjectId: null,
@@ -241,7 +270,7 @@ export async function completeMcpOAuthCallback(
241
270
  expiresAt: token.expiresAt,
242
271
  metadata,
243
272
  createdBySubjectId: state.subjectId,
244
- });
273
+ }));
245
274
  if (!connection) {
246
275
  throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
247
276
  }
@@ -249,12 +278,19 @@ export async function completeMcpOAuthCallback(
249
278
  // the enable connectionRef straight from the redirect, without a listConnections
250
279
  // round-trip that could fail (transient, or a grant lacking connections:read)
251
280
  // and leave the connection created but the capability un-enabled.
252
- return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id, providerDomain: connection.providerDomain }) };
281
+ return {
282
+ redirectTo: callbackReturnPath(state.returnPath, "success", {
283
+ connectionId: connection.id,
284
+ providerDomain: connection.providerDomain,
285
+ ...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
286
+ }),
287
+ };
253
288
  } catch (error) {
254
- if (error instanceof HTTPException && error.status >= 400 && error.status < 500) {
255
- throw error;
256
- }
257
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "oauth_callback_failed" }) };
289
+ const staged = error instanceof OAuthCallbackStageError
290
+ ? error
291
+ : new OAuthCallbackStageError("persist", "persist_failed", error);
292
+ logOAuthCallbackFailure(observability, staged, state);
293
+ return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
258
294
  }
259
295
  }
260
296
 
@@ -555,12 +591,14 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
555
591
  if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
556
592
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
557
593
  }
594
+ const resource = requiredString(payload.resource, "state.resource");
558
595
  const parsed = {
559
596
  accountId: requiredString(payload.accountId, "state.accountId"),
560
597
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
561
598
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
562
599
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
563
- resource: requiredString(payload.resource, "state.resource"),
600
+ mcpUrl: stringValue(payload.mcpUrl) ?? resource,
601
+ resource,
564
602
  requestedScopes: stringArray(payload.requestedScopes),
565
603
  authorizeScopes: stringArray(payload.authorizeScopes),
566
604
  encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
@@ -648,7 +686,8 @@ async function exchangeAuthorizationCode(
648
686
  }
649
687
  const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
650
688
  if (!response.ok) {
651
- throw new Error(`OAuth token endpoint returned HTTP ${response.status}`);
689
+ const oauthError = await oauthErrorFromResponse(response);
690
+ throw new OAuthCallbackStageError("token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`));
652
691
  }
653
692
  const payload = await response.json() as Record<string, unknown>;
654
693
  const accessToken = stringValue(payload.access_token);
@@ -665,6 +704,91 @@ async function exchangeAuthorizationCode(
665
704
  };
666
705
  }
667
706
 
707
+ async function stage<T>(
708
+ stage: OAuthCallbackStage,
709
+ fallbackReason: string,
710
+ fn: () => Promise<T>,
711
+ ): Promise<T> {
712
+ try {
713
+ return await fn();
714
+ } catch (error) {
715
+ if (error instanceof OAuthCallbackStageError) {
716
+ throw error;
717
+ }
718
+ throw new OAuthCallbackStageError(stage, fallbackReason, error);
719
+ }
720
+ }
721
+
722
+ function logOAuthCallbackFailure(
723
+ observability: Observability | undefined,
724
+ error: OAuthCallbackStageError,
725
+ state: OAuthStatePayload | null,
726
+ ): void {
727
+ observability?.error("MCP OAuth callback failed", {
728
+ "opengeni.oauth.stage": error.stage,
729
+ "opengeni.oauth.reason": error.reason,
730
+ "opengeni.oauth.provider_domain": state?.providerDomain,
731
+ "opengeni.oauth.resource_host": state ? safeHost(state.resource) : undefined,
732
+ "opengeni.oauth.authorization_server": state?.authorizationServer,
733
+ "opengeni.oauth.issuer": state?.issuer,
734
+ "opengeni.oauth.client_registration_method": state?.clientRegistrationMethod,
735
+ error: sanitizedError(error.cause),
736
+ });
737
+ }
738
+
739
+ function logOAuthVerificationWarning(
740
+ observability: Observability | undefined,
741
+ error: OAuthCallbackStageError,
742
+ state: OAuthStatePayload,
743
+ ): void {
744
+ observability?.warn("MCP OAuth tools/list verification failed after token exchange", {
745
+ "opengeni.oauth.stage": error.stage,
746
+ "opengeni.oauth.reason": error.reason,
747
+ "opengeni.oauth.provider_domain": state.providerDomain,
748
+ "opengeni.oauth.resource_host": safeHost(state.resource),
749
+ "opengeni.oauth.mcp_host": safeHost(state.mcpUrl),
750
+ "opengeni.oauth.authorization_server": state.authorizationServer,
751
+ "opengeni.oauth.issuer": state.issuer,
752
+ "opengeni.oauth.client_registration_method": state.clientRegistrationMethod,
753
+ error: sanitizedError(error.cause),
754
+ });
755
+ }
756
+
757
+ function sanitizedError(error: unknown): string {
758
+ if (error instanceof HTTPException) {
759
+ return `HTTPException ${error.status}: ${error.message}`;
760
+ }
761
+ if (error instanceof Error) {
762
+ return `${error.name}: ${error.message}`;
763
+ }
764
+ return String(error);
765
+ }
766
+
767
+ function errorMessage(error: unknown): string {
768
+ return error instanceof Error ? error.message : String(error);
769
+ }
770
+
771
+ function safeHost(rawUrl: string): string | undefined {
772
+ try {
773
+ return new URL(rawUrl).host;
774
+ } catch {
775
+ return undefined;
776
+ }
777
+ }
778
+
779
+ async function oauthErrorFromResponse(response: Response): Promise<string | null> {
780
+ const contentType = response.headers.get("content-type") ?? "";
781
+ if (!contentType.toLowerCase().includes("application/json")) {
782
+ return null;
783
+ }
784
+ const payload = await response.clone().json().catch(() => null) as Record<string, unknown> | null;
785
+ const error = stringValue(payload?.error);
786
+ if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
787
+ return null;
788
+ }
789
+ return error;
790
+ }
791
+
668
792
  async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
669
793
  await assertOAuthFetchAllowed(resource, settings);
670
794
  const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
@@ -686,6 +810,42 @@ async function verifyMcpToolsList(settings: Settings, resource: string, token: T
686
810
  }
687
811
  }
688
812
 
813
+ async function verifyMcpToolsListNonFatal(
814
+ observability: Observability | undefined,
815
+ settings: Settings,
816
+ state: OAuthStatePayload,
817
+ token: TokenResponse,
818
+ ): Promise<{
819
+ metadata:
820
+ | { status: "ok"; checkedAt: string; toolCount: number }
821
+ | { status: "failed"; checkedAt: string; reason: string };
822
+ tools?: Array<{ name: string; description?: string }>;
823
+ }> {
824
+ try {
825
+ const tools = await stage("tools_list", "tools_list_failed", () => verifyMcpToolsList(settings, state.mcpUrl, token));
826
+ return {
827
+ metadata: {
828
+ status: "ok",
829
+ checkedAt: new Date().toISOString(),
830
+ toolCount: tools.length,
831
+ },
832
+ tools,
833
+ };
834
+ } catch (error) {
835
+ const staged = error instanceof OAuthCallbackStageError
836
+ ? error
837
+ : new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
838
+ logOAuthVerificationWarning(observability, staged, state);
839
+ return {
840
+ metadata: {
841
+ status: "failed",
842
+ checkedAt: new Date().toISOString(),
843
+ reason: staged.reason,
844
+ },
845
+ };
846
+ }
847
+ }
848
+
689
849
  function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
690
850
  return {
691
851
  access_token: token.accessToken,
@@ -693,6 +853,7 @@ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client
693
853
  token_type: token.tokenType,
694
854
  ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
695
855
  resource: state.resource,
856
+ mcp_url: state.mcpUrl,
696
857
  ...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
697
858
  token_endpoint: state.tokenEndpoint,
698
859
  client_id: client.clientId,
@@ -723,6 +884,23 @@ function canonicalMcpResource(value: string | undefined): string {
723
884
  return url.toString();
724
885
  }
725
886
 
887
+ function canonicalOAuthResource(value: string): string {
888
+ const trimmed = value.trim();
889
+ if (!trimmed) {
890
+ throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
891
+ }
892
+ try {
893
+ const url = new URL(trimmed);
894
+ if (url.protocol === "http:" || url.protocol === "https:") {
895
+ url.hash = "";
896
+ return url.toString();
897
+ }
898
+ return trimmed;
899
+ } catch {
900
+ throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
901
+ }
902
+ }
903
+
726
904
  function safeReturnPath(value: string): string {
727
905
  if (!value.startsWith("/") || value.startsWith("//")) {
728
906
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
@@ -27,7 +27,7 @@ import {
27
27
  import { canonicalProviderDomain } from "../integrations/provider-domain";
28
28
 
29
29
  export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
30
- const { db, settings } = deps;
30
+ const { db, settings, observability } = deps;
31
31
 
32
32
  function assertIntegrationsEnabled(): void {
33
33
  if (!settings.integrationsEnabled) {
@@ -131,7 +131,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
131
131
  throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
132
132
  }
133
133
  const payload = parsed.data;
134
- const result = await startMcpOAuth({ db, settings }, {
134
+ const result = await startMcpOAuth({ db, settings, observability }, {
135
135
  accountId: grant.accountId,
136
136
  workspaceId,
137
137
  subjectId: grant.subjectId,
@@ -143,7 +143,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
143
143
 
144
144
  app.get("/v1/integrations/oauth/callback", async (c) => {
145
145
  assertIntegrationsEnabled();
146
- const result = await completeMcpOAuthCallback({ db, settings }, {
146
+ const result = await completeMcpOAuthCallback({ db, settings, observability }, {
147
147
  code: c.req.query("code"),
148
148
  state: c.req.query("state"),
149
149
  requestUrl: c.req.url,