@opengeni/api-router 2.6.4 → 2.10.0-canary.0
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/access-grant-rls.d.ts +3 -0
- package/dist/app.d.ts +2 -1
- package/dist/app.js +3 -1
- package/dist/auth/organization-user-setup.d.ts +23 -0
- package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
- package/dist/chunk-XXH7DW34.js.map +1 -0
- package/dist/codemode.d.ts +6 -0
- package/dist/github-access.d.ts +25 -2
- package/dist/http/request-source.d.ts +14 -0
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/integrations/oauth-client.d.ts +2 -11
- package/dist/integrations/personal-github-repositories.d.ts +41 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/server.d.ts +158 -1
- package/dist/mcp/session-view.d.ts +56 -2
- package/dist/mcp/session-wait.d.ts +1 -1
- package/dist/mcp-oauth.d.ts +19 -0
- package/dist/model-catalog.d.ts +5 -31
- package/dist/routes/codex.d.ts +27 -2
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/routes/workspace-artifacts.d.ts +2 -1
- package/dist/work-discovery-observability.d.ts +1 -1
- package/dist/workspace-artifact-content.d.ts +28 -0
- package/dist/workspace-artifact-provenance.d.ts +7 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/dist/workspace-tool-gateway-observability.d.ts +17 -0
- package/dist/workspace-tool-gateway.d.ts +118 -0
- package/package.json +19 -18
- package/src/access-grant-rls.ts +40 -0
- package/src/app.ts +296 -53
- package/src/auth/organization-user-setup.ts +95 -5
- package/src/codemode.ts +33 -4
- package/src/github-access.ts +117 -1
- package/src/http/auth.ts +11 -0
- package/src/http/request-source.ts +37 -0
- package/src/http/sse.ts +15 -7
- package/src/index.ts +18 -2
- package/src/integrations/oauth-client.ts +168 -203
- package/src/integrations/personal-github-repositories.ts +238 -9
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/integrations/slack-interactions.ts +77 -37
- package/src/mcp/company-brain-governed-writes.ts +2 -2
- package/src/mcp/company-profile-agent-admin.ts +1 -1
- package/src/mcp/remember.ts +1 -1
- package/src/mcp/server.ts +504 -133
- package/src/mcp/session-view.ts +20 -1
- package/src/mcp/session-wait.ts +3 -0
- package/src/mcp-oauth.ts +539 -0
- package/src/model-catalog.ts +45 -337
- package/src/routes/automations.ts +178 -19
- package/src/routes/codex.ts +242 -73
- package/src/routes/connections.ts +271 -16
- package/src/routes/documents.ts +67 -19
- package/src/routes/files.ts +54 -6
- package/src/routes/github.ts +116 -15
- package/src/routes/organization-memberships.ts +59 -16
- package/src/routes/organization-model-providers.ts +231 -0
- package/src/routes/packs.ts +1 -0
- package/src/routes/personal-github.ts +39 -0
- package/src/routes/pr-review.ts +72 -4
- package/src/routes/scheduled-tasks.ts +40 -13
- package/src/routes/sessions.ts +153 -65
- package/src/routes/supergrok.ts +3 -2
- package/src/routes/workspace-artifacts.ts +176 -67
- package/src/routes/workspaces.ts +405 -62
- package/src/sandbox/channel-a.ts +17 -4
- package/src/work-discovery-observability.ts +3 -3
- package/src/workspace-artifact-content.ts +163 -0
- package/src/workspace-artifact-provenance.ts +104 -0
- package/src/workspace-deletion.ts +64 -0
- package/src/workspace-tool-gateway-observability.ts +100 -0
- package/src/workspace-tool-gateway.ts +691 -0
- package/dist/chunk-XTLI3CBH.js.map +0 -1
package/src/app.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
resolveWorkspaceMemoryPromptMode,
|
|
19
19
|
VOICE_INPUT_ACCEPTED_MIME_TYPES,
|
|
20
20
|
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
|
|
21
|
+
ToolGatewayApprovalRequest,
|
|
22
|
+
ToolGatewayCallRequest,
|
|
21
23
|
type AccessGrant,
|
|
22
24
|
type ErrorCode,
|
|
23
25
|
} from "@opengeni/contracts";
|
|
@@ -41,7 +43,6 @@ import {
|
|
|
41
43
|
reapManagedAuthIsolatedSessions,
|
|
42
44
|
reapExpiredManagedAuthSessionSets,
|
|
43
45
|
rlsContextForWorkspace,
|
|
44
|
-
withSessionRlsActorContext,
|
|
45
46
|
} from "@opengeni/db";
|
|
46
47
|
import { requireSessionEventDurableFanoutCapability } from "@opengeni/events";
|
|
47
48
|
import { githubAppBotIdentityWarnings } from "@opengeni/github";
|
|
@@ -63,9 +64,11 @@ import {
|
|
|
63
64
|
ManagedAuthActorLeaseOutcomeUnknownError,
|
|
64
65
|
hasPermission,
|
|
65
66
|
requireAccessGrant,
|
|
66
|
-
|
|
67
|
+
requireAccessGrantAuthorization,
|
|
67
68
|
requirePermission,
|
|
68
69
|
releaseManagedAuthRequestActorLease,
|
|
70
|
+
resolveCatalogSettings,
|
|
71
|
+
resolveWorkspaceCatalogSettings,
|
|
69
72
|
validateManagedAuthRequestActorLease,
|
|
70
73
|
requireSessionAuthorization,
|
|
71
74
|
SessionAuthorizationDeniedError,
|
|
@@ -94,13 +97,33 @@ import {
|
|
|
94
97
|
runManagedAuthProvider,
|
|
95
98
|
} from "./auth/managed-auth-attempt-context";
|
|
96
99
|
import { createManagedEmailTransport } from "./auth/managed-email";
|
|
97
|
-
import {
|
|
100
|
+
import {
|
|
101
|
+
assertManagedEmailTransportMetadata,
|
|
102
|
+
assertOrganizationUserSetupQueryTransportConfigured,
|
|
103
|
+
} from "./auth/organization-user-setup";
|
|
98
104
|
import { createApiSandboxClient, makeResumeBoxById } from "./sandbox/access";
|
|
99
105
|
import { requireLimit } from "@opengeni/core";
|
|
100
106
|
import { buildOpenGeniMcpServer } from "./mcp/server";
|
|
107
|
+
import {
|
|
108
|
+
buildWorkspaceToolGatewayMcpServer,
|
|
109
|
+
approveWorkspaceToolGatewayCall,
|
|
110
|
+
callWorkspaceToolGateway,
|
|
111
|
+
grantUsesAttemptScopedMcp,
|
|
112
|
+
prepareMcpOAuthWorkspaceToolGateway,
|
|
113
|
+
prepareWorkspaceToolGateway,
|
|
114
|
+
workspaceToolGatewayDeclarations,
|
|
115
|
+
} from "./workspace-tool-gateway";
|
|
116
|
+
import {
|
|
117
|
+
isMcpOAuthResourcePath,
|
|
118
|
+
mcpOAuthAuthenticateHeader,
|
|
119
|
+
mcpOAuthBearerToken,
|
|
120
|
+
registerMcpOAuthRoutes,
|
|
121
|
+
resolveMcpOAuthRouteAccess,
|
|
122
|
+
} from "./mcp-oauth";
|
|
101
123
|
import {
|
|
102
124
|
CodemodeAuthorityError,
|
|
103
125
|
CodemodeCatalogNotReadyError,
|
|
126
|
+
CodemodeCatalogStaleError,
|
|
104
127
|
isCodemodeGrant,
|
|
105
128
|
readCodemodeOperation,
|
|
106
129
|
requireActiveCodemodeCatalog,
|
|
@@ -113,9 +136,11 @@ import {
|
|
|
113
136
|
} from "@opengeni/runtime/sandbox";
|
|
114
137
|
import { requireAccessKey } from "./http/auth";
|
|
115
138
|
import { allowedCorsOrigin } from "./http/cors";
|
|
139
|
+
import { withAccessGrantSessionRlsContext } from "./access-grant-rls";
|
|
116
140
|
import { registerCapabilityRoutes } from "./routes/capabilities";
|
|
117
141
|
import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
|
|
118
142
|
import { registerCodexRoutes } from "./routes/codex";
|
|
143
|
+
import { registerOrganizationModelProviderRoutes } from "./routes/organization-model-providers";
|
|
119
144
|
import { registerSuperGrokRoutes } from "./routes/supergrok";
|
|
120
145
|
import { registerConnectionRoutes } from "./routes/connections";
|
|
121
146
|
import { registerDocumentRoutes } from "./routes/documents";
|
|
@@ -222,10 +247,27 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
222
247
|
return createAppComposition(deps).app;
|
|
223
248
|
}
|
|
224
249
|
|
|
250
|
+
export async function resolveWorkspaceMcpRouteDeps(
|
|
251
|
+
routeDeps: ApiRouteDeps,
|
|
252
|
+
grant: AccessGrant,
|
|
253
|
+
): Promise<ApiRouteDeps> {
|
|
254
|
+
const catalogSourceSettings = routeDeps.catalogSourceSettings ?? routeDeps.settings;
|
|
255
|
+
const resolvedCatalog = await resolveWorkspaceCatalogSettings(
|
|
256
|
+
routeDeps.db,
|
|
257
|
+
catalogSourceSettings,
|
|
258
|
+
{
|
|
259
|
+
accountId: grant.accountId,
|
|
260
|
+
workspaceId: grant.workspaceId,
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
return { ...routeDeps, catalogSourceSettings, settings: resolvedCatalog.settings };
|
|
264
|
+
}
|
|
265
|
+
|
|
225
266
|
export function createAppComposition(deps: AppDependencies): {
|
|
226
267
|
app: Hono;
|
|
227
268
|
routeDeps: ApiRouteDeps;
|
|
228
269
|
} {
|
|
270
|
+
assertOrganizationUserSetupQueryTransportConfigured(deps.settings);
|
|
229
271
|
// The request-scoped workspace control-prefix budget is validated once by
|
|
230
272
|
// @opengeni/config at boot; install it for every request-scoped db command
|
|
231
273
|
// (Send/Steer/control/queue/settings/delete) built by this app.
|
|
@@ -358,6 +400,7 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
358
400
|
: deps.transcriptionSegmenter;
|
|
359
401
|
const routeDeps: ApiRouteDeps = {
|
|
360
402
|
...deps,
|
|
403
|
+
resolveCatalogSettings: () => resolveCatalogSettings(deps.db, deps.settings),
|
|
361
404
|
observability,
|
|
362
405
|
githubStateSecret:
|
|
363
406
|
deps.githubStateSecret ?? deps.settings.githubAppManifestStateSecret ?? crypto.randomUUID(),
|
|
@@ -383,6 +426,18 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
383
426
|
await next();
|
|
384
427
|
});
|
|
385
428
|
|
|
429
|
+
app.use("*", async (c, next) => {
|
|
430
|
+
const oauthToken = mcpOAuthBearerToken(c.req.raw);
|
|
431
|
+
if (
|
|
432
|
+
oauthToken &&
|
|
433
|
+
(!deps.settings.mcpOauthEnabled || !isMcpOAuthResourcePath(new URL(c.req.url).pathname))
|
|
434
|
+
) {
|
|
435
|
+
c.header("www-authenticate", 'Bearer error="invalid_token"');
|
|
436
|
+
return c.json({ error: "invalid_token" }, 401);
|
|
437
|
+
}
|
|
438
|
+
await next();
|
|
439
|
+
});
|
|
440
|
+
|
|
386
441
|
const corsHeaders = {
|
|
387
442
|
allowHeaders: [
|
|
388
443
|
"Accept",
|
|
@@ -403,6 +458,21 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
403
458
|
"X-OpenGeni-Actor-Epoch",
|
|
404
459
|
"X-OpenGeni-Actor-State",
|
|
405
460
|
"X-OpenGeni-Correlation-Id",
|
|
461
|
+
"X-OpenGeni-Covered-First",
|
|
462
|
+
"X-OpenGeni-Covered-Last",
|
|
463
|
+
"X-OpenGeni-Event-Direction",
|
|
464
|
+
"X-OpenGeni-Event-Mode",
|
|
465
|
+
"X-OpenGeni-Event-Result",
|
|
466
|
+
"X-OpenGeni-Event-Result-Mode",
|
|
467
|
+
"X-OpenGeni-Forensic-Exact",
|
|
468
|
+
"X-OpenGeni-Has-More",
|
|
469
|
+
"X-OpenGeni-Next-After",
|
|
470
|
+
"X-OpenGeni-Next-Before",
|
|
471
|
+
"X-OpenGeni-Page-Bytes",
|
|
472
|
+
"X-OpenGeni-Page-Max-Bytes",
|
|
473
|
+
"X-OpenGeni-Page-Truncated",
|
|
474
|
+
"X-OpenGeni-Payload-Mode",
|
|
475
|
+
"X-OpenGeni-Truncated-By",
|
|
406
476
|
],
|
|
407
477
|
};
|
|
408
478
|
const publicApiCors = cors({
|
|
@@ -764,12 +834,16 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
764
834
|
}),
|
|
765
835
|
);
|
|
766
836
|
|
|
837
|
+
registerMcpOAuthRoutes(app, routeDeps);
|
|
838
|
+
|
|
767
839
|
app.get("/v1/config/client", async (c) => {
|
|
768
840
|
c.header("cache-control", "no-store");
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
841
|
+
const resolvedCatalog = await resolveCatalogSettings(deps.db, deps.settings);
|
|
842
|
+
const baseCatalogSettings = resolvedCatalog.settings;
|
|
843
|
+
const codexCatalogSettings = baseCatalogSettings.codexSubscriptionEnabled
|
|
844
|
+
? withCodexCatalogProvider(baseCatalogSettings)
|
|
845
|
+
: baseCatalogSettings;
|
|
846
|
+
const catalogSettings = baseCatalogSettings.supergrokSubscriptionEnabled
|
|
773
847
|
? withXaiSubscriptionCatalogProvider(codexCatalogSettings)
|
|
774
848
|
: codexCatalogSettings;
|
|
775
849
|
return c.json(
|
|
@@ -861,7 +935,54 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
861
935
|
}
|
|
862
936
|
throw error;
|
|
863
937
|
}
|
|
864
|
-
|
|
938
|
+
let oauthAccess: Awaited<ReturnType<typeof resolveMcpOAuthRouteAccess>>;
|
|
939
|
+
try {
|
|
940
|
+
oauthAccess = await resolveMcpOAuthRouteAccess(routeDeps, c.req.raw, workspaceId);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (error instanceof HTTPException && error.status === 401) {
|
|
943
|
+
c.header(
|
|
944
|
+
"www-authenticate",
|
|
945
|
+
mcpOAuthAuthenticateHeader(routeDeps, new URL(c.req.url).pathname),
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
throw error;
|
|
949
|
+
}
|
|
950
|
+
if (oauthAccess) {
|
|
951
|
+
return await withAccessGrantSessionRlsContext(routeDeps, oauthAccess.grant, async () => {
|
|
952
|
+
const prepared = await prepareMcpOAuthWorkspaceToolGateway(
|
|
953
|
+
routeDeps,
|
|
954
|
+
oauthAccess.grant,
|
|
955
|
+
oauthAccess.allowedToolIdentities,
|
|
956
|
+
);
|
|
957
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
958
|
+
enableJsonResponse: true,
|
|
959
|
+
});
|
|
960
|
+
const mcp = buildWorkspaceToolGatewayMcpServer(
|
|
961
|
+
prepared,
|
|
962
|
+
oauthAccess.grant,
|
|
963
|
+
routeDeps.observability,
|
|
964
|
+
);
|
|
965
|
+
try {
|
|
966
|
+
await mcp.connect(transport);
|
|
967
|
+
return await handleMcpRequestWithClientAbort(transport, boundedRequest, c.req.raw.signal);
|
|
968
|
+
} finally {
|
|
969
|
+
await Promise.allSettled([mcp.close(), prepared.close()]);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
let authorization: Awaited<ReturnType<typeof requireMcpAccessGrantAuthorization>>;
|
|
974
|
+
try {
|
|
975
|
+
authorization = await requireMcpAccessGrantAuthorization(c, routeDeps, workspaceId);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (deps.settings.mcpOauthEnabled && error instanceof HTTPException && error.status === 401) {
|
|
978
|
+
c.header(
|
|
979
|
+
"www-authenticate",
|
|
980
|
+
mcpOAuthAuthenticateHeader(routeDeps, new URL(c.req.url).pathname),
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
throw error;
|
|
984
|
+
}
|
|
985
|
+
const grant = authorization.grant;
|
|
865
986
|
return await withAccessGrantSessionRlsContext(routeDeps, grant, async () => {
|
|
866
987
|
const boundSessionId = grant.metadata?.sessionId;
|
|
867
988
|
if (typeof boundSessionId === "string") {
|
|
@@ -889,7 +1010,18 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
889
1010
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
890
1011
|
enableJsonResponse: true,
|
|
891
1012
|
});
|
|
892
|
-
|
|
1013
|
+
if (!grantUsesAttemptScopedMcp(grant)) {
|
|
1014
|
+
const prepared = await prepareWorkspaceToolGateway(routeDeps, authorization);
|
|
1015
|
+
const mcp = buildWorkspaceToolGatewayMcpServer(prepared, grant, routeDeps.observability);
|
|
1016
|
+
try {
|
|
1017
|
+
await mcp.connect(transport);
|
|
1018
|
+
return await handleMcpRequestWithClientAbort(transport, boundedRequest, c.req.raw.signal);
|
|
1019
|
+
} finally {
|
|
1020
|
+
await Promise.allSettled([mcp.close(), prepared.close()]);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const mcpDeps = await resolveWorkspaceMcpRouteDeps(routeDeps, grant);
|
|
1024
|
+
const mcp = buildOpenGeniMcpServer(mcpDeps, grant, {
|
|
893
1025
|
requestOrigin: new URL(c.req.url).origin,
|
|
894
1026
|
workspaceMemoryEnabled,
|
|
895
1027
|
workspaceMemoryPromptMode,
|
|
@@ -901,6 +1033,100 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
901
1033
|
});
|
|
902
1034
|
});
|
|
903
1035
|
|
|
1036
|
+
app.get("/v1/workspaces/:workspaceId/tools/catalog", async (c) => {
|
|
1037
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1038
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
1039
|
+
c,
|
|
1040
|
+
routeDeps,
|
|
1041
|
+
workspaceId,
|
|
1042
|
+
"workspace:read",
|
|
1043
|
+
);
|
|
1044
|
+
const prepared = await prepareWorkspaceToolGateway(routeDeps, authorization);
|
|
1045
|
+
try {
|
|
1046
|
+
c.header("cache-control", "no-store");
|
|
1047
|
+
return c.json(prepared.toolGatewayCatalog);
|
|
1048
|
+
} finally {
|
|
1049
|
+
await prepared.close();
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
app.post("/v1/workspaces/:workspaceId/tools/calls", async (c) => {
|
|
1054
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1055
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
1056
|
+
c,
|
|
1057
|
+
routeDeps,
|
|
1058
|
+
workspaceId,
|
|
1059
|
+
"workspace:read",
|
|
1060
|
+
);
|
|
1061
|
+
const grant = authorization.grant;
|
|
1062
|
+
const parsed = ToolGatewayCallRequest.safeParse(await c.req.json().catch(() => null));
|
|
1063
|
+
if (!parsed.success) {
|
|
1064
|
+
throw new HTTPException(400, { message: "Invalid tool gateway call" });
|
|
1065
|
+
}
|
|
1066
|
+
const prepared = await prepareWorkspaceToolGateway(routeDeps, authorization);
|
|
1067
|
+
try {
|
|
1068
|
+
return c.json(
|
|
1069
|
+
await callWorkspaceToolGateway(
|
|
1070
|
+
prepared,
|
|
1071
|
+
grant,
|
|
1072
|
+
parsed.data,
|
|
1073
|
+
routeDeps.db,
|
|
1074
|
+
undefined,
|
|
1075
|
+
routeDeps.observability,
|
|
1076
|
+
),
|
|
1077
|
+
);
|
|
1078
|
+
} finally {
|
|
1079
|
+
await prepared.close();
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
app.post("/v1/workspaces/:workspaceId/tools/approvals", async (c) => {
|
|
1084
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1085
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
1086
|
+
c,
|
|
1087
|
+
routeDeps,
|
|
1088
|
+
workspaceId,
|
|
1089
|
+
"workspace:read",
|
|
1090
|
+
);
|
|
1091
|
+
const parsed = ToolGatewayApprovalRequest.safeParse(await c.req.json().catch(() => null));
|
|
1092
|
+
if (!parsed.success) {
|
|
1093
|
+
throw new HTTPException(400, { message: "Invalid tool gateway approval" });
|
|
1094
|
+
}
|
|
1095
|
+
const prepared = await prepareWorkspaceToolGateway(routeDeps, authorization);
|
|
1096
|
+
try {
|
|
1097
|
+
return c.json(
|
|
1098
|
+
await approveWorkspaceToolGatewayCall(
|
|
1099
|
+
prepared,
|
|
1100
|
+
authorization.grant,
|
|
1101
|
+
routeDeps.db,
|
|
1102
|
+
parsed.data,
|
|
1103
|
+
undefined,
|
|
1104
|
+
routeDeps.observability,
|
|
1105
|
+
),
|
|
1106
|
+
201,
|
|
1107
|
+
);
|
|
1108
|
+
} finally {
|
|
1109
|
+
await prepared.close();
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
app.get("/v1/workspaces/:workspaceId/tools/declarations", async (c) => {
|
|
1114
|
+
const workspaceId = c.req.param("workspaceId");
|
|
1115
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
1116
|
+
c,
|
|
1117
|
+
routeDeps,
|
|
1118
|
+
workspaceId,
|
|
1119
|
+
"workspace:read",
|
|
1120
|
+
);
|
|
1121
|
+
const prepared = await prepareWorkspaceToolGateway(routeDeps, authorization);
|
|
1122
|
+
try {
|
|
1123
|
+
c.header("cache-control", "no-store");
|
|
1124
|
+
return c.json(workspaceToolGatewayDeclarations(prepared));
|
|
1125
|
+
} finally {
|
|
1126
|
+
await prepared.close();
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
|
|
904
1130
|
app.get("/v1/workspaces/:workspaceId/codemode/catalog", async (c) => {
|
|
905
1131
|
const workspaceId = c.req.param("workspaceId");
|
|
906
1132
|
const grant = await requireAccessGrant(c, routeDeps, workspaceId);
|
|
@@ -996,6 +1222,7 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
996
1222
|
registerSessionRoutes(app, routeDeps);
|
|
997
1223
|
registerScheduledTaskRoutes(app, routeDeps);
|
|
998
1224
|
registerCodexRoutes(app, routeDeps);
|
|
1225
|
+
registerOrganizationModelProviderRoutes(app, routeDeps);
|
|
999
1226
|
registerSuperGrokRoutes(app, routeDeps);
|
|
1000
1227
|
registerTranscriptionRoutes(app, routeDeps);
|
|
1001
1228
|
registerEditableArtifactRoutes(app, routeDeps);
|
|
@@ -1093,7 +1320,11 @@ const publicWorkspaceBrowserRoutePatterns = [
|
|
|
1093
1320
|
] as const;
|
|
1094
1321
|
|
|
1095
1322
|
export function workspaceActorContextExempt(method: string, pathname: string): boolean {
|
|
1096
|
-
|
|
1323
|
+
// The account-scoped provisioning route shares the workspace collection prefix.
|
|
1324
|
+
// Hono's trailing wildcard also matches it, but "external" is not a workspace UUID;
|
|
1325
|
+
// the route performs its own organization API-key authorization.
|
|
1326
|
+
if (method === "PUT" && pathname === "/v1/workspaces/external") return true;
|
|
1327
|
+
if (/^\/v1\/workspaces\/[^/]+\/mcp(?:\/(?:docs|files))?$/.test(pathname)) return true;
|
|
1097
1328
|
if (
|
|
1098
1329
|
method === "GET" &&
|
|
1099
1330
|
publicWorkspaceBrowserRoutePatterns.some((route) => route.test(pathname))
|
|
@@ -1122,46 +1353,15 @@ async function validateCodexAccountTarget(request: Request): Promise<void> {
|
|
|
1122
1353
|
}
|
|
1123
1354
|
}
|
|
1124
1355
|
|
|
1125
|
-
async function
|
|
1126
|
-
deps: ApiRouteDeps,
|
|
1127
|
-
grant: AccessGrant,
|
|
1128
|
-
fn: () => Promise<T>,
|
|
1129
|
-
): Promise<T> {
|
|
1130
|
-
if (grant.principalKind !== "agent_attempt") {
|
|
1131
|
-
return await withSessionRlsActorContext({ subjectId: grant.subjectId }, fn);
|
|
1132
|
-
}
|
|
1133
|
-
const callerSessionId = grant.metadata?.sessionId;
|
|
1134
|
-
if (typeof callerSessionId !== "string") {
|
|
1135
|
-
throw new HTTPException(403, { message: "agent attempt authority is invalid" });
|
|
1136
|
-
}
|
|
1137
|
-
try {
|
|
1138
|
-
const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, callerSessionId);
|
|
1139
|
-
return await withSessionRlsActorContext(
|
|
1140
|
-
{
|
|
1141
|
-
subjectId: actor.subjectId,
|
|
1142
|
-
initiatingHumanSubjectId: actor.initiatingHumanSubjectId,
|
|
1143
|
-
},
|
|
1144
|
-
fn,
|
|
1145
|
-
);
|
|
1146
|
-
} catch (error) {
|
|
1147
|
-
if (error instanceof SessionAuthorizationDeniedError) {
|
|
1148
|
-
throw new HTTPException(403, {
|
|
1149
|
-
message: "agent attempt authority is invalid",
|
|
1150
|
-
cause: error,
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
throw error;
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
async function requireMcpAccessGrant(
|
|
1356
|
+
async function requireMcpAccessGrantAuthorization(
|
|
1158
1357
|
c: Parameters<typeof requireAccessGrant>[0],
|
|
1159
1358
|
deps: ApiRouteDeps,
|
|
1160
1359
|
workspaceId: string,
|
|
1161
|
-
): Promise<
|
|
1162
|
-
const
|
|
1360
|
+
): Promise<Awaited<ReturnType<typeof requireAccessGrantAuthorization>>> {
|
|
1361
|
+
const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
|
|
1362
|
+
const grant = authorization.grant;
|
|
1163
1363
|
if (hasPermission(grant.permissions, "workspace:read")) {
|
|
1164
|
-
return
|
|
1364
|
+
return authorization;
|
|
1165
1365
|
}
|
|
1166
1366
|
if (isCodemodeGrant(grant)) {
|
|
1167
1367
|
requirePermission(grant, "workspace:read");
|
|
@@ -1171,10 +1371,10 @@ async function requireMcpAccessGrant(
|
|
|
1171
1371
|
// authorization seam runs immediately after this gate, and tool registration
|
|
1172
1372
|
// still exposes only capabilities permitted by the delegated grant.
|
|
1173
1373
|
if (grant.metadata?.delegated === true && typeof grant.metadata.sessionId === "string") {
|
|
1174
|
-
return
|
|
1374
|
+
return authorization;
|
|
1175
1375
|
}
|
|
1176
1376
|
requirePermission(grant, "workspace:read");
|
|
1177
|
-
return
|
|
1377
|
+
return authorization;
|
|
1178
1378
|
}
|
|
1179
1379
|
|
|
1180
1380
|
function clientAuthConfig(settings: AppDependencies["settings"]) {
|
|
@@ -1231,6 +1431,15 @@ function codemodeHttpError(error: unknown): HTTPException {
|
|
|
1231
1431
|
details: { code: error.code },
|
|
1232
1432
|
});
|
|
1233
1433
|
}
|
|
1434
|
+
if (error instanceof CodemodeCatalogStaleError) {
|
|
1435
|
+
return new ApiHttpError(409, {
|
|
1436
|
+
code: "conflict",
|
|
1437
|
+
message: error.message,
|
|
1438
|
+
retryable: true,
|
|
1439
|
+
outcomeUnknown: false,
|
|
1440
|
+
details: { code: error.code },
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1234
1443
|
if (error instanceof CodemodeAuthorityError) {
|
|
1235
1444
|
return new ApiHttpError(error.reason === "invalid_grant" ? 403 : 409, {
|
|
1236
1445
|
code: error.reason === "invalid_grant" ? "forbidden" : "conflict",
|
|
@@ -1379,11 +1588,14 @@ type ReadinessCheckResult = { ok: boolean; error?: string };
|
|
|
1379
1588
|
function readinessChecks(deps: AppDependencies): ReadinessChecks {
|
|
1380
1589
|
const configuredNatsCheck = deps.readinessChecks?.nats;
|
|
1381
1590
|
return {
|
|
1382
|
-
db:
|
|
1383
|
-
deps.readinessChecks?.db
|
|
1384
|
-
|
|
1591
|
+
db: async () => {
|
|
1592
|
+
if (deps.readinessChecks?.db) {
|
|
1593
|
+
await deps.readinessChecks.db();
|
|
1594
|
+
} else {
|
|
1385
1595
|
await deps.db.execute(dbSql`select 1`);
|
|
1386
|
-
}
|
|
1596
|
+
}
|
|
1597
|
+
await resolveCatalogSettings(deps.db, deps.settings);
|
|
1598
|
+
},
|
|
1387
1599
|
nats: async () => {
|
|
1388
1600
|
requireSessionEventDurableFanoutCapability(deps.bus);
|
|
1389
1601
|
if (configuredNatsCheck) {
|
|
@@ -1458,6 +1670,20 @@ const routeLabelPatterns: Array<{
|
|
|
1458
1670
|
pattern: RegExp;
|
|
1459
1671
|
label: string | ((match: RegExpMatchArray) => string);
|
|
1460
1672
|
}> = [
|
|
1673
|
+
{
|
|
1674
|
+
pattern: /^\/\.well-known\/oauth-authorization-server$/,
|
|
1675
|
+
label: "/.well-known/oauth-authorization-server",
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
pattern:
|
|
1679
|
+
/^\/\.well-known\/oauth-protected-resource\/v1\/workspaces\/[^/]+\/mcp(?:\/(docs|files))?$/,
|
|
1680
|
+
label: (match) =>
|
|
1681
|
+
`/.well-known/oauth-protected-resource/v1/workspaces/:workspaceId/mcp${match[1] ? `/${match[1]}` : ""}`,
|
|
1682
|
+
},
|
|
1683
|
+
{
|
|
1684
|
+
pattern: /^\/oauth\/(register|authorize|token)$/,
|
|
1685
|
+
label: (match) => `/oauth/${match[1]}`,
|
|
1686
|
+
},
|
|
1461
1687
|
{ pattern: /^\/healthz$/, label: "/healthz" },
|
|
1462
1688
|
{ pattern: /^\/readyz$/, label: "/readyz" },
|
|
1463
1689
|
{ pattern: /^\/traffic-readyz$/, label: "/traffic-readyz" },
|
|
@@ -1543,6 +1769,22 @@ const routeLabelPatterns: Array<{
|
|
|
1543
1769
|
pattern: /^\/v1\/workspaces\/[^/]+\/mcp\/files$/,
|
|
1544
1770
|
label: "/v1/workspaces/:workspaceId/mcp/files",
|
|
1545
1771
|
},
|
|
1772
|
+
{
|
|
1773
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/tools\/catalog$/,
|
|
1774
|
+
label: "/v1/workspaces/:workspaceId/tools/catalog",
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/tools\/calls$/,
|
|
1778
|
+
label: "/v1/workspaces/:workspaceId/tools/calls",
|
|
1779
|
+
},
|
|
1780
|
+
{
|
|
1781
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/tools\/approvals$/,
|
|
1782
|
+
label: "/v1/workspaces/:workspaceId/tools/approvals",
|
|
1783
|
+
},
|
|
1784
|
+
{
|
|
1785
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/tools\/declarations$/,
|
|
1786
|
+
label: "/v1/workspaces/:workspaceId/tools/declarations",
|
|
1787
|
+
},
|
|
1546
1788
|
{
|
|
1547
1789
|
pattern: /^\/v1\/workspaces\/[^/]+\/default-rig$/,
|
|
1548
1790
|
label: "/v1/workspaces/:workspaceId/default-rig",
|
|
@@ -2307,5 +2549,6 @@ export function isApiContractProtectedMutation(method: string, pathname: string)
|
|
|
2307
2549
|
) {
|
|
2308
2550
|
return false;
|
|
2309
2551
|
}
|
|
2310
|
-
|
|
2552
|
+
const segments = pathname.split("/");
|
|
2553
|
+
return !segments.includes("mcp") && !segments.includes("codemode");
|
|
2311
2554
|
}
|
|
@@ -2,6 +2,7 @@ import type { Settings } from "@opengeni/config";
|
|
|
2
2
|
import type { ManagedEmailTransport } from "@opengeni/core";
|
|
3
3
|
|
|
4
4
|
const encoder = new TextEncoder();
|
|
5
|
+
export type OrganizationUserSetupEmailTokenTransport = "fragment" | "query";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Prove that the stable invited-user setup bearer can be constructed before an
|
|
@@ -13,11 +14,30 @@ export function assertOrganizationUserSetupDeliveryConfigured(
|
|
|
13
14
|
settings: Settings,
|
|
14
15
|
transport: ManagedEmailTransport,
|
|
15
16
|
): void {
|
|
17
|
+
assertOrganizationUserSetupQueryTransportConfigured(settings);
|
|
16
18
|
requiredSetupSecret(settings);
|
|
17
19
|
requiredPublicBaseUrl(settings);
|
|
18
20
|
assertManagedEmailTransportMetadata(transport);
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/** Enforce the query-bearing edge proof for env and embedded settings alike. */
|
|
24
|
+
export function assertOrganizationUserSetupQueryTransportConfigured(
|
|
25
|
+
settings: Pick<
|
|
26
|
+
Settings,
|
|
27
|
+
| "organizationUserSetupEmailTokenTransport"
|
|
28
|
+
| "organizationUserSetupQueryEdgeSanitizationConfirmed"
|
|
29
|
+
>,
|
|
30
|
+
): void {
|
|
31
|
+
if (
|
|
32
|
+
settings.organizationUserSetupEmailTokenTransport === "query" &&
|
|
33
|
+
!settings.organizationUserSetupQueryEdgeSanitizationConfirmed
|
|
34
|
+
) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"OPENGENI_ORGANIZATION_USER_SETUP_QUERY_EDGE_SANITIZATION_CONFIRMED=true is required when OPENGENI_ORGANIZATION_USER_SETUP_EMAIL_TOKEN_TRANSPORT=query",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
/** Reject an invalid embedded-provider contract before any durable boundary. */
|
|
22
42
|
export function assertManagedEmailTransportMetadata(transport: ManagedEmailTransport): void {
|
|
23
43
|
if (
|
|
@@ -42,7 +62,11 @@ export function assertManagedEmailTransportMetadata(transport: ManagedEmailTrans
|
|
|
42
62
|
|
|
43
63
|
export async function deriveOrganizationUserSetupToken(
|
|
44
64
|
settings: Settings,
|
|
45
|
-
input: {
|
|
65
|
+
input: {
|
|
66
|
+
invitationId: string;
|
|
67
|
+
deliveryId: string;
|
|
68
|
+
transport?: OrganizationUserSetupEmailTokenTransport;
|
|
69
|
+
},
|
|
46
70
|
): Promise<{ token: string; digest: string; url: string }> {
|
|
47
71
|
const secret = requiredSetupSecret(settings);
|
|
48
72
|
const key = await crypto.subtle.importKey(
|
|
@@ -62,7 +86,15 @@ export async function deriveOrganizationUserSetupToken(
|
|
|
62
86
|
const token = base64Url(new Uint8Array(signature));
|
|
63
87
|
const digest = await sha256Hex(token);
|
|
64
88
|
const url = new URL("/setup-account", requiredPublicBaseUrl(settings));
|
|
65
|
-
|
|
89
|
+
if ((input.transport ?? settings.organizationUserSetupEmailTokenTransport) === "query") {
|
|
90
|
+
// Email security gateways routinely wrap links and may discard URL
|
|
91
|
+
// fragments. Enable this only after the compatibility web rollout; the
|
|
92
|
+
// browser's first executable head bootstrap scrubs query authority without
|
|
93
|
+
// redirecting or allowing a query-bearing subresource Referer.
|
|
94
|
+
url.searchParams.set("token", token);
|
|
95
|
+
} else {
|
|
96
|
+
url.hash = new URLSearchParams({ token }).toString();
|
|
97
|
+
}
|
|
66
98
|
return { token, digest, url: url.toString() };
|
|
67
99
|
}
|
|
68
100
|
|
|
@@ -107,9 +139,9 @@ export function renderOrganizationUserSetupEmail(input: OrganizationUserSetupEma
|
|
|
107
139
|
return {
|
|
108
140
|
from: input.senderEmail,
|
|
109
141
|
to: input.recipientEmail,
|
|
110
|
-
subject: `
|
|
111
|
-
text: `${greeting}\n\nYou
|
|
112
|
-
html: `<p>${escapeHtml(greeting)}</p><p>You
|
|
142
|
+
subject: `You're invited to join ${input.organizationName} on OpenGeni`,
|
|
143
|
+
text: `${greeting}\n\nYou've been invited to join ${input.organizationName} on OpenGeni as ${role}.\n\n${workspaceSummary}\n\nThis invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.\n\nAccept invitation to ${input.organizationName}: ${input.setupUrl}\n\nYou'll sign in or create an account before joining. Already use OpenGeni? Open this invitation and choose Sign in as ${input.recipientEmail}. OpenGeni will show the invitation immediately after you sign in.`,
|
|
144
|
+
html: `<p>${escapeHtml(greeting)}</p><p>You've been invited to join <strong>${escapeHtml(input.organizationName)}</strong> on OpenGeni as ${escapeHtml(role)}.</p>${workspaceHtml}<p>This invitation grants only the organization role and shared workspace access listed above. It never shares anyone's Personal workspace.</p><p><a href="${escapeHtml(input.setupUrl)}">Accept invitation to ${escapeHtml(input.organizationName)}</a></p><p>You'll sign in or create an account before joining. Already use OpenGeni? Open this invitation and choose <strong>Sign in as ${escapeHtml(input.recipientEmail)}</strong>. OpenGeni will show the invitation immediately after you sign in.</p>`,
|
|
113
145
|
};
|
|
114
146
|
}
|
|
115
147
|
|
|
@@ -134,6 +166,64 @@ export async function organizationUserSetupPayloadDigest(input: {
|
|
|
134
166
|
);
|
|
135
167
|
}
|
|
136
168
|
|
|
169
|
+
export async function resolveOrganizationUserSetupDeliveryEmail(
|
|
170
|
+
settings: Settings,
|
|
171
|
+
input: {
|
|
172
|
+
invitationId: string;
|
|
173
|
+
deliveryId: string;
|
|
174
|
+
senderEmail: string;
|
|
175
|
+
recipientEmail: string;
|
|
176
|
+
recipientName: string | null;
|
|
177
|
+
organizationName: string;
|
|
178
|
+
organizationRole: "owner" | "admin" | "member";
|
|
179
|
+
sharedWorkspaceAccess: OrganizationUserSetupEmailSnapshot["sharedWorkspaceAccess"];
|
|
180
|
+
providerIdempotencyScope: string;
|
|
181
|
+
frozenTransport: OrganizationUserSetupEmailTokenTransport | null;
|
|
182
|
+
frozenPayloadDigest: string | null;
|
|
183
|
+
},
|
|
184
|
+
): Promise<{
|
|
185
|
+
transport: OrganizationUserSetupEmailTokenTransport;
|
|
186
|
+
token: string;
|
|
187
|
+
tokenDigest: string;
|
|
188
|
+
payloadDigest: string;
|
|
189
|
+
message: ReturnType<typeof renderOrganizationUserSetupEmail>;
|
|
190
|
+
}> {
|
|
191
|
+
const candidates: OrganizationUserSetupEmailTokenTransport[] = input.frozenTransport
|
|
192
|
+
? [input.frozenTransport]
|
|
193
|
+
: input.frozenPayloadDigest
|
|
194
|
+
? ["fragment", "query"]
|
|
195
|
+
: [settings.organizationUserSetupEmailTokenTransport];
|
|
196
|
+
for (const transport of candidates) {
|
|
197
|
+
const setup = await deriveOrganizationUserSetupToken(settings, {
|
|
198
|
+
invitationId: input.invitationId,
|
|
199
|
+
deliveryId: input.deliveryId,
|
|
200
|
+
transport,
|
|
201
|
+
});
|
|
202
|
+
const message = renderOrganizationUserSetupEmail({
|
|
203
|
+
senderEmail: input.senderEmail,
|
|
204
|
+
recipientEmail: input.recipientEmail,
|
|
205
|
+
recipientName: input.recipientName,
|
|
206
|
+
organizationName: input.organizationName,
|
|
207
|
+
organizationRole: input.organizationRole,
|
|
208
|
+
sharedWorkspaceAccess: input.sharedWorkspaceAccess,
|
|
209
|
+
setupUrl: setup.url,
|
|
210
|
+
});
|
|
211
|
+
const payloadDigest = await organizationUserSetupPayloadDigest({
|
|
212
|
+
...message,
|
|
213
|
+
providerIdempotencyScope: input.providerIdempotencyScope,
|
|
214
|
+
});
|
|
215
|
+
if (input.frozenPayloadDigest && payloadDigest !== input.frozenPayloadDigest) continue;
|
|
216
|
+
return {
|
|
217
|
+
transport,
|
|
218
|
+
token: setup.token,
|
|
219
|
+
tokenDigest: setup.digest,
|
|
220
|
+
payloadDigest,
|
|
221
|
+
message,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
throw new Error("Organization setup delivery transport does not match its frozen payload");
|
|
225
|
+
}
|
|
226
|
+
|
|
137
227
|
export async function organizationUserSetupRequestFingerprint(
|
|
138
228
|
settings: Settings,
|
|
139
229
|
input: { tokenDigest: string; name: string; password: string },
|