@opengeni/api-router 2.11.3 → 2.12.3-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 (66) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
  3. package/dist/chunk-4AQMKVQM.js.map +1 -0
  4. package/dist/connection-ownership.d.ts +5 -1
  5. package/dist/index.js +3 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/atlassian.d.ts +4 -0
  8. package/dist/integrations/connect-authority.d.ts +1 -0
  9. package/dist/integrations/connect-callback-return.d.ts +6 -0
  10. package/dist/integrations/fiken.d.ts +7 -0
  11. package/dist/integrations/github-app-connect.d.ts +47 -0
  12. package/dist/integrations/github-installation-proof.d.ts +3 -0
  13. package/dist/integrations/github-lens-connect.d.ts +153 -0
  14. package/dist/integrations/google-drive.d.ts +4 -0
  15. package/dist/integrations/oauth-client.d.ts +4 -0
  16. package/dist/integrations/oauth-return-path.d.ts +1 -0
  17. package/dist/integrations/personal-github.d.ts +2 -0
  18. package/dist/integrations/provider-oauth.d.ts +11 -1
  19. package/dist/integrations/slack-install.d.ts +20 -0
  20. package/dist/integrations/social-oauth.d.ts +2 -0
  21. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  22. package/dist/routes/api-integrations.d.ts +14 -0
  23. package/dist/routes/connect.d.ts +5 -0
  24. package/dist/routes/external-identity-links.d.ts +6 -0
  25. package/dist/routes/host-mcp-bindings.d.ts +5 -0
  26. package/dist/sandbox/rematerialize.d.ts +1 -1
  27. package/dist/workspace-tool-gateway.d.ts +1 -0
  28. package/package.json +19 -19
  29. package/src/app.ts +22 -2
  30. package/src/connection-ownership.ts +14 -1
  31. package/src/index.ts +2 -0
  32. package/src/integrations/atlassian.ts +143 -39
  33. package/src/integrations/connect-authority.ts +2 -0
  34. package/src/integrations/connect-callback-return.ts +86 -0
  35. package/src/integrations/fiken.ts +222 -40
  36. package/src/integrations/github-app-connect.ts +389 -0
  37. package/src/integrations/github-installation-proof.ts +60 -0
  38. package/src/integrations/github-lens-connect.ts +97 -0
  39. package/src/integrations/google-drive.ts +152 -47
  40. package/src/integrations/oauth-client.ts +180 -84
  41. package/src/integrations/oauth-return-path.ts +18 -0
  42. package/src/integrations/personal-github.ts +146 -52
  43. package/src/integrations/provider-oauth.ts +132 -21
  44. package/src/integrations/slack-install.ts +83 -0
  45. package/src/integrations/social-oauth.ts +142 -1
  46. package/src/mcp/documents.ts +1 -1
  47. package/src/mcp/server.ts +64 -17
  48. package/src/routes/api-integrations.ts +74 -64
  49. package/src/routes/connect.ts +1869 -0
  50. package/src/routes/connections.ts +263 -221
  51. package/src/routes/documents.ts +2 -2
  52. package/src/routes/external-identity-links.ts +200 -0
  53. package/src/routes/github.ts +39 -64
  54. package/src/routes/host-mcp-bindings.ts +122 -0
  55. package/src/routes/organization-memberships.ts +23 -0
  56. package/src/routes/organization-sessions.ts +2 -2
  57. package/src/routes/personal-github.ts +8 -1
  58. package/src/routes/pr-review-github.ts +30 -3
  59. package/src/routes/scheduled-tasks.ts +29 -4
  60. package/src/routes/sessions.ts +61 -32
  61. package/src/routes/social.ts +5 -1
  62. package/src/routes/supergrok.ts +90 -3
  63. package/src/routes/workspaces.ts +16 -0
  64. package/src/sandbox/rematerialize.ts +33 -1
  65. package/src/workspace-tool-gateway.ts +136 -24
  66. package/dist/chunk-5X3XM6CE.js.map +0 -1
@@ -1,7 +1,20 @@
1
1
  import type { ConnectionOwnership } from "@opengeni/contracts";
2
- import type { AccessGrantAuthorization } from "@opengeni/core";
2
+ import {
3
+ externalActorContinuationForAuthorization,
4
+ type AccessGrantAuthorization,
5
+ } from "@opengeni/core";
3
6
  import { HTTPException } from "hono/http-exception";
4
7
 
