@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/codemode.ts CHANGED
@@ -61,6 +61,15 @@ export class CodemodeCatalogNotReadyError extends Error {
61
61
  }
62
62
  }
63
63
 
64
+ export class CodemodeCatalogStaleError extends Error {
65
+ readonly code = "codemode_catalog_stale";
66
+
67
+ constructor() {
68
+ super("Codemode tool catalog is stale for the active execution attempt");
69
+ this.name = "CodemodeCatalogStaleError";
70
+ }
71
+ }
72
+
64
73
  export function codemodeAuthorityForGrant(grant: AccessGrant): CodemodeGrantAuthority | null {
65
74
  const metadata = grant.metadata;
66
75
  if (
@@ -153,6 +162,7 @@ export async function submitAndDispatchCodemodeCall(
153
162
  ): Promise<CodemodeCallSubmissionValue> {
154
163
  const request = CodemodeCallRequest.parse(rawRequest);
155
164
  const { authority, catalog } = await requireActiveCodemodeCatalog(deps, grant);
165
+ if (request.catalogDigest !== catalog.digest) throw new CodemodeCatalogStaleError();
156
166
  const submitted = await submitCodemodeOperation(deps.db, {
157
167
  ...authority,
158
168
  call: {
@@ -166,7 +176,7 @@ export async function submitAndDispatchCodemodeCall(
166
176
  : operation.state === "running"
167
177
  ? "already_running"
168
178
  : "unavailable";
169
- if (operation.state === "queued") {
179
+ if (codemodeOperationNeedsDispatch(operation)) {
170
180
  try {
171
181
  const reply = await deps.bus.request(
172
182
  codemodeDispatchSubject(authority.workspaceId, authority.attemptId),
@@ -181,13 +191,14 @@ export async function submitAndDispatchCodemodeCall(
181
191
  } catch {
182
192
  dispatch = "unavailable";
183
193
  }
184
- operation =
185
- (await getCodemodeOperation(deps.db, {
194
+ operation = await refreshAdmittedCodemodeOperation(operation, () =>
195
+ getCodemodeOperation(deps.db, {
186
196
  accountId: authority.accountId,
187
197
  workspaceId: authority.workspaceId,
188
198
  attemptId: authority.attemptId,
189
199
  operationId: operation.operationId,
190
- })) ?? operation;
200
+ }),
201
+ );
191
202
  if (terminal(operation)) dispatch = "terminal";
192
203
  else if (operation.state === "running" && dispatch === "unavailable") {
193
204
  dispatch = "already_running";
@@ -196,6 +207,10 @@ export async function submitAndDispatchCodemodeCall(
196
207
  return CodemodeCallSubmission.parse({ operation, dispatch });
197
208
  }
198
209
 
210
+ export function codemodeOperationNeedsDispatch(operation: CodemodeOperation): boolean {
211
+ return operation.state === "queued" || operation.state === "running";
212
+ }
213
+
199
214
  export async function readCodemodeOperation(
200
215
  deps: ApiRouteDeps,
201
216
  grant: AccessGrant,
@@ -214,3 +229,17 @@ export async function readCodemodeOperation(
214
229
  function terminal(operation: CodemodeOperation): boolean {
215
230
  return ["completed", "failed", "outcome_unknown", "cancelled"].includes(operation.state);
216
231
  }
232
+
233
+ export async function refreshAdmittedCodemodeOperation(
234
+ admitted: CodemodeOperation,
235
+ read: () => Promise<CodemodeOperation | null>,
236
+ ): Promise<CodemodeOperation> {
237
+ try {
238
+ return (await read()) ?? admitted;
239
+ } catch {
240
+ // Admission is already durable. Returning the known row lets the client
241
+ // continue with the same operation id instead of turning a refresh outage
242
+ // into an unmarked post-commit failure.
243
+ return admitted;
244
+ }
245
+ }
@@ -1,15 +1,61 @@
1
1
  import type {
2
+ GitHubAppRepositoryBranchPage,
2
3
  GitHubBindingStatus,
3
4
  GitHubInstallationBinding,
4
5
  GitHubRepository,
5
6
  } from "@opengeni/contracts";
6
7
  import {
8
+ GitHubRepositoryBranchesResponse as GitHubRepositoryBranchesResponseSchema,
9
+ type GitHubRepositoryBranchesResponse,
10
+ type ListGitHubRepositoryBranchesQuery,
11
+ } from "@opengeni/contracts/github-repository-contracts";
12
+ import {
13
+ areGitHubRepositoriesAllowedForWorkspace,
7
14
  hasAuditableGitHubInstallationAuthority,
8
15
  listGitHubInstallationAccessForWorkspace,
9
16
  } from "@opengeni/db";
10
- import { listGitHubAppInstallationSummaries, listGitHubAppRepositories } from "@opengeni/github";
17
+ import {
18
+ GitHubAppApiError,
19
+ listGitHubAppInstallationSummaries,
20
+ listGitHubAppRepositories,
21
+ listGitHubAppRepositoryBranches,
22
+ } from "@opengeni/github";
11
23
  import type { ApiRouteDeps } from "@opengeni/core";
12
24
 
25
+ export type GitHubRepositoryBranchAuthorityErrorCode = "changed" | "not_authorized";
26
+
27
+ export class GitHubRepositoryBranchAuthorityError extends Error {
28
+ constructor(readonly code: GitHubRepositoryBranchAuthorityErrorCode) {
29
+ super(code);
30
+ this.name = "GitHubRepositoryBranchAuthorityError";
31
+ }
32
+ }
33
+
34
+ export type WorkspaceGitHubRepositoryBranchServices = {
35
+ listInstallationAccess: typeof listGitHubInstallationAccessForWorkspace;
36
+ areRepositoriesAllowed: typeof areGitHubRepositoriesAllowedForWorkspace;
37
+ listProviderBranches: (
38
+ deps: ApiRouteDeps,
39
+ input: {
40
+ installationId: number;
41
+ repositoryId: number;
42
+ page: number;
43
+ limit: number;
44
+ },
45
+ ) => Promise<GitHubAppRepositoryBranchPage | null>;
46
+ };
47
+
48
+ const workspaceGitHubRepositoryBranchServices: WorkspaceGitHubRepositoryBranchServices = {
49
+ listInstallationAccess: listGitHubInstallationAccessForWorkspace,
50
+ areRepositoriesAllowed: areGitHubRepositoriesAllowedForWorkspace,
51
+ listProviderBranches: async (deps, input) =>
52
+ deps.githubAppApi?.listRepositoryBranches
53
+ ? await deps.githubAppApi.listRepositoryBranches(input)
54
+ : deps.githubAppApi
55
+ ? null
56
+ : await listGitHubAppRepositoryBranches(deps.settings, input),
57
+ };
58
+
13
59
  export async function listWorkspaceGitHubInstallationBindings(
14
60
  deps: ApiRouteDeps,
15
61
  workspaceId: string,
@@ -153,3 +199,73 @@ export async function listWorkspaceGitHubRepositories(
153
199
  return installation.repositoryIds.includes(repository.id);
154
200
  });
155
201
  }
202
+
203
+ export async function listWorkspaceGitHubRepositoryBranches(
204
+ deps: ApiRouteDeps,
205
+ input: {
206
+ accountId: string;
207
+ workspaceId: string;
208
+ installationId: number;
209
+ repositoryId: number;
210
+ query: ListGitHubRepositoryBranchesQuery;
211
+ },
212
+ services: WorkspaceGitHubRepositoryBranchServices = workspaceGitHubRepositoryBranchServices,
213
+ ): Promise<GitHubRepositoryBranchesResponse> {
214
+ const installations = await services.listInstallationAccess(deps.db, input.workspaceId);
215
+ const installation = installations.find(
216
+ (candidate) => candidate.installationId === input.installationId,
217
+ );
218
+ if (
219
+ !installation ||
220
+ installation.accountId !== input.accountId ||
221
+ !hasAuditableGitHubInstallationAuthority(installation) ||
222
+ !installation.repositoryIds.includes(input.repositoryId)
223
+ ) {
224
+ throw new GitHubRepositoryBranchAuthorityError("not_authorized");
225
+ }
226
+ if (
227
+ !(await services.areRepositoriesAllowed(deps.db, input.workspaceId, input.installationId, [
228
+ input.repositoryId,
229
+ ]))
230
+ ) {
231
+ throw new GitHubRepositoryBranchAuthorityError("changed");
232
+ }
233
+ const page = await services.listProviderBranches(deps, {
234
+ installationId: input.installationId,
235
+ repositoryId: input.repositoryId,
236
+ page: input.query.cursor,
237
+ limit: input.query.limit,
238
+ });
239
+ if (!page) {
240
+ throw new GitHubAppApiError(
241
+ "The configured GitHub provider cannot list exact repository branches",
242
+ );
243
+ }
244
+ if (
245
+ page.installationId !== input.installationId ||
246
+ page.repositoryId !== input.repositoryId ||
247
+ page.branches.length > input.query.limit ||
248
+ (page.nextPage !== null && page.nextPage !== input.query.cursor + 1) ||
249
+ (page.branches.length < input.query.limit && page.nextPage !== null)
250
+ ) {
251
+ throw new GitHubAppApiError("GitHub returned an invalid repository branches page");
252
+ }
253
+ const response = GitHubRepositoryBranchesResponseSchema.safeParse({
254
+ branches: page.branches.map((name) => ({
255
+ name,
256
+ isDefault: name === page.defaultBranch,
257
+ })),
258
+ nextCursor: page.nextPage,
259
+ });
260
+ if (!response.success) {
261
+ throw new GitHubAppApiError("GitHub returned an invalid repository branches page");
262
+ }
263
+ if (
264
+ !(await services.areRepositoriesAllowed(deps.db, input.workspaceId, input.installationId, [
265
+ input.repositoryId,
266
+ ]))
267
+ ) {
268
+ throw new GitHubRepositoryBranchAuthorityError("changed");
269
+ }
270
+ return response.data;
271
+ }
package/src/http/auth.ts CHANGED
@@ -2,6 +2,11 @@ import { resolveFirstPartyDelegationSecret, type Settings } from "@opengeni/conf
2
2
  import { verifyDelegatedAccessToken } from "@opengeni/contracts";
3
3
  import type { Context, MiddlewareHandler } from "hono";
4
4
  import { installExactPaths, isInstallRedirectPath } from "../routes/install";
5
+ import {
6
+ isMcpOAuthPublicProtocolPath,
7
+ isMcpOAuthResourcePath,
8
+ mcpOAuthBearerToken,
9
+ } from "../mcp-oauth";
5
10
 
6
11
  const githubConnectPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/connect$/;
7
12
  const githubInstallationLinkPathPattern = /^\/v1\/workspaces\/[^/]+\/github\/installations$/;
@@ -33,6 +38,12 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
33
38
  return true;
34
39
  }
35
40
  const path = new URL(c.req.url).pathname;
41
+ if (settings.mcpOauthEnabled && isMcpOAuthPublicProtocolPath(path)) {
42
+ return true;
43
+ }
44
+ if (settings.mcpOauthEnabled && isMcpOAuthResourcePath(path) && mcpOAuthBearerToken(c.req.raw)) {
45
+ return true;
46
+ }
36
47
  if (path === "/v1/config/client") {
37
48
  return true;
38
49
  }
@@ -0,0 +1,37 @@
1
+ import type { Context } from "hono";
2
+
3
+ const TRANSPORT_PEER_ADDRESS_BINDING = "opengeniTransportPeerAddress";
4
+
5
+ type ApiRequestBindings = {
6
+ [TRANSPORT_PEER_ADDRESS_BINDING]?: string | null;
7
+ };
8
+
9
+ export function apiRequestBindingsForTransportPeer(
10
+ address: string | null | undefined,
11
+ ): ApiRequestBindings {
12
+ return { [TRANSPORT_PEER_ADDRESS_BINDING]: address ?? null };
13
+ }
14
+
15
+ /**
16
+ * Resolve a quota/audit source from the server-owned transport peer. Forwarded
17
+ * values are considered only when the operator declares an exact trusted proxy
18
+ * hop count; the chain is then walked from the server side so caller-prepended
19
+ * values cannot replace the address inserted by the trusted edge.
20
+ */
21
+ export function trustedRequestSourceAddress(c: Context, trustedProxyHops: number): string {
22
+ const bindings = c.env as ApiRequestBindings | undefined;
23
+ const peer = normalizedAddress(bindings?.[TRANSPORT_PEER_ADDRESS_BINDING]) ?? "unknown";
24
+ if (trustedProxyHops <= 0 || peer === "unknown") return peer;
25
+
26
+ const forwarded = (c.req.header("x-forwarded-for") ?? "")
27
+ .split(",")
28
+ .map(normalizedAddress)
29
+ .filter((value): value is string => value !== null);
30
+ const sourceIndex = forwarded.length - trustedProxyHops;
31
+ return sourceIndex >= 0 ? forwarded[sourceIndex]! : peer;
32
+ }
33
+
34
+ function normalizedAddress(value: string | null | undefined): string | null {
35
+ const normalized = value?.trim();
36
+ return normalized ? normalized.slice(0, 128) : null;
37
+ }
package/src/http/sse.ts CHANGED
@@ -10,12 +10,11 @@ import {
10
10
  type Database,
11
11
  } from "@opengeni/db";
12
12
  import {
13
- coalesceSessionEventDeltas,
13
+ coalesceSessionEventDeltasWithCoverage,
14
14
  formatSessionEventSse,
15
15
  formatWorkspaceControlEventSse,
16
16
  requireSessionEventDurableFanoutCapability,
17
17
  SESSION_EVENT_SSE_FRAME_MAX_BYTES,
18
- sessionEventResumeSequence,
19
18
  type EventBus,
20
19
  } from "@opengeni/events";
21
20
  import type { Observability } from "@opengeni/observability";
@@ -295,8 +294,14 @@ export async function sseSessionStream(
295
294
  limit: SESSION_REPLAY_PAGE_SIZE,
296
295
  });
297
296
  await options.reauthorize?.();
297
+ const compactProjection = coalesceSessionEventDeltasWithCoverage(events);
298
298
  return finiteSseBatchResponse(
299
- coalesceSessionEventDeltas(events).map(formatSessionEventSse),
299
+ compactProjection.events.map((event) =>
300
+ formatSessionEventSse(
301
+ event,
302
+ compactProjection.coveredThroughBySequence.get(event.sequence) ?? event.sequence,
303
+ ),
304
+ ),
300
305
  options,
301
306
  );
302
307
  }
@@ -378,9 +383,12 @@ export async function sseSessionStream(
378
383
  // adjacent text deltas into bounded frames carrying `coalescedUntil`, so
379
384
  // a long answer cannot create thousands of React renders and starve
380
385
  // command acknowledgements behind its own token stream.
381
- for (const projected of coalesceSessionEventDeltas(eligible)) {
382
- await writeFrame(formatSessionEventSse(projected));
383
- lastSent = sessionEventResumeSequence(projected);
386
+ const compactProjection = coalesceSessionEventDeltasWithCoverage(eligible);
387
+ for (const projected of compactProjection.events) {
388
+ const coveredThrough =
389
+ compactProjection.coveredThroughBySequence.get(projected.sequence) ?? projected.sequence;
390
+ await writeFrame(formatSessionEventSse(projected, coveredThrough));
391
+ lastSent = coveredThrough;
384
392
  }
385
393
  if (lastSent <= previousLastSent) {
386
394
  throw new Error(`Session event replay made no progress after sequence ${lastSent}`);
@@ -432,7 +440,7 @@ export async function sseSessionStream(
432
440
  drainReconnectReconciliation();
433
441
  };
434
442
  const send = async (event: SessionEvent) => {
435
- const targetSequence = sessionEventResumeSequence(event);
443
+ const targetSequence = event.sequence;
436
444
  if (targetSequence <= lastSent) return;
437
445
  await reconcileDurableThrough(targetSequence);
438
446
  };
package/src/index.ts CHANGED
@@ -28,7 +28,10 @@ import {
28
28
  } from "@opengeni/observability";
29
29
  import { createObjectStorage } from "@opengeni/storage";
30
30
  import { isArtifactRuntimeConfigured } from "@opengeni/artifact-tool/runtime/development";
31
- import { SESSION_WORKFLOW_WAKE_DISPATCHER_SCHEDULE_ID } from "@opengeni/core";
31
+ import {
32
+ resolveCatalogSettings,
33
+ SESSION_WORKFLOW_WAKE_DISPATCHER_SCHEDULE_ID,
34
+ } from "@opengeni/core";
32
35
  import {
33
36
  Connection,
34
37
  Client as TemporalClient,
@@ -51,6 +54,7 @@ import {
51
54
  } from "./editable-artifact-websocket";
52
55
  import type { ApiWebSocketConnection } from "./api-websocket";
53
56
  import { InteractionFrameProxyTransport } from "./interaction-frame-proxy";
57
+ import { apiRequestBindingsForTransportPeer } from "./http/request-source";
54
58
  import {
55
59
  createStandaloneEditableArtifactApplication,
56
60
  type StandaloneEditableArtifactApplication,
@@ -357,6 +361,15 @@ export async function startApi(
357
361
  () => assertRuntimeDatabasePosture(dbClient.db, databasePosture),
358
362
  { ...retryOptions, onRetry },
359
363
  );
364
+ const resolvedCatalog = await retryStartupDependency(
365
+ "model catalog",
366
+ () => resolveCatalogSettings(dbClient.db, settings),
367
+ { ...retryOptions, onRetry },
368
+ );
369
+ observability.info("OpenGeni model catalog resolved", {
370
+ catalogSource: resolvedCatalog.source,
371
+ catalogVersion: resolvedCatalog.version,
372
+ });
360
373
  bus = await retryStartupDependency(
361
374
  "NATS",
362
375
  () =>
@@ -443,7 +456,10 @@ export async function startApi(
443
456
  if (artifactWebSockets.handles(request)) {
444
457
  return artifactWebSockets.upgrade(request, bunServer);
445
458
  }
446
- return app.fetch(request);
459
+ return app.fetch(
460
+ request,
461
+ apiRequestBindingsForTransportPeer(bunServer.requestIP(request)?.address),
462
+ );
447
463
  },
448
464
  websocket: {
449
465
  maxPayloadLength: EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES,