@opengeni/api-router 0.5.1 → 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-DQ5TIRDZ.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.1",
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": {
@@ -47,16 +47,16 @@
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
48
  "@opengeni/agent-proto": "^0.2.1",
49
49
  "@opengeni/codex": "^0.2.1",
50
- "@opengeni/config": "^0.3.0",
50
+ "@opengeni/config": "^0.4.0",
51
51
  "@opengeni/contracts": "^0.9.0",
52
- "@opengeni/core": "^0.4.4",
53
- "@opengeni/db": "^0.6.0",
54
- "@opengeni/documents": "^0.2.7",
55
- "@opengeni/events": "^0.2.7",
56
- "@opengeni/github": "^0.2.7",
57
- "@opengeni/observability": "^0.2.1",
58
- "@opengeni/runtime": "^0.5.0",
59
- "@opengeni/storage": "^0.2.7",
52
+ "@opengeni/core": "^0.4.6",
53
+ "@opengeni/db": "^0.6.1",
54
+ "@opengeni/documents": "^0.2.8",
55
+ "@opengeni/events": "^0.2.8",
56
+ "@opengeni/github": "^0.2.8",
57
+ "@opengeni/observability": "^0.3.0",
58
+ "@opengeni/runtime": "^0.6.1",
59
+ "@opengeni/storage": "^0.2.8",
60
60
  "@temporalio/client": "^1.17.0",
61
61
  "better-auth": "^1.6.14",
62
62
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -21,6 +21,7 @@ import { buildOpenGeniMcpServer } from "./mcp/server";
21
21
  import { isToolspaceGrant, prepareToolspaceMcpSurface } from "./mcp/toolspace";
22
22
  import { requireAccessKey } from "./http/auth";
23
23
  import { registerCapabilityRoutes } from "./routes/capabilities";
24
+ import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
24
25
  import { registerCodexRoutes } from "./routes/codex";
25
26
  import { registerConnectionRoutes } from "./routes/connections";
26
27
  import { registerDocumentRoutes } from "./routes/documents";
@@ -246,6 +247,7 @@ export function createApp(deps: AppDependencies): Hono {
246
247
  registerSocialRoutes(app, routeDeps);
247
248
  registerConnectionRoutes(app, routeDeps);
248
249
  registerCapabilityRoutes(app, routeDeps);
250
+ registerCatalogAssetRoutes(app, routeDeps);
249
251
  registerEnrollmentRoutes(app, routeDeps);
250
252
  registerMachineRoutes(app, routeDeps);
251
253
  registerEnvironmentRoutes(app, routeDeps);
@@ -426,6 +428,7 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
426
428
  { pattern: /^\/v1\/workspaces\/[^/]+\/connections$/, label: "/v1/workspaces/:workspaceId/connections" },
427
429
  { pattern: /^\/v1\/workspaces\/[^/]+\/connections\/oauth\/start$/, label: "/v1/workspaces/:workspaceId/connections/oauth/start" },
428
430
  { pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/, label: "/v1/workspaces/:workspaceId/connections/:connectionId" },
431
+ { pattern: /^\/v1\/catalog-assets\/.+$/, label: "/v1/catalog-assets/*" },
429
432
  { pattern: /^\/v1\/integrations\/oauth\/callback$/, label: "/v1/integrations/oauth/callback" },
430
433
  { pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/, label: "/v1/integrations/oauth/client-metadata.json" },
431
434
  { pattern: /^\/v1\/enrollments\/device\/start$/, label: "/v1/enrollments/device/start" },
package/src/http/auth.ts CHANGED
@@ -52,6 +52,12 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
52
52
  ) {
53
53
  return true;
54
54
  }
55
+ // Catalog logos are rendered via bare <img> tags, which carry no credentials;
56
+ // the images are public vendor logos, digest-keyed by content, and the route
57
+ // itself enforces the catalog-assets/ prefix lock and extension whitelist.
58
+ if (path.startsWith("/v1/catalog-assets/")) {
59
+ return true;
60
+ }
55
61
  // Browser entry for MCP-issued GitHub install links: opened in a browser