8
+ /** A verified external actor must not enter a native callback that has no
9
+ * corresponding key/identity reauthorization proof. Remove at an entry point
10
+ * only when that provider's signed continuation and commit fence are wired. */
11
+ export function requireLegacyOAuthActor(access: AccessGrantAuthorization): void {
12
+ if (externalActorContinuationForAuthorization(access))
13
+ throw new HTTPException(422, {
14
+ message: "This provider does not yet support external-user OAuth continuation",
15
+ });
16
+ }
17
+
5
18
  /**
6
19
  * Who may own a *personal* Connection.
7
20
  *
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type Observability,
28
28
  } from "@opengeni/observability";
29
29
  import { createObjectStorage } from "@opengeni/storage";
30
+ import { createRemoteMcpCredentialsPort } from "@opengeni/core/remote-mcp-credentials";
30
31
  import { isArtifactRuntimeConfigured } from "@opengeni/artifact-tool/runtime/development";
31
32
  import {
32
33
  resolveCatalogSettings,
@@ -419,6 +420,7 @@ export async function startApi(
419
420
  }
420
421
  const { app, routeDeps } = createAppComposition({
421
422
  settings,
423
+ connectionCredentials: createRemoteMcpCredentialsPort(settings),
422
424
  db: dbClient.db,
423
425
  bus,
424
426
  workflowClient: workflowClient.client,
@@ -1,4 +1,7 @@
1
1
  import type { Settings } from "@opengeni/config";
2
+ import { createHash } from "node:crypto";
3
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
4
+ import { requireConnectOwnerAuthority } from "./connect-authority";
2
5
  import type { AccessGrant, ScheduledTask, ScheduledTaskScheduleSpec } from "@opengeni/contracts";
3
6
  import {
4
7
  ATLASSIAN_CREDENTIAL_LABEL,
@@ -24,7 +27,6 @@ import {
24
27
  import {
25
28
  captureScheduledTaskRestoreState,
26
29
  createValidatedScheduledTask,
27
- hasPermission,
28
30
  manualScheduledTaskTriggerWorkflowId,
29
31
  requireEnvironmentEncryption,
30
32
  syncCreatedScheduledTask,
@@ -37,6 +39,11 @@ import {
37
39
  ConnectionDisconnectGenerationError,
38
40
  ConnectionDisconnectIdempotencyError,
39
41
  consumeIntegrationOAuthStateNonce,
42
+ claimConnectOperation,
43
+ finishConnectOperation,
44
+ getConnectAttempt,
45
+ decryptEnvironmentValue,
46
+ type Database,
40
47
  createConnection,
41
48
  deauthorizeKnowledgeSourceRetrieval,
42
49
  disconnectConnectionIdempotently,
@@ -44,7 +51,6 @@ import {
44
51
  getConnectionMetadata,
45
52
  getKnowledgeSourceByExternalIdentityForSyncAuthority,
46
53
  getKnowledgeSourceForSyncAuthority,
47
- getWorkspaceGrant,
48
54
  listKnowledgeSourceSyncTasksForConnection,
49
55
  loadConnectionCredentialForBroker,
50
56
  recordKnowledgeLifecycleEvent,
@@ -87,6 +93,8 @@ const ATLASSIAN_REQUEST_TIMEOUT_MS = 15_000;
87
93
  const ATLASSIAN_RETURN_PATH = (workspaceId: string) => `/workspaces/${workspaceId}/capabilities`;
88
94
 
89
95
  type AtlassianOAuthState = {
96
+ connectAttemptId?: string;
97
+ externalContinuation?: ExternalActorContinuation;
90
98
  accountId: string;
91
99
  workspaceId: string;
92
100
  subjectId: string;
@@ -115,6 +123,8 @@ export async function startAtlassianOAuth(
115
123
  subjectId: string;
116
124
  requestUrl: string;
117
125
  payload: AtlassianOAuthStartRequest;
126
+ connectAttemptId?: string;
127
+ externalContinuation?: ExternalActorContinuation;
118
128
  },
119
129
  ): Promise<AtlassianOAuthStartResponse> {
120
130
  const oauth = requireAtlassianSettings(deps.settings);
@@ -138,6 +148,15 @@ export async function startAtlassianOAuth(
138
148
  workspaceId: input.workspaceId,
139
149
  subjectId: input.subjectId,
140
150
  kind: ATLASSIAN_OAUTH_STATE_KIND,
151
+ ...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
152
+ ...(input.externalContinuation
153
+ ? {
154
+ encryptedExternalContinuation: encryptEnvironmentValue(
155
+ requireEnvironmentEncryption(deps.settings),
156
+ JSON.stringify(ExternalActorContinuation.parse(input.externalContinuation)),
157
+ ),
158
+ }
159
+ : {}),
141
160
  // The route admits only a managed human, so reaching here is the proof; the
142
161
  // callback has no live principal and enforces exactly this claim.
143
162
  [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: true,
@@ -166,12 +185,32 @@ export async function completeAtlassianOAuthCallback(
166
185
  error?: string | undefined;
167
186
  requestUrl: string;
168
187
  },
169
- ): Promise<{ redirectTo: string }> {
188
+ ): Promise<{ redirectTo: string; exactReturn?: boolean }> {
170
189
  const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
171
190
  const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? baseUrl;
172
191
  let state: AtlassianOAuthState | null = null;
192
+ let exactReturnUrl: string | undefined;
193
+ let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
173
194
  try {
174
195
  state = readAtlassianOAuthState(input.state, deps.settings);
196
+ if (state.connectAttemptId) {
197
+ const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId);
198
+ if (stored.attempt.providerId !== "atlassian" || stored.attempt.ownership !== "personal")
199
+ throw new AtlassianCallbackError("connection_conflict");
200
+ exactReturnUrl = stored.returnUrl;
201
+ operation = {
202
+ attemptId: state.connectAttemptId,
203
+ operationId: `oauth:${state.nonce}`,
204
+ inputDigest: createHash("sha256").update(input.state!).digest("hex"),
205
+ };
206
+ const claim = await claimConnectOperation(deps.db, state, {
207
+ ...operation,
208
+ expectedRevision: stored.attempt.revision,
209
+ authorize: (tx, _attempt, origin) =>
210
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
211
+ });
212
+ if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
213
+ }
175
214
  await requireCallbackGrant(deps, state);
176
215
  const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
177
216
  accountId: state.accountId,
@@ -182,6 +221,25 @@ export async function completeAtlassianOAuthCallback(
182
221
  now: new Date(),
183
222
  });
184
223
  if (!consumed) throw new AtlassianCallbackError("state_reused");
224
+ if (operation && (input.error || !input.code)) {
225
+ await finishConnectOperation(deps.db, state, {
226
+ ...operation,
227
+ authorize: (tx, _attempt, origin) =>
228
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
229
+ commit: async (_tx, current) => ({
230
+ ...current,
231
+ revision: current.revision + 1,
232
+ state: input.error === "access_denied" ? "cancelled" : "failed",
233
+ nextAction: { type: "none" },
234
+ error: {
235
+ code: input.error ? "provider_denied" : "missing_code",
236
+ message: "Authorization was not completed. Start a new connection attempt.",
237
+ retryable: false,
238
+ },
239
+ }),
240
+ });
241
+ return { redirectTo: exactReturnUrl!, exactReturn: true };
242
+ }
185
243
  if (input.error) throw new AtlassianCallbackError("provider_denied");
186
244
  if (!input.code) throw new AtlassianCallbackError("missing_code");
187
245
 
@@ -262,39 +320,77 @@ export async function completeAtlassianOAuthCallback(
262
320
  : {}),
263
321
  selectedSources: previousMetadata?.selectedSources ?? [],
264
322
  });
265
- const connection = existing
266
- ? await updateConnection(deps.db, {
267
- workspaceId: state.workspaceId,
268
- connectionId: existing.id,
269
- visibleToSubjectId: state.subjectId,
270
- expectedVersion: existing.version,
271
- subjectId: state.subjectId,
272
- providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
273
- kind: "oauth2",
274
- status: "active",
275
- credentialEncrypted,
276
- grantedScopes: token.scopes,
277
- expiresAt: token.expiresAt,
278
- metadata,
279
- updatedBySubjectId: state.subjectId,
280
- })
281
- : await createConnection(deps.db, {
282
- accountId: state.accountId,
283
- workspaceId: state.workspaceId,
284
- subjectId: state.subjectId,
285
- providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
286
- kind: "oauth2",
287
- credentialEncrypted,
288
- grantedScopes: token.scopes,
289
- expiresAt: token.expiresAt,
290
- metadata,
291
- createdBySubjectId: state.subjectId,
292
- });
323
+ const acceptedState = state;
324
+ const persist = (tx: Database) => {
325
+ // Capture the validated immutable callback state for delayed persistence.
326
+ // eslint-disable-next-line no-shadow
327
+ const state = acceptedState;
328
+ return existing
329
+ ? updateConnection(tx, {
330
+ workspaceId: state.workspaceId,
331
+ connectionId: existing.id,
332
+ visibleToSubjectId: state.subjectId,
333
+ expectedVersion: existing.version,
334
+ subjectId: state.subjectId,
335
+ providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
336
+ kind: "oauth2",
337
+ status: "active",
338
+ credentialEncrypted,
339
+ grantedScopes: token.scopes,
340
+ expiresAt: token.expiresAt,
341
+ metadata,
342
+ updatedBySubjectId: state.subjectId,
343
+ })
344
+ : createConnection(tx, {
345
+ accountId: state.accountId,
346
+ workspaceId: state.workspaceId,
347
+ subjectId: state.subjectId,
348
+ providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
349
+ kind: "oauth2",
350
+ credentialEncrypted,
351
+ grantedScopes: token.scopes,
352
+ expiresAt: token.expiresAt,
353
+ metadata,
354
+ createdBySubjectId: state.subjectId,
355
+ });
356
+ };
357
+ if (operation) {
358
+ await finishConnectOperation(deps.db, acceptedState, {
359
+ ...operation,
360
+ authorize: (tx, _attempt, origin) =>
361
+ requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
362
+ commit: async (tx, current) => {
363
+ const connection = await persist(tx);
364
+ if (!connection) throw new AtlassianCallbackError("connection_conflict");
365
+ return {
366
+ ...current,
367
+ revision: current.revision + 1,
368
+ state: "complete",
369
+ credentialsCommitted: true,
370
+ nextAction: { type: "none" },
371
+ account: {
372
+ id: connection.id,
373
+ version: connection.version,
374
+ providerId: "atlassian",
375
+ label: profile.displayName ?? "Atlassian",
376
+ ownership: "personal",
377
+ status: "connected",
378
+ },
379
+ };
380
+ },
381
+ });
382
+ return { redirectTo: exactReturnUrl!, exactReturn: true };
383
+ }
384
+ const connection = await deps.db.transaction(async (tx) => {
385
+ await requireCallbackGrant({ ...deps, db: tx }, acceptedState);
386
+ return persist(tx);
387
+ });
293
388
  if (!connection) throw new AtlassianCallbackError("connection_conflict");
294
389
  return {
295
390
  redirectTo: returnUrl(returnBaseUrl, state.returnPath, "connected", connection.id),
296
391
  };
297
392
  } catch (error) {
393
+ if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
298
394
  return {
299
395
  redirectTo: returnUrl(
300
396
  returnBaseUrl,
@@ -1521,6 +1617,21 @@ function readAtlassianOAuthState(raw: string | undefined, settings: Settings): A
1521
1617
  workspaceId,
1522
1618
  subjectId,
1523
1619
  personalOwnerVerified: personalOwnerVerifiedInState(payload),
1620
+ ...(typeof payload.connectAttemptId === "string"
1621
+ ? { connectAttemptId: payload.connectAttemptId }
1622
+ : {}),
1623
+ ...(typeof payload.encryptedExternalContinuation === "string"
1624
+ ? {
1625
+ externalContinuation: ExternalActorContinuation.parse(
1626
+ JSON.parse(
1627
+ decryptEnvironmentValue(
1628
+ requireEnvironmentEncryption(settings),
1629
+ payload.encryptedExternalContinuation,
1630
+ ),
1631
+ ),
1632
+ ),
1633
+ }
1634
+ : {}),
1524
1635
  returnPath,
1525
1636
  ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
1526
1637
  nonce: requiredString(payload.nonce, "state.nonce"),
@@ -1544,14 +1655,7 @@ async function requireCallbackGrant(deps: ApiRouteDeps, state: AtlassianOAuthSta
1544
1655
  message: personalOnlyConnectionPrincipalMessage("Atlassian"),
1545
1656
  });
1546
1657
  }
1547
- const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
1548
- if (
1549
- !grant ||
1550
- grant.accountId !== state.accountId ||
1551
- !hasPermission(grant.permissions, "connections:write")
1552
- ) {
1553
- throw new HTTPException(403, { message: "Atlassian OAuth subject no longer has access" });
1554
- }
1658
+ await requireConnectOwnerAuthority(deps.db, state, "connections:write");
1555
1659
  }
1556
1660
 
1557
1661
  function requireAtlassianSettings(settings: Settings) {
@@ -0,0 +1,2 @@
1
+ // API adapters and core host-binding admission share one authority implementation.
2
+ export { requireConnectOwnerAuthority } from "@opengeni/core";
@@ -0,0 +1,86 @@
1
+ import type { Hono } from "hono";
2
+ import type { ApiRouteDeps } from "@opengeni/core";
3
+ import { getConnectAttempt } from "@opengeni/db";
4
+ import { readSignedState } from "@opengeni/github";
5
+ import { z } from "zod";
6
+
7
+ const callbacks = new Set([
8
+ "/v1/integrations/slack/callback",
9
+ "/v1/integrations/fiken/callback",
10
+ "/v1/integrations/atlassian/callback",
11
+ "/v1/integrations/google-drive/callback",
12
+ "/v1/integrations/oauth/callback",
13
+ "/v1/integrations/provider-oauth/callback",
14
+ "/v1/integrations/github-personal/oauth/callback",
15
+ "/v1/social/oauth/callback",
16
+ "/v1/github/oauth/callback",
17
+ "/v1/github/setup",
18
+ "/v1/github/install/callback",
19
+ "/v1/pr-review/github/oauth/callback",
20
+ "/v1/pr-review/github/setup",
21
+ "/v1/pr-review/github/install/callback",
22
+ ]);
23
+ const locator = z.object({
24
+ accountId: z.string().uuid(),
25
+ workspaceId: z.string().uuid(),
26
+ subjectId: z.string().min(1).max(1024),
27
+ connectAttemptId: z.string().uuid(),
28
+ iat: z.number().int().nonnegative(),
29
+ });
30
+
31
+ /** Navigation-only recovery. Recently expired signed state can identify the host's saved
32
+ * destination, but cannot authorize exchange, persistence, or credential use.
33
+ * Normal callbacks independently enforce current time and live authority. */
34
+ export function registerConnectCallbackReturns(app: Hono, deps: ApiRouteDeps) {
35
+ app.use("/v1/*", async (c, next) => {
36
+ if (c.req.method !== "GET" || !callbacks.has(c.req.path)) return next();
37
+ const raw = c.req.query("state");
38
+ if (!raw || raw.length > 32_768) return next();
39
+ let candidate: z.infer<typeof locator>;
40
+ try {
41
+ candidate = locator.parse(
42
+ JSON.parse(Buffer.from(raw.split(".")[0]!, "base64url").toString("utf8")),
43
+ );
44
+ } catch {
45
+ return next();
46
+ }
47
+ const secret =
48
+ c.req.path.startsWith("/v1/github/") || c.req.path.startsWith("/v1/pr-review/github/")
49
+ ? deps.githubStateSecret
50
+ : deps.settings.integrationsStateSecret?.trim();
51
+ // Verify at the signed timestamp ONLY for a read of the immutable return
52
+ // destination. Never pass this timestamp to the actual provider callback.
53
+ // Keep recovery useful after a normal provider timeout, without making a
54
+ // signed callback an indefinitely reusable redirect. This does not change
55
+ // the trusted backend's exact destination or provider exchange expiry.
56
+ const now = Math.floor(Date.now() / 1000);
57
+ const recoveryWindow = candidate.iat <= now + 60 && candidate.iat >= now - 24 * 60 * 60;
58
+ const verified =
59
+ secret && recoveryWindow
60
+ ? locator.safeParse(readSignedState(raw, secret, candidate.iat))
61
+ : null;
62
+ let destination: string | undefined;
63
+ if (verified?.success) {
64
+ try {
65
+ destination = (
66
+ await getConnectAttempt(deps.db, verified.data, verified.data.connectAttemptId)
67
+ ).returnUrl;
68
+ } catch {
69
+ /* Deleted attempts or unavailable storage cannot supply a return. */
70
+ }
71
+ }
72
+ await next();
73
+ c.res = destination
74
+ ? new Response(null, {
75
+ status: 302,
76
+ headers: { location: destination, "cache-control": "no-store" },
77
+ })
78
+ : new Response(
79
+ "Connection setup could not be completed. Return to the application and start again.",
80
+ {
81
+ status: 400,
82
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
83
+ },
84
+ );
85
+ });
86
+ }