@opengeni/api-router 0.5.0 → 0.5.2

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-YY6OAEL6.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.0",
3
+ "version": "0.5.2",
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.3",
53
- "@opengeni/db": "^0.6.0",
54
- "@opengeni/documents": "^0.2.7",
55
- "@opengeni/events": "^0.2.7",
56
- "@opengeni/github": "^0.2.7",
52
+ "@opengeni/core": "^0.4.5",
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
57
  "@opengeni/observability": "^0.2.1",
58
- "@opengeni/runtime": "^0.4.0",
59
- "@opengeni/storage": "^0.2.7",
58
+ "@opengeni/runtime": "^0.6.0",
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.
@@ -22,6 +22,7 @@ import { createHash, randomBytes } from "node:crypto";
22
22
  import { lookup } from "node:dns/promises";
23
23
  import { isIP } from "node:net";
24
24
  import { HTTPException } from "hono/http-exception";
25
+ import { canonicalProviderDomain } from "./provider-domain";
25
26
 
26
27
  export const oauthStateTtlMs = 10 * 60 * 1000;
27
28
 
@@ -244,7 +245,11 @@ export async function completeMcpOAuthCallback(
244
245
  if (!connection) {
245
246
  throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
246
247
  }
247
- return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id }) };
248
+ // Carry the canonical providerDomain (not just the id) so the SPA can build
249
+ // the enable connectionRef straight from the redirect, without a listConnections
250
+ // round-trip that could fail (transient, or a grant lacking connections:read)
251
+ // and leave the connection created but the capability un-enabled.
252
+ return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id, providerDomain: connection.providerDomain }) };
248
253
  } catch (error) {
249
254
  if (error instanceof HTTPException && error.status >= 400 && error.status < 500) {
250
255
  throw error;
@@ -718,10 +723,6 @@ function canonicalMcpResource(value: string | undefined): string {
718
723
  return url.toString();
719
724
  }
720
725
 
721
- function canonicalProviderDomain(value: string): string {
722
- return value.trim().toLowerCase().replace(/^www\./, "");
723
- }
724
-
725
726
  function safeReturnPath(value: string): string {
726
727
  if (!value.startsWith("/") || value.startsWith("//")) {
727
728
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
@@ -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,6 +24,7 @@ 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
30
  const { db, settings } = deps;
@@ -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 } : {}),