56
62
  // that holds no API credentials, like the callbacks above. The route itself
57
63
  // verifies the signed workspace-bound state before doing anything.
@@ -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,
@@ -22,12 +23,14 @@ import { createHash, randomBytes } from "node:crypto";
22
23
  import { lookup } from "node:dns/promises";
23
24
  import { isIP } from "node:net";
24
25
  import { HTTPException } from "hono/http-exception";
26
+ import { canonicalProviderDomain } from "./provider-domain";
25
27
 
26
28
  export const oauthStateTtlMs = 10 * 60 * 1000;
27
29
 
28
30
  type OAuthClientDeps = {
29
31
  db: Database;
30
32
  settings: Settings;
33
+ observability?: Observability | undefined;
31
34
  };
32
35
 
33
36
  export type OAuthStartContext = {
@@ -80,6 +83,7 @@ type OAuthStatePayload = {
80
83
  workspaceId: string;
81
84
  subjectId: string;
82
85
  providerDomain: string;
86
+ mcpUrl: string;
83
87
  resource: string;
84
88
  requestedScopes: string[];
85
89
  authorizeScopes: string[];
@@ -106,13 +110,26 @@ type TokenResponse = {
106
110
  raw: Record<string, unknown>;
107
111
  };
108
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
+
109
126
  export async function startMcpOAuth(
110
127
  deps: OAuthClientDeps,
111
128
  context: OAuthStartContext,
112
129
  ): Promise<OAuthStartResponse> {
113
130
  const { db, settings } = deps;
114
- const resource = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
115
- 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);
116
133
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
117
134
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
118
135
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -124,7 +141,8 @@ export async function startMcpOAuth(
124
141
  throw new HTTPException(404, { message: "connection not found" });
125
142
  }
126
143
 
127
- const discovery = await discoverMcpOAuth(resource, settings);
144
+ const discovery = await discoverMcpOAuth(mcpUrl, settings);
145
+ const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
128
146
  const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
129
147
  const verifier = randomPkceVerifier();
130
148
  const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
@@ -134,6 +152,7 @@ export async function startMcpOAuth(
134
152
  workspaceId: context.workspaceId,
135
153
  subjectId: context.subjectId,
136
154
  providerDomain,
155
+ mcpUrl,
137
156
  resource,
138
157
  requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
139
158
  authorizeScopes,
@@ -167,24 +186,33 @@ export async function completeMcpOAuthCallback(
167
186
  deps: OAuthClientDeps,
168
187
  input: { code?: string | undefined; state?: string | undefined; requestUrl: string },
169
188
  ): Promise<OAuthCallbackResult> {
170
- const { db, settings } = deps;
189
+ const { db, settings, observability } = deps;
190
+ let state: OAuthStatePayload | null = null;
171
191
  if (!input.state) {
172
- throw new HTTPException(400, { message: "missing OAuth state" });
173
- }
174
- const state = readOAuthState(input.state, settings);
175
- if (!input.code) {
176
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
177
- }
178
- const consumed = await consumeIntegrationOAuthStateNonce(db, {
179
- accountId: state.accountId,
180
- workspaceId: state.workspaceId,
181
- subjectId: state.subjectId,
182
- nonce: state.nonce,
183
- expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
184
- now: new Date(),
185
- });
186
- if (!consumed) {
187
- 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 }) };
188
216
  }
189
217
 
190
218
  try {
@@ -193,29 +221,31 @@ export async function completeMcpOAuthCallback(
193
221
  const key = requireEnvironmentEncryption(settings);
194
222
  const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
195
223
  const client = await clientForState(db, settings, state);
196
- const token = await exchangeAuthorizationCode(settings, {
197
- code: input.code,
224
+ const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
225
+ code: input.code!,
198
226
  verifier,
199
227
  redirectUri,
200
228
  resource: state.resource,
201
229
  tokenEndpoint: state.tokenEndpoint,
202
230
  client,
203
- });
204
- const tools = await verifyMcpToolsList(settings, state.resource, token);
231
+ }));
232
+ const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
205
233
  const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
