@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/app.js +1 -1
- package/dist/{chunk-DQ5TIRDZ.js → chunk-YY6OAEL6.js} +342 -243
- package/dist/chunk-YY6OAEL6.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +9 -9
- package/src/app.ts +3 -0
- package/src/http/auth.ts +6 -0
- package/src/integrations/oauth-client.ts +6 -5
- package/src/integrations/provider-domain.ts +15 -0
- package/src/routes/catalog-assets.ts +105 -0
- package/src/routes/connections.ts +3 -2
- package/dist/chunk-DQ5TIRDZ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "./chunk-
|
|
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.
|
|
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.
|
|
50
|
+
"@opengeni/config": "^0.4.0",
|
|
51
51
|
"@opengeni/contracts": "^0.9.0",
|
|
52
|
-
"@opengeni/core": "^0.4.
|
|
53
|
-
"@opengeni/db": "^0.6.
|
|
54
|
-
"@opengeni/documents": "^0.2.
|
|
55
|
-
"@opengeni/events": "^0.2.
|
|
56
|
-
"@opengeni/github": "^0.2.
|
|
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.
|
|
59
|
-
"@opengeni/storage": "^0.2.
|
|
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
|
-
|
|
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 } : {}),
|