@opengeni/api-router 3.0.1 → 4.0.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 (96) hide show
  1. package/dist/admission-trace.d.ts +8 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
  4. package/dist/auth/managed-auth-session-adapter.d.ts +2 -0
  5. package/dist/auth/managed-auth.d.ts +1 -0
  6. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  7. package/dist/{chunk-AJ62T5QN.js → chunk-E7TZFJ65.js} +22300 -21113
  8. package/dist/{chunk-AJ62T5QN.js.map → chunk-E7TZFJ65.js.map} +1 -1
  9. package/dist/connection-ownership.d.ts +1 -2
  10. package/dist/fatal-process-boundary.d.ts +1 -1
  11. package/dist/index.js +24 -6
  12. package/dist/index.js.map +1 -1
  13. package/dist/integrations/atlassian.d.ts +8 -3
  14. package/dist/integrations/fiken.d.ts +3 -1
  15. package/dist/integrations/github-lens-connect.d.ts +5 -65
  16. package/dist/integrations/google-drive.d.ts +2 -0
  17. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  18. package/dist/integrations/oauth-client.d.ts +119 -11
  19. package/dist/integrations/oauth-profiles.d.ts +8 -13
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
  23. package/dist/mcp-oauth.d.ts +22 -1
  24. package/dist/routes/api-integrations.d.ts +1 -0
  25. package/dist/routes/codex.d.ts +28 -0
  26. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  27. package/dist/routes/organization-integration-policy.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +24 -2
  29. package/dist/scheduled-task-deletion.d.ts +3 -4
  30. package/dist/workspace-tool-gateway.d.ts +3 -1
  31. package/package.json +15 -15
  32. package/src/admission-trace.ts +14 -0
  33. package/src/app.ts +141 -94
  34. package/src/auth/managed-auth-attempt-context.ts +9 -0
  35. package/src/auth/managed-auth-session-adapter.ts +11 -0
  36. package/src/auth/managed-auth.ts +39 -2
  37. package/src/auth/managed-sign-in-notifications.ts +81 -0
  38. package/src/connection-ownership.ts +2 -9
  39. package/src/editable-artifact-production.ts +6 -0
  40. package/src/fatal-process-boundary.ts +17 -2
  41. package/src/index.ts +3 -4
  42. package/src/integrations/atlassian.ts +184 -76
  43. package/src/integrations/fiken.ts +57 -35
  44. package/src/integrations/github-app-connect.ts +51 -15
  45. package/src/integrations/github-lens-connect.ts +7 -28
  46. package/src/integrations/google-drive.ts +296 -125
  47. package/src/integrations/integration-commit-authority.ts +69 -0
  48. package/src/integrations/oauth-client.ts +231 -132
  49. package/src/integrations/oauth-profiles.ts +10 -45
  50. package/src/integrations/personal-github.ts +55 -31
  51. package/src/integrations/provider-oauth.ts +57 -82
  52. package/src/integrations/social-oauth.ts +83 -56
  53. package/src/mcp/capability-account-readiness.ts +17 -0
  54. package/src/mcp/editable-artifacts.ts +7 -2
  55. package/src/mcp/first-party-tool-permissions.ts +1 -0
  56. package/src/mcp/knowledge-entries.ts +25 -8
  57. package/src/mcp/server.ts +135 -43
  58. package/src/mcp-oauth.ts +336 -34
  59. package/src/routes/api-integrations.ts +116 -4
  60. package/src/routes/automations.ts +5 -13
  61. package/src/routes/billing.ts +52 -0
  62. package/src/routes/canonical-human-identities.ts +61 -0
  63. package/src/routes/capabilities.ts +51 -1
  64. package/src/routes/codex.ts +4 -2
  65. package/src/routes/connect.ts +90 -31
  66. package/src/routes/connections.ts +487 -307
  67. package/src/routes/files.ts +21 -12
  68. package/src/routes/github.ts +48 -19
  69. package/src/routes/insights.ts +17 -7
  70. package/src/routes/integration-facets.ts +66 -25
  71. package/src/routes/knowledge.ts +48 -1
  72. package/src/routes/managed-sign-in-methods.ts +433 -0
  73. package/src/routes/organization-integration-policy.ts +113 -0
  74. package/src/routes/organization-memberships.ts +22 -2
  75. package/src/routes/plugins.ts +42 -4
  76. package/src/routes/pr-review-github.ts +67 -57
  77. package/src/routes/pr-review.ts +74 -71
  78. package/src/routes/rigs.ts +32 -13
  79. package/src/routes/scheduled-tasks.ts +6 -6
  80. package/src/routes/sessions.ts +233 -11
  81. package/src/routes/skill-content.ts +19 -3
  82. package/src/routes/social.ts +24 -13
  83. package/src/sandbox/channel-a.ts +7 -1
  84. package/src/sandbox/rematerialize.ts +2 -1
  85. package/src/scheduled-task-deletion.ts +13 -3
  86. package/src/workspace-tool-gateway.ts +31 -31
  87. package/dist/connection-authority-owner.d.ts +0 -59
  88. package/dist/routes/connection-authorities.d.ts +0 -3
  89. package/dist/routes/host-mcp-bindings.d.ts +0 -5
  90. package/dist/routes/host-mcp-resolvers.d.ts +0 -3
  91. package/dist/routes/packs.d.ts +0 -3
  92. package/src/connection-authority-owner.ts +0 -61
  93. package/src/routes/connection-authorities.ts +0 -139
  94. package/src/routes/host-mcp-bindings.ts +0 -122
  95. package/src/routes/host-mcp-resolvers.ts +0 -41
  96. package/src/routes/packs.ts +0 -784
@@ -1,3 +1,5 @@
1
+ import { withOrganizationIntegrationAcquisition } from "@opengeni/db/organization-integration-policy";
2
+ import { claimOAuthAcquisition, finishOAuthAcquisition } from "./oauth-client";
1
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
4
  import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
3
5
  import { requireConnectOwnerAuthority } from "./connect-authority";
@@ -23,7 +25,6 @@ import {
23
25
  } from "@opengeni/core";
24
26
  import {
25
27
  consumeIntegrationOAuthStateNonce,
26
- claimConnectOperation,
27
28
  finishConnectOperation,
28
29
  getConnectAttempt,
29
30
  type Database,
@@ -216,6 +217,7 @@ export async function startPersonalGitHubOAuth(
216
217
  ) {
217
218
  const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
218
219
  const { grant } = input.access;
220
+ await withOrganizationIntegrationAcquisition(deps.db, grant, ["github-personal"], async () => {});
219
221
  const existing = input.connectionId
220
222
  ? await getConnectionMetadata(deps.db, input.workspaceId, input.connectionId, grant.subjectId)
221
223
  : null;
@@ -301,12 +303,18 @@ export async function completePersonalGitHubOAuthCallback(
301
303
  operationId: `oauth:${state.nonce}`,
302
304
  inputDigest: createHash("sha256").update(input.state!).digest("hex"),
303
305
  };
304
- const claim = await claimConnectOperation(deps.db, state, {
305
- ...operation,
306
- expectedRevision: stored.attempt.revision,
307
- authorize: (tx, _attempt, origin) =>
308
- requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
309
- });
306
+ const claim = await claimOAuthAcquisition(
307
+ deps.db,
308
+ state,
309
+ {
310
+ ...operation,
311
+ expectedRevision: stored.attempt.revision,
312
+ authorize: (tx, _attempt, origin) =>
313
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
314
+ },
315
+ "github-personal",
316
+ Boolean(input.code && !input.error),
317
+ );
310
318
  if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
311
319
  }
312
320
  await requirePersonalGitHubCallbackGrant(deps, state);
@@ -340,6 +348,12 @@ export async function completePersonalGitHubOAuthCallback(
340
348
  }
341
349
  if (input.error) throw new PersonalGitHubCallbackError("provider_denied");
342
350
  if (!input.code) throw new PersonalGitHubCallbackError("missing_code");
351
+ await withOrganizationIntegrationAcquisition(
352
+ deps.db,
353
+ state,
354
+ ["github-personal"],
355
+ async () => {},
356
+ );
343
357
 
344
358
  const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
345
359
  if (
@@ -474,33 +488,43 @@ export async function completePersonalGitHubOAuthCallback(
474
488
  });
475
489
  };
