@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.
Files changed (75) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/app.d.ts +2 -1
  3. package/dist/app.js +3 -1
  4. package/dist/auth/organization-user-setup.d.ts +23 -0
  5. package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
  6. package/dist/chunk-XXH7DW34.js.map +1 -0
  7. package/dist/codemode.d.ts +6 -0
  8. package/dist/github-access.d.ts +25 -2
  9. package/dist/http/request-source.d.ts +14 -0
  10. package/dist/index.js +19 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/oauth-client.d.ts +2 -11
  13. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  14. package/dist/integrations/slack-interactions.d.ts +1 -1
  15. package/dist/mcp/server.d.ts +158 -1
  16. package/dist/mcp/session-view.d.ts +56 -2
  17. package/dist/mcp/session-wait.d.ts +1 -1
  18. package/dist/mcp-oauth.d.ts +19 -0
  19. package/dist/model-catalog.d.ts +5 -31
  20. package/dist/routes/codex.d.ts +27 -2
  21. package/dist/routes/github.d.ts +2 -0
  22. package/dist/routes/organization-model-providers.d.ts +3 -0
  23. package/dist/routes/workspace-artifacts.d.ts +2 -1
  24. package/dist/work-discovery-observability.d.ts +1 -1
  25. package/dist/workspace-artifact-content.d.ts +28 -0
  26. package/dist/workspace-artifact-provenance.d.ts +7 -0
  27. package/dist/workspace-deletion.d.ts +6 -0
  28. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  29. package/dist/workspace-tool-gateway.d.ts +118 -0
  30. package/package.json +19 -18
  31. package/src/access-grant-rls.ts +40 -0
  32. package/src/app.ts +296 -53
  33. package/src/auth/organization-user-setup.ts +95 -5
  34. package/src/codemode.ts +33 -4
  35. package/src/github-access.ts +117 -1
  36. package/src/http/auth.ts +11 -0
  37. package/src/http/request-source.ts +37 -0
  38. package/src/http/sse.ts +15 -7
  39. package/src/index.ts +18 -2
  40. package/src/integrations/oauth-client.ts +168 -203
  41. package/src/integrations/personal-github-repositories.ts +238 -9
  42. package/src/integrations/slack-app-home.ts +2 -2
  43. package/src/integrations/slack-interactions.ts +77 -37
  44. package/src/mcp/company-brain-governed-writes.ts +2 -2
  45. package/src/mcp/company-profile-agent-admin.ts +1 -1
  46. package/src/mcp/remember.ts +1 -1
  47. package/src/mcp/server.ts +504 -133
  48. package/src/mcp/session-view.ts +20 -1
  49. package/src/mcp/session-wait.ts +3 -0
  50. package/src/mcp-oauth.ts +539 -0
  51. package/src/model-catalog.ts +45 -337
  52. package/src/routes/automations.ts +178 -19
  53. package/src/routes/codex.ts +242 -73
  54. package/src/routes/connections.ts +271 -16
  55. package/src/routes/documents.ts +67 -19
  56. package/src/routes/files.ts +54 -6
  57. package/src/routes/github.ts +116 -15
  58. package/src/routes/organization-memberships.ts +59 -16
  59. package/src/routes/organization-model-providers.ts +231 -0
  60. package/src/routes/packs.ts +1 -0
  61. package/src/routes/personal-github.ts +39 -0
  62. package/src/routes/pr-review.ts +72 -4
  63. package/src/routes/scheduled-tasks.ts +40 -13
  64. package/src/routes/sessions.ts +153 -65
  65. package/src/routes/supergrok.ts +3 -2
  66. package/src/routes/workspace-artifacts.ts +176 -67
  67. package/src/routes/workspaces.ts +405 -62
  68. package/src/sandbox/channel-a.ts +17 -4
  69. package/src/work-discovery-observability.ts +3 -3
  70. package/src/workspace-artifact-content.ts +163 -0
  71. package/src/workspace-artifact-provenance.ts +104 -0
  72. package/src/workspace-deletion.ts +64 -0
  73. package/src/workspace-tool-gateway-observability.ts +100 -0
  74. package/src/workspace-tool-gateway.ts +691 -0
  75. 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
- requireLiveAgentAttemptAuthorization,
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 { assertManagedEmailTransportMetadata } from "./auth/organization-user-setup";
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 codexCatalogSettings = deps.settings.codexSubscriptionEnabled
770
- ? withCodexCatalogProvider(deps.settings)
771
- : deps.settings;
772
- const catalogSettings = deps.settings.supergrokSubscriptionEnabled
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
- const grant = await requireMcpAccessGrant(c, routeDeps, workspaceId);
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
- const mcp = buildOpenGeniMcpServer(routeDeps, grant, {
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
- if (/^\/v1\/workspaces\/[^/]+\/mcp$/.test(pathname)) return true;
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 withAccessGrantSessionRlsContext<T>(
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<AccessGrant> {
1162
- const grant = await requireAccessGrant(c, deps, workspaceId);
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 grant;
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 grant;
1374
+ return authorization;
1175
1375
  }
1176
1376
  requirePermission(grant, "workspace:read");
1177
- return grant;
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
- (async () => {
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
- return !pathname.split("/").includes("mcp");
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: { invitationId: string; deliveryId: string },
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
- url.hash = new URLSearchParams({ token }).toString();
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: `Join ${input.organizationName} on OpenGeni`,
111
- text: `${greeting}\n\nYou have been invited to ${input.organizationName} 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\nSet up your account: ${input.setupUrl}\n\nIf you already have an OpenGeni account, sign in and accept the invitation instead.`,
112
- html: `<p>${escapeHtml(greeting)}</p><p>You have been invited to <strong>${escapeHtml(input.organizationName)}</strong> 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)}">Set up your account</a></p><p>If you already have an OpenGeni account, sign in and accept the invitation instead.</p>`,
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 },