206
234
  const credential = credentialBundle(token, state, client);
207
235
  const metadata = {
208
236
  resource: state.resource,
237
+ mcpUrl: state.mcpUrl,
209
238
  authorizationServer: state.authorizationServer,
210
239
  authorizationServerIssuer: state.issuer,
211
240
  tokenEndpoint: state.tokenEndpoint,
212
241
  clientId: client.clientId,
213
242
  clientRegistrationMethod: state.clientRegistrationMethod,
214
- mcpTools: tools,
243
+ mcpToolsVerification: verification.metadata,
244
+ ...(verification.tools ? { mcpTools: verification.tools } : {}),
215
245
  };
216
246
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
217
- const connection = state.connectionId
218
- ? await updateConnection(db, {
247
+ const connection = await stage("persist", "persist_failed", () => state.connectionId
248
+ ? updateConnection(db, {
219
249
  workspaceId: state.workspaceId,
220
250
  connectionId: state.connectionId,
221
251
  visibleToSubjectId: state.subjectId,
@@ -229,7 +259,7 @@ export async function completeMcpOAuthCallback(
229
259
  metadata,
230
260
  updatedBySubjectId: state.subjectId,
231
261
  })
232
- : await createConnection(db, {
262
+ : createConnection(db, {
233
263
  accountId: state.accountId,
234
264
  workspaceId: state.workspaceId,
235
265
  subjectId: null,
@@ -240,16 +270,27 @@ export async function completeMcpOAuthCallback(
240
270
  expiresAt: token.expiresAt,
241
271
  metadata,
242
272
  createdBySubjectId: state.subjectId,
243
- });
273
+ }));
244
274
  if (!connection) {
245
275
  throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
246
276
  }
247
- return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id }) };
277
+ // Carry the canonical providerDomain (not just the id) so the SPA can build
278
+ // the enable connectionRef straight from the redirect, without a listConnections
279
+ // round-trip that could fail (transient, or a grant lacking connections:read)
280
+ // and leave the connection created but the capability un-enabled.
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
+ };
248
288
  } catch (error) {
249
- if (error instanceof HTTPException && error.status >= 400 && error.status < 500) {
250
- throw error;
251
- }
252
- 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 }) };
253
294
  }
254
295
  }
255
296
 
@@ -550,12 +591,14 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
550
591
  if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
551
592
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
552
593
  }