476
490
  if (operation) {
477
- await finishConnectOperation(deps.db, acceptedState, {
478
- ...operation,
479
- authorize: (tx, _attempt, origin) =>
480
- requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
481
- commit: async (tx, current) => {
482
- const connection = await persist(tx);
483
- if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
484
- return {
485
- ...current,
486
- revision: current.revision + 1,
487
- state: "complete",
488
- credentialsCommitted: true,
489
- nextAction: { type: "none" },
490
- account: {
491
- id: connection.id,
492
- version: connection.version,
493
- providerId: "github-personal",
494
- label: identity.login,
495
- ownership: "personal",
496
- status: "connected",
497
- },
498
- };
491
+ await finishOAuthAcquisition(
492
+ deps.db,
493
+ acceptedState,
494
+ {
495
+ ...operation,
496
+ authorize: (tx, _attempt, origin) =>
497
+ requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
498
+ commit: async (tx, current) => {
499
+ const connection = await persist(tx);
500
+ if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
501
+ return {
502
+ ...current,
503
+ revision: current.revision + 1,
504
+ state: "complete",
505
+ credentialsCommitted: true,
506
+ nextAction: { type: "none" },
507
+ account: {
508
+ id: connection.id,
509
+ version: connection.version,
510
+ providerId: "github-personal",
511
+ label: identity.login,
512
+ ownership: "personal",
513
+ status: "connected",
514
+ },
515
+ };
516
+ },
499
517
  },
500
- });
518
+ "github-personal",
519
+ );
501
520
  return { redirectTo: exactReturnUrl!, exactReturn: true };
502
521
  }
503
- const connection = await persist(deps.db);
522
+ const connection = await withOrganizationIntegrationAcquisition(
523
+ deps.db,
524
+ acceptedState,
525
+ ["github-personal"],
526
+ persist,
527
+ );
504
528
  if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
505
529
  return {
506
530
  redirectTo: personalGitHubCallbackReturnUrl(returnBaseUrl, state.returnPath, "success", {
@@ -1,3 +1,5 @@
1
+ import { withOrganizationIntegrationAcquisition } from "@opengeni/db/organization-integration-policy";
2
+ import { claimOAuthAcquisition, finishOAuthAcquisition } from "./oauth-client";
1
3
  import { Buffer } from "node:buffer";
2
4
  import { createHash, randomBytes } from "node:crypto";
3
5
 
@@ -18,7 +20,11 @@ import {
18
20
  type ApiIntegrationOAuthStartRequest,
19
21
  type ConnectionOwnership,
20
22
  } from "@opengeni/contracts";
21
- import { requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
23
+ import {
24
+ integrationKeyForConnectProvider,
25
+ requireEnvironmentEncryption,
26
+ type ApiRouteDeps,
27
+ } from "@opengeni/core";
22
28
  import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
23
29
  import {
24
30
  consumeIntegrationOAuthStateNonce,
@@ -28,7 +34,6 @@ import {
28
34
  loadConnectionCredentialForBroker,
29
35
  persistProviderOAuthConnection,
30
36
  getConnectAttempt,
31
- claimConnectOperation,
32
37
  finishConnectOperation,
33
38
  } from "@opengeni/db";
34
39
  import { createSignedState, readSignedState } from "@opengeni/github";
@@ -52,12 +57,6 @@ import {
52
57
  oauthStateTtlMs,
53
58
  requireIntegrationsStateSecret,
54
59
  } from "./oauth-client";
55
- import {
56
- assertOwnershipAllowed,
57
- builtInOAuthProfileFor,
58
- DEFAULT_OAUTH_PROFILE,
59
- type OAuthProviderProfile,
60
- } from "./oauth-profiles";
61
60
 
62
61
  const PROVIDER_OAUTH_CALLBACK_PATH = "/v1/integrations/provider-oauth/callback";
63
62
  const PROVIDER_OAUTH_TIMEOUT_MS = 15_000;
@@ -149,6 +148,7 @@ export async function startApiIntegrationProviderOAuth(
149
148
  },
150
149
  ): Promise<OAuthStartResponse> {
151
150
  const definition = requiredDefinition(input.payload.definitionId);
151
+ await withOrganizationIntegrationAcquisition(deps.db, input, [definition.id], async () => {});
152
152
  const providerDomain = integrationDefinitionProviderDomain(definition);
153
153
  const existing = input.payload.connectionId
154
154
  ? await getConnectionMetadata(
@@ -182,18 +182,8 @@ export async function startApiIntegrationProviderOAuth(
182
182
  message: "The selected Connection ownership does not match this OAuth request",
183
183
  });
184
184
  }
185
- // This flow used to resolve an omitted ownership to `personal`, inverting the
186
- // documented workspace-owned default. Resolving it to `workspace` instead
187
- // would have been the opposite defect: an executed probe on
188
- // `microsoft-outlook-mail` confirmed it flips a newly connected mailbox from
189
- // subject-scoped to workspace-shared for API/SDK callers, which is a real
190
- // narrow -> broad widening. So an ambiguous omission is refused outright and
191
- // the caller must choose. A profile that allows exactly one ownership is not
192
- // ambiguous (Gmail and hosted Slack MCP are personal-only), and a reconnect
193
- // takes the existing row's ownership.
194
- const profile = providerOAuthProfile(definition, providerDomain);
195
- const ownership =
196
- existingOwnership ?? input.payload.ownership ?? soleAllowedOwnership(profile) ?? null;
185
+ // API callers choose ownership explicitly; reconnects preserve the saved choice.
186
+ const ownership = existingOwnership ?? input.payload.ownership ?? null;
197
187
  if (ownership === null) {
198
188
  throw new HTTPException(422, {
199
189
  message:
@@ -201,13 +191,6 @@ export async function startApiIntegrationProviderOAuth(
201
191
  'or "personal" to connect only for yourself',
202
192
  });
203
193
  }
204
- // Inert by construction today, and deliberately kept: no curated Definition
205
- // resolves to a personal-only profile (all five are Google Workspace and
206
- // Microsoft Graph APIs, so `providerOAuthProfile` returns the default), which
207
- // is why disabling this line reddens nothing. It is the fence that keeps a
208
- // future Definition targeting slack.com or Gmail from minting an ownership
209
- // its profile forbids - read it as pre-wiring, not as an untested gap.
210
- assertOwnershipAllowed(profile, ownership);
211
194
  assertConnectionOwnershipAllowedForPrincipal(ownership, input.personalOwnershipAllowed);
212
195
  const authorizeScopes = uniqueStrings([
213
196
  ...(existing?.grantedScopes ?? []),
@@ -307,12 +290,18 @@ export async function completeApiIntegrationProviderOAuth(
307
290
  operationId: `oauth:${state.nonce}`,
308
291
  inputDigest: createHash("sha256").update(input.state!).digest("hex"),
309
292
  };
310
- const claim = await claimConnectOperation(deps.db, state, {
311
- ...connectOperation,
312
- expectedRevision: stored.attempt.revision,
313
- authorize: (tx, _attempt, origin) =>
314
- requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
315
- });
293
+ const claim = await claimOAuthAcquisition(
294
+ deps.db,
295
+ state,
296
+ {
297
+ ...connectOperation,
298
+ expectedRevision: stored.attempt.revision,
299
+ authorize: (tx, _attempt, origin) =>
300
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
301
+ },
302
+ integrationKeyForConnectProvider(state.definitionId),
303
+ Boolean(input.code && !input.error),
304
+ );
316
305
  if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
317
306
  }
318
307
  requireProviderOAuthOwner(state);
@@ -350,6 +339,7 @@ export async function completeApiIntegrationProviderOAuth(
350
339
 
351
340
  const definition = integrationDefinitionById(state.definitionId);
352
341
  if (!definition) throw new ProviderOAuthCallbackError("state_invalid");
342
+ await withOrganizationIntegrationAcquisition(deps.db, state, [definition.id], async () => {});
353
343
  if (
354
344
  state.definitionFingerprint !== providerDefinitionFingerprint(definition) ||
355
345
  state.providerDomain !== integrationDefinitionProviderDomain(definition) ||
@@ -480,32 +470,42 @@ export async function completeApiIntegrationProviderOAuth(
480
470
  const persist = (database: import("@opengeni/db").Database) =>
481
471
  persistProviderOAuthConnection(database, persistenceInput);
482
472
  if (connectOperation) {
483
- await finishConnectOperation(deps.db, state, {
484
- ...connectOperation,
485
- authorize: (tx, _attempt, origin) =>
486
- requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
487
- commit: async (tx, current) => {
488
- const connection = await persist(tx);
489
- if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
490
- return {
491
- ...current,
492
- revision: current.revision + 1,
493
- state: "connected_but_incomplete",
494
- credentialsCommitted: true,
495
- nextAction: { type: "none" },
496
- account: {
497
- id: connection.id,
498
- providerId: current.providerId,
499
- label: identity.displayName ?? identity.email ?? identity.principalId,
500
- ownership: current.ownership,
501
- status: "connected",
502
- },
503
- };
473
+ await finishOAuthAcquisition(
474
+ deps.db,
475
+ state,
476
+ {
477
+ ...connectOperation,
478
+ authorize: (tx, _attempt, origin) =>
479
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
480
+ commit: async (tx, current) => {
481
+ const connection = await persist(tx);
482
+ if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
483
+ return {
484
+ ...current,
485
+ revision: current.revision + 1,
486
+ state: "connected_but_incomplete",
487
+ credentialsCommitted: true,
488
+ nextAction: { type: "none" },
489
+ account: {
490
+ id: connection.id,
491
+ providerId: current.providerId,
492
+ label: identity.displayName ?? identity.email ?? identity.principalId,
493
+ ownership: current.ownership,
494
+ status: "connected",
495
+ },
496
+ };
497
+ },
504
498
  },
505
- });
499
+ definition.id,
500
+ );
506
501
  return { redirectTo: exactReturnUrl!, exactReturn: true };
507
502
  }
508
- const connection = await persist(deps.db);
503
+ const connection = await withOrganizationIntegrationAcquisition(
504
+ deps.db,
505
+ state,
506
+ [definition.id],
507
+ persist,
508
+ );
509
509
  if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
510
510
  return {
511
511
  redirectTo: providerOAuthReturnUrl(returnBaseUrl, state.returnPath, "success", {
@@ -531,28 +531,6 @@ export async function completeApiIntegrationProviderOAuth(
531
531
  }
532
532
  }
533
533
 
534
- /**
535
- * The ownership fences that apply to a curated Definition, read from the same
536
- * profile table the MCP OAuth start uses. No curated Definition targets a
537
- * personal-only provider today (they are Google Workspace and Microsoft Graph
538
- * APIs), so this resolves to the default profile and its workspace default;
539
- * matching by provider domain keeps the Slack/Gmail fences in force if one ever
540
- * does, instead of letting this flow mint an ownership their profile forbids.
541
- */
542
- /** The profile's ownership when it allows exactly one, else null (ambiguous). */
543
- function soleAllowedOwnership(profile: OAuthProviderProfile): ConnectionOwnership | null {
544
- return profile.allowedOwnership.length === 1 ? profile.allowedOwnership[0]! : null;
545
- }
546
-
547
- function providerOAuthProfile(
548
- definition: IntegrationDefinition,
549
- providerDomain: string,
550
- ): OAuthProviderProfile {
551
- return (
552
- builtInOAuthProfileFor({ mcpUrl: definition.baseUrl, providerDomain }) ?? DEFAULT_OAUTH_PROFILE
553
- );
554
- }
555
-
556
534
  function requiredDefinition(id: string): IntegrationDefinition {
557
535
  const definition = integrationDefinitionById(id);
558
536
  if (!definition) throw new HTTPException(404, { message: "Unknown Integration definition" });
@@ -564,10 +542,7 @@ export function curatedOAuthReadiness(
564
542
  settings: Settings,
565
543
  definition: IntegrationDefinition,
566
544
  ): { configured: boolean; ownership: ConnectionOwnership[] } {
567
- const ownership = [
568
- ...providerOAuthProfile(definition, integrationDefinitionProviderDomain(definition))
569
- .allowedOwnership,
570
- ];
545
+ const ownership: ConnectionOwnership[] = ["workspace", "personal"];
571
546
  try {
572
547
  if (!settings.integrationsEnabled) return { configured: false, ownership };
573
548
  requireEnvironmentEncryption(settings);
@@ -1,3 +1,5 @@
1
+ import { withOrganizationIntegrationAcquisition } from "@opengeni/db/organization-integration-policy";
2
+ import { claimOAuthAcquisition, finishOAuthAcquisition } from "./oauth-client";
1
3
  import { parseSocialOauthClientsJson, type Settings } from "@opengeni/config";
2
4
  import {
3
5
  OAuthStartResponse,
@@ -15,7 +17,6 @@ import {
15
17
  loadSocialConnectionCredential,
16
18
  updateSocialConnectionCredential,
17
19
  upsertSocialOAuthConnection,
18
- claimConnectOperation,
19
20
  finishConnectOperation,
20
21
  getConnectAttempt,
21
22
  type Database,
@@ -196,6 +197,7 @@ export async function startSocialOAuth(
196
197
  ): Promise<OAuthStartResponse> {
197
198
  const { settings } = deps;
198
199
  const provider = SOCIAL_OAUTH_PROVIDERS[context.payload.provider];
200
+ await withOrganizationIntegrationAcquisition(deps.db, context, [provider.id], async () => {});
199
201
  const client = socialOAuthClientFor(settings, provider.id);
200
202
  const redirectUri = socialOAuthRedirectUri(settings, context.requestUrl);
201
203
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
@@ -323,6 +325,7 @@ export async function completeSocialOAuthCallback(
323
325
  }
324
326
  try {
325
327
  const provider = SOCIAL_OAUTH_PROVIDERS[state.provider];
328
+ await withOrganizationIntegrationAcquisition(db, state, [provider.id], async () => {});
326
329
  const client = socialOAuthClientFor(settings, provider.id);
327
330
  const key = requireEnvironmentEncryption(settings);
328
331
  const verifier = state.encryptedPkceVerifier
@@ -344,18 +347,31 @@ export async function completeSocialOAuthCallback(
344
347
  ...(token.scope ? { scope: token.scope } : {}),
345
348
  };
346
349
  const grantedScopes = token.scope ? token.scope.split(/[\s,]+/).filter(Boolean) : state.scopes;
347
- const connection = await upsertSocialOAuthConnection(db, {
348
- accountId: state.accountId,
349
- workspaceId: state.workspaceId,
350
- subjectId: state.ownership === "personal" ? state.subjectId : null,
351
- provider: provider.id,
352
- accountHandle: identity.handle,
353
- accountName: identity.name ?? null,
354
- externalAccountId: identity.externalAccountId,
355
- scopes: grantedScopes,
356
- credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(bundle)),
357
- tokenMetadata: publicTokenMetadata(bundle),
358
- });
350
+ const acceptedState = state;
351
+ const connection = await withOrganizationIntegrationAcquisition(
352
+ db,
353
+ acceptedState,
354
+ [provider.id],
355
+ async (tx) => {
356
+ await requireConnectOwnerAuthority(
357
+ tx,
358
+ acceptedState,
359
+ acceptedState.ownership === "workspace" ? "workspace:admin" : "workspace:read",
360
+ );
361
+ return upsertSocialOAuthConnection(tx, {
362
+ accountId: acceptedState.accountId,
363
+ workspaceId: acceptedState.workspaceId,
364
+ subjectId: acceptedState.ownership === "personal" ? acceptedState.subjectId : null,
365
+ provider: provider.id,
366
+ accountHandle: identity.handle,
367
+ accountName: identity.name ?? null,
368
+ externalAccountId: identity.externalAccountId,
369
+ scopes: grantedScopes,
370
+ credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(bundle)),
371
+ tokenMetadata: publicTokenMetadata(bundle),
372
+ });
373
+ },
374
+ );
359
375
  return {
360
376
  redirectTo: callbackReturnPath(state.returnPath, "success", {
361
377
  connectionId: connection.id,
@@ -407,10 +423,16 @@ async function completeSocialConnect(
407
423
  authorize,
408
424
  };
409
425
  try {
410
- const claim = await claimConnectOperation(deps.db, state, {
411
- ...operation,
412
- expectedRevision: stored.attempt.revision,
413
- });
426
+ const claim = await claimOAuthAcquisition(
427
+ deps.db,
428
+ state,
429
+ {
430
+ ...operation,
431
+ expectedRevision: stored.attempt.revision,
432
+ },
433
+ state.provider,
434
+ Boolean(input.code && !input.error),
435
+ );
414
436
  if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
415
437
  if (!personalOwnerStateAccepted(state))
416
438
  throw new HTTPException(403, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE });
@@ -455,46 +477,51 @@ async function completeSocialConnect(
455
477
  ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}),
456
478
  ...(token.scope ? { scope: token.scope } : {}),
457
479
  };
458
- await finishConnectOperation(deps.db, state, {
459
- ...operation,
460
- commit: async (tx, current) => {
461
- const connection = await upsertSocialOAuthConnection(tx, {
462
- accountId: state.accountId,
463
- workspaceId: state.workspaceId,
464
- ...(current.account
465
- ? {
466
- expectedConnection: {
467
- id: current.account.id.slice("social:".length),
468
- version: current.account.version!,
469
- },
470
- }
471
- : {}),
472
- subjectId: state.ownership === "personal" ? state.subjectId : null,
473
- provider: provider.id,
474
- accountHandle: identity.handle,
475
- accountName: identity.name ?? null,
476
- externalAccountId: identity.externalAccountId,
477
- scopes: token.scope ? token.scope.split(/[\s,]+/).filter(Boolean) : state.scopes,
478
- credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(bundle)),
479
- tokenMetadata: publicTokenMetadata(bundle),
480
- });
481
- return {
482
- ...current,
483
- revision: current.revision + 1,
484
- state: "complete",
485
- credentialsCommitted: true,
486
- nextAction: { type: "none" },
487
- account: {
488
- id: `social:${connection.id}`,
489
- providerId: provider.id,
490
- version: connection.version!,
491
- label: identity.handle,
492
- ownership: state.ownership,
493
- status: "connected",
494
- },
495
- };
480
+ await finishOAuthAcquisition(
481
+ deps.db,
482
+ state,
483
+ {
484
+ ...operation,
485
+ commit: async (tx, current) => {
486
+ const connection = await upsertSocialOAuthConnection(tx, {
487
+ accountId: state.accountId,
488
+ workspaceId: state.workspaceId,
489
+ ...(current.account
490
+ ? {
491
+ expectedConnection: {
492
+ id: current.account.id.slice("social:".length),
493
+ version: current.account.version!,
494
+ },
495
+ }
496
+ : {}),
497
+ subjectId: state.ownership === "personal" ? state.subjectId : null,
498
+ provider: provider.id,
499
+ accountHandle: identity.handle,
500
+ accountName: identity.name ?? null,
501
+ externalAccountId: identity.externalAccountId,
502
+ scopes: token.scope ? token.scope.split(/[\s,]+/).filter(Boolean) : state.scopes,
503
+ credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(bundle)),
504
+ tokenMetadata: publicTokenMetadata(bundle),
505
+ });
506
+ return {
507
+ ...current,
508
+ revision: current.revision + 1,
509
+ state: "complete",
510
+ credentialsCommitted: true,
511
+ nextAction: { type: "none" },
512
+ account: {
513
+ id: `social:${connection.id}`,
514
+ providerId: provider.id,
515
+ version: connection.version!,
516
+ label: identity.handle,
517
+ ownership: state.ownership,
518
+ status: "connected",
519
+ },
520
+ };
521
+ },
496
522
  },
497
- });
523
+ state.provider,
524
+ );
498
525
  }
499
526
  } catch (error) {
500
527
  // An uncertain exchange stays occupied: do not replay a consumed OAuth code.
@@ -0,0 +1,17 @@
1
+ import type { McpConnectionAccountBinding } from "@opengeni/contracts";
2
+
3
+ /** Canonical readiness comes only from routes present in this exact attempt,
4
+ * joined to this turn's accepted bindings. It never grants account access. */
5
+ export function capabilityAccountReadiness(
6
+ availableRoutes: Iterable<string>,
7
+ bindings: readonly McpConnectionAccountBinding[] | null,
8
+ ) {
9
+ const available = new Set(availableRoutes);
10
+ const actualRoutes = new Set(available);
11
+ const accepted = new Set<string>();
12
+ for (const binding of bindings ?? []) {
13
+ accepted.add(binding.canonicalServerId);
14
+ if (actualRoutes.has(binding.serverId)) available.add(binding.canonicalServerId);
15
+ }
16
+ return { available, accepted };
17
+ }
@@ -38,6 +38,7 @@ const ArtifactCommand = z
38
38
  const ArtifactMetadata = z
39
39
  .object({
40
40
  id: ArtifactId,
41
+ artifactReference: z.string(),
41
42
  modality: Modality,
42
43
  title: z.string(),
43
44
  lifecycle: z.enum(["active", "archived"]),
@@ -203,13 +204,17 @@ export function registerEditableArtifactAgentTools(
203
204
  {
204
205
  title: "Inspect editable artifact",
205
206
  description:
206
- "Query the artifact's current canonical head through the same native kernel used by the browser. Use bounded modality queries; results are current, not a sandbox-file snapshot.",
207
+ "Query the artifact's current canonical head through the same native kernel used by the browser. Use bounded modality queries; results are current, not a sandbox-file snapshot. Native document queries return a server-issued inspectionReceiptId; use a post-edit body inspection receipt for declared report completion. If the document changes during inspection, retry against its current head.",
207
208
  inputSchema: {
208
209
  artifactId: ArtifactId,
209
210
  modality: Modality,
210
211
  request: EditableArtifactQueryRequestSchema,
211
212
  },
212
- outputSchema: { artifact: ArtifactMetadata, projection: z.unknown() },
213
+ outputSchema: {
214
+ artifact: ArtifactMetadata,
215
+ projection: z.unknown(),
216
+ inspectionReceiptId: z.string().uuid().optional(),
217
+ },
213
218
  annotations: readOnlyAnnotations("Inspect editable artifact"),
214
219
  },
215
220
  async ({ artifactId, modality, request }, extra) =>
@@ -21,6 +21,7 @@ export const FIRST_PARTY_TOOL_AUTHORIZATION = {
21
21
  goal_pause: { sessionRequired: true, allOf: ["goals:manage"] },
22
22
  goal_resume: { sessionRequired: true, allOf: ["goals:manage"] },
23
23
  knowledge_search: { sessionRequired: true, allOf: ["documents:search"] },
24
+ knowledge_prepare_save: { sessionRequired: true, allOf: ["documents:search"] },
24
25
  knowledge_get: { sessionRequired: true, allOf: ["documents:search"] },
25
26
  knowledge_browse: { sessionRequired: true, allOf: ["documents:search"] },
26
27
  knowledge_retain_file: { sessionRequired: true, allOf: ["documents:search", "files:read"] },