594
+ const resource = requiredString(payload.resource, "state.resource");
553
595
  const parsed = {
554
596
  accountId: requiredString(payload.accountId, "state.accountId"),
555
597
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
556
598
  subjectId: requiredString(payload.subjectId, "state.subjectId"),
557
599
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
558
- resource: requiredString(payload.resource, "state.resource"),
600
+ mcpUrl: stringValue(payload.mcpUrl) ?? resource,
601
+ resource,
559
602
  requestedScopes: stringArray(payload.requestedScopes),
560
603
  authorizeScopes: stringArray(payload.authorizeScopes),
561
604
  encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
@@ -643,7 +686,8 @@ async function exchangeAuthorizationCode(
643
686
  }
644
687
  const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
645
688
  if (!response.ok) {
646
- 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}`));
647
691
  }
648
692
  const payload = await response.json() as Record<string, unknown>;
649
693
  const accessToken = stringValue(payload.access_token);
@@ -660,6 +704,91 @@ async function exchangeAuthorizationCode(
660
704
  };
661
705
  }
662
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
+
663
792
  async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
664
793
  await assertOAuthFetchAllowed(resource, settings);
665
794
  const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
@@ -681,6 +810,42 @@ async function verifyMcpToolsList(settings: Settings, resource: string, token: T
681
810
  }
682
811
  }
683
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
+
684
849
  function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
685
850
  return {
686
851
  access_token: token.accessToken,
@@ -688,6 +853,7 @@ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client
688
853
  token_type: token.tokenType,
689
854
  ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
690
855
  resource: state.resource,
856
+ mcp_url: state.mcpUrl,
691
857
  ...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
692
858
  token_endpoint: state.tokenEndpoint,
693
859
  client_id: client.clientId,
@@ -718,8 +884,21 @@ function canonicalMcpResource(value: string | undefined): string {
718
884
  return url.toString();
719
885
  }
720
886
 
721
- function canonicalProviderDomain(value: string): string {
722
- return value.trim().toLowerCase().replace(/^www\./, "");
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
+ }
723
902
  }
724
903
 
725
904
  function safeReturnPath(value: string): string {
@@ -0,0 +1,15 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+
3
+ /**
4
+ * Canonical form of a connection's providerDomain: trimmed, lowercased, no
5
+ * leading "www.". Rejects a value that canonicalizes to empty (whitespace-only,
6
+ * or a bare "www.") — `min(1)` validation passes such input, but an empty stored
7
+ * domain silently breaks the enable-time connectionRef domain match.
8
+ */
9
+ export function canonicalProviderDomain(value: string): string {
10
+ const canonical = value.trim().toLowerCase().replace(/^www\./, "");
11
+ if (!canonical) {
12
+ throw new HTTPException(400, { message: "providerDomain must not be empty" });
13
+ }
14
+ return canonical;
15
+ }
@@ -0,0 +1,105 @@
1
+ import type { Hono } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import type { ApiRouteDeps } from "@opengeni/core";
4
+
5
+ const CATALOG_ASSET_PREFIX = "catalog-assets/";
6
+ const MAX_KEY_LENGTH = 512;
7
+ const PRINTABLE_ASCII = /^[\x20-\x7e]+$/;
8
+
9
+ const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
10
+ png: "image/png",
11
+ jpg: "image/jpeg",
12
+ jpeg: "image/jpeg",
13
+ svg: "image/svg+xml",
14
+ webp: "image/webp",
15
+ gif: "image/gif",
16
+ ico: "image/x-icon",
17
+ };
18
+
19
+ export function registerCatalogAssetRoutes(app: Hono, deps: ApiRouteDeps): void {
20
+ const { settings, objectStorage } = deps;
21
+
22
+ app.get("/v1/catalog-assets/*", async (c) => {
23
+ if (!settings.integrationsEnabled) {
24
+ throw new HTTPException(404, { message: "integrations are not enabled for this deployment" });
25
+ }
26
+ if (!objectStorage) {
27
+ throw new HTTPException(404, { message: "asset not found" });
28
+ }
29
+ const key = catalogAssetKeyFromPath(new URL(c.req.url).pathname);
30
+ if (!key) {
31
+ throw new HTTPException(404, { message: "asset not found" });
32
+ }
33
+ const contentType = contentTypeForKey(key);
34
+ if (!contentType) {
35
+ throw new HTTPException(404, { message: "asset not found" });
36
+ }
37
+ const object = await objectStorage.getObjectBytes(key);
38
+ if (!object) {
39
+ throw new HTTPException(404, { message: "asset not found" });
40
+ }
41
+ const etag = etagForKey(key);
42
+ const headers = {
43
+ "Cache-Control": "public, max-age=31536000, immutable",
44
+ ETag: etag,
45
+ "X-Content-Type-Options": "nosniff",
46
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
47
+ };
48
+ if (ifNoneMatchSatisfied(c.req.header("if-none-match"), etag)) {
49
+ return c.body(null, 304, headers);
50
+ }
51
+ // Copy narrows the AWS/GCS/Azure SDKs' ArrayBufferLike-backed Uint8Array to the
52
+ // ArrayBuffer-backed one Hono's body() type expects.
53
+ return c.body(new Uint8Array(object.bytes), 200, { ...headers, "Content-Type": contentType });
54
+ });
55
+ }
56
+
57
+ /** Decoded, validated storage key from a `/v1/catalog-assets/...` request path, or null if malformed/unsafe. */
58
+ export function catalogAssetKeyFromPath(pathname: string): string | null {
59
+ const prefix = "/v1/";
60
+ if (!pathname.startsWith(prefix)) {
61
+ return null;
62
+ }
63
+ let key: string;
64
+ try {
65
+ key = decodeURIComponent(pathname.slice(prefix.length));
66
+ } catch {
67
+ return null;
68
+ }
69
+ if (
70
+ key.length === 0 ||
71
+ key.length > MAX_KEY_LENGTH ||
72
+ !key.startsWith(CATALOG_ASSET_PREFIX) ||
73
+ key.includes("..") ||
74
+ key.includes("\\") ||
75
+ key.includes("//") ||
76
+ !PRINTABLE_ASCII.test(key)
77
+ ) {
78
+ return null;
79
+ }
80
+ return key;
81
+ }
82
+
83
+ function contentTypeForKey(key: string): string | null {
84
+ const match = /\.([a-zA-Z0-9]+)$/.exec(key);
85
+ const ext = match?.[1]?.toLowerCase();
86
+ return ext ? CONTENT_TYPE_BY_EXTENSION[ext] ?? null : null;
87
+ }
88
+
89
+ /** Digest-keyed filenames (`{domain}/{digest24}.{ext}`) make the basename itself a stable ETag. */
90
+ function etagForKey(key: string): string {
91
+ const filename = key.slice(key.lastIndexOf("/") + 1);
92
+ const dot = filename.lastIndexOf(".");
93
+ const digest = dot === -1 ? filename : filename.slice(0, dot);
94
+ return `"${digest}"`;
95
+ }
96
+
97
+ function ifNoneMatchSatisfied(header: string | undefined, etag: string): boolean {
98
+ if (!header) {
99
+ return false;
100
+ }
101
+ if (header.trim() === "*") {
102
+ return true;
103
+ }
104
+ return header.split(",").map((value) => value.trim()).includes(etag);
105
+ }
@@ -24,9 +24,10 @@ import {
24
24
  integrationBaseUrl,
25
25
  startMcpOAuth,
26
26
  } from "../integrations/oauth-client";
27
+ import { canonicalProviderDomain } from "../integrations/provider-domain";
27
28
 
28
29
  export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
29
- const { db, settings } = deps;
30
+ const { db, settings, observability } = deps;
30
31
 
31
32
  function assertIntegrationsEnabled(): void {
32
33
  if (!settings.integrationsEnabled) {
@@ -52,7 +53,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
52
53
  accountId: grant.accountId,
53
54
  workspaceId,
54
55
  subjectId,
55
- providerDomain: payload.providerDomain,
56
+ providerDomain: canonicalProviderDomain(payload.providerDomain),
56
57
  kind: payload.kind,
57
58
  credentialEncrypted: encryptCredentialBundle(key, payload.credential),
58
59
  grantedScopes: payload.grantedScopes,
@@ -96,7 +97,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
96
97
  connectionId: c.req.param("connectionId"),
97
98
  visibleToSubjectId: grant.subjectId,
98
99
  updatedBySubjectId: grant.subjectId,
99
- ...(payload.providerDomain !== undefined ? { providerDomain: payload.providerDomain } : {}),
100
+ ...(payload.providerDomain !== undefined ? { providerDomain: canonicalProviderDomain(payload.providerDomain) } : {}),
100
101
  ...(subjectId !== undefined ? { subjectId } : {}),
101
102
  ...(payload.kind !== undefined ? { kind: payload.kind } : {}),
102
103
  ...(payload.status !== undefined ? { status: payload.status } : {}),
@@ -130,7 +131,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
130
131
  throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
131
132
  }
132
133
  const payload = parsed.data;
133
- const result = await startMcpOAuth({ db, settings }, {
134
+ const result = await startMcpOAuth({ db, settings, observability }, {
134
135
  accountId: grant.accountId,
135
136
  workspaceId,
136
137
  subjectId: grant.subjectId,
@@ -142,7 +143,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
142
143
 
143
144
  app.get("/v1/integrations/oauth/callback", async (c) => {
144
145
  assertIntegrationsEnabled();
145
- const result = await completeMcpOAuthCallback({ db, settings }, {
146
+ const result = await completeMcpOAuthCallback({ db, settings, observability }, {
146
147
  code: c.req.query("code"),
147
148
  state: c.req.query("state"),
148
149
  requestUrl: c.req.url,