@opengeni/api-router 3.0.0-canary.0 → 3.0.1-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 (39) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-7IAWEYBH.js → chunk-XZGTKYOA.js} +3626 -2525
  3. package/dist/{chunk-7IAWEYBH.js.map → chunk-XZGTKYOA.js.map} +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/integrations/fiken.d.ts +3 -1
  6. package/dist/integrations/github-lens-connect.d.ts +2 -2
  7. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  8. package/dist/integrations/oauth-client.d.ts +118 -1
  9. package/dist/integrations/slack-interactions.d.ts +1 -1
  10. package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
  11. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  12. package/dist/routes/api-integrations.d.ts +1 -0
  13. package/dist/routes/organization-integration-policy.d.ts +3 -0
  14. package/package.json +19 -19
  15. package/src/app.ts +7 -2
  16. package/src/integrations/atlassian.ts +135 -65
  17. package/src/integrations/fiken.ts +57 -35
  18. package/src/integrations/github-app-connect.ts +34 -15
  19. package/src/integrations/google-drive.ts +295 -124
  20. package/src/integrations/integration-commit-authority.ts +69 -0
  21. package/src/integrations/oauth-client.ts +220 -65
  22. package/src/integrations/personal-github.ts +55 -31
  23. package/src/integrations/provider-oauth.ts +54 -31
  24. package/src/integrations/social-oauth.ts +83 -56
  25. package/src/mcp/first-party-tool-permissions.ts +1 -0
  26. package/src/mcp/knowledge-entries.ts +23 -6
  27. package/src/routes/api-integrations.ts +116 -4
  28. package/src/routes/billing.ts +52 -0
  29. package/src/routes/capabilities.ts +51 -1
  30. package/src/routes/connect.ts +161 -57
  31. package/src/routes/connections.ts +430 -305
  32. package/src/routes/github.ts +48 -19
  33. package/src/routes/integration-facets.ts +66 -25
  34. package/src/routes/knowledge.ts +48 -1
  35. package/src/routes/organization-integration-policy.ts +113 -0
  36. package/src/routes/plugins.ts +12 -2
  37. package/src/routes/pr-review-github.ts +60 -30
  38. package/src/routes/pr-review.ts +69 -45
  39. package/src/routes/social.ts +24 -13
@@ -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";
@@ -149,6 +154,7 @@ export async function startApiIntegrationProviderOAuth(
149
154
  },
150
155
  ): Promise<OAuthStartResponse> {
151
156
  const definition = requiredDefinition(input.payload.definitionId);
157
+ await withOrganizationIntegrationAcquisition(deps.db, input, [definition.id], async () => {});
152
158
  const providerDomain = integrationDefinitionProviderDomain(definition);
153
159
  const existing = input.payload.connectionId
154
160
  ? await getConnectionMetadata(
@@ -307,12 +313,18 @@ export async function completeApiIntegrationProviderOAuth(
307
313
  operationId: `oauth:${state.nonce}`,
308
314
  inputDigest: createHash("sha256").update(input.state!).digest("hex"),
309
315
  };
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
- });
316
+ const claim = await claimOAuthAcquisition(
317
+ deps.db,
318
+ state,
319
+ {
320
+ ...connectOperation,
321
+ expectedRevision: stored.attempt.revision,
322
+ authorize: (tx, _attempt, origin) =>
323
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
324
+ },
325
+ integrationKeyForConnectProvider(state.definitionId),
326
+ Boolean(input.code && !input.error),
327
+ );
316
328
  if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
317
329
  }
318
330
  requireProviderOAuthOwner(state);
@@ -350,6 +362,7 @@ export async function completeApiIntegrationProviderOAuth(
350
362
 
351
363
  const definition = integrationDefinitionById(state.definitionId);
352
364
  if (!definition) throw new ProviderOAuthCallbackError("state_invalid");
365
+ await withOrganizationIntegrationAcquisition(deps.db, state, [definition.id], async () => {});
353
366
  if (
354
367
  state.definitionFingerprint !== providerDefinitionFingerprint(definition) ||
355
368
  state.providerDomain !== integrationDefinitionProviderDomain(definition) ||
@@ -480,32 +493,42 @@ export async function completeApiIntegrationProviderOAuth(
480
493
  const persist = (database: import("@opengeni/db").Database) =>
481
494
  persistProviderOAuthConnection(database, persistenceInput);
482
495
  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
- };
496
+ await finishOAuthAcquisition(
497
+ deps.db,
498
+ state,
499
+ {
500
+ ...connectOperation,
501
+ authorize: (tx, _attempt, origin) =>
502
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
503
+ commit: async (tx, current) => {
504
+ const connection = await persist(tx);
505
+ if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
506
+ return {
507
+ ...current,
508
+ revision: current.revision + 1,
509
+ state: "connected_but_incomplete",
510
+ credentialsCommitted: true,
511
+ nextAction: { type: "none" },
512
+ account: {
513
+ id: connection.id,
514
+ providerId: current.providerId,
515
+ label: identity.displayName ?? identity.email ?? identity.principalId,
516
+ ownership: current.ownership,
517
+ status: "connected",
518
+ },
519
+ };
520
+ },
504
521
  },
505
- });
522
+ definition.id,
523
+ );
506
524
  return { redirectTo: exactReturnUrl!, exactReturn: true };
507
525
  }
508
- const connection = await persist(deps.db);
526
+ const connection = await withOrganizationIntegrationAcquisition(
527
+ deps.db,
528
+ state,
529
+ [definition.id],
530
+ persist,
531
+ );
509
532
  if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
510
533
  return {
511
534
  redirectTo: providerOAuthReturnUrl(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 { 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.
@@ -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"] },
@@ -2,12 +2,14 @@ import {
2
2
  AgentInstructionSaveRequest,
3
3
  WorkspaceInstructionPolicyTarget,
4
4
  KnowledgeEntryListRequest,
5
+ KnowledgeSavePreparationRequest,
5
6
  KnowledgeEntrySaveRequest,
6
7
  KnowledgeTaskNotePromotionRequest,
7
8
  type AccessGrant,
8
9
  } from "@opengeni/contracts";
9
10
  import {
10
11
  prepareKnowledgeFile,
12
+ prepareKnowledgeSave,
11
13
  retainKnowledgeMessage,
12
14
  searchKnowledgeEntries,
13
15
  requireLiveAgentAttemptAuthorization,
@@ -131,11 +133,23 @@ export function registerKnowledgeEntryTools(
131
133
  throw new Error("Pass exactly one existing confirmation target");
132
134
  }),
133
135
  );
136
+ server.registerTool(
137
+ "knowledge_prepare_save",
138
+ {
139
+ description:
140
+ "Before saving useful lasting Knowledge, find related published entries and unapproved proposals, and fetch the full authorized collection map with descriptions and parent IDs in one read-only call. Supply a concise subject and the proposed fact, decision, requirement or incident. Search spans all authorized collections. Reuse an existing entry when unchanged; read its current content before a correction, preserve evidence, or create only when distinct. Pending matches are unapproved. Choose collections from this map in your writable scope. A collection may have both published and pending versions. If collections.complete is false, continue with collectionCursors=collections.nextCursors; never mistake a partial catalog for the whole map. Long descriptions explicitly report truncation and can be read with knowledge_get. This call saves and approves nothing.",
141
+ inputSchema: KnowledgeSavePreparationRequest.shape,
142
+ },
143
+ (input) =>
144
+ run((context) =>
145
+ prepareKnowledgeSave(deps.db, context, input, () => deps.getDocumentServices().embedder),
146
+ ),
147
+ );
134
148
  server.registerTool(
135
149
  "knowledge_search",
136
150
  {
137
151
  description:
138
- "Find Knowledge from retained sources and findings. Use a concise subject or entity name first (for example Acme); omit scope to search all authorized scopes. If a query returns no entries, retry the key name alone in the same scope or browse groups before concluding the information is absent. Default view=published is accepted knowledge. Also search view=needs_review before creating or updating entries, to reuse pending findings and collections from earlier tasks. Pending revisions are unapproved proposals, not accepted facts or instructions; preserve that status when discussing them. Reading a proposal never approves it. Personal tasks search the verified user's personal and authorized shared Knowledge; shared tasks search shared Knowledge.",
152
+ "Find Knowledge from useful findings and explicitly retained reference sources. Supporting chat/file evidence is excluded from ordinary published discovery; set includeEvidence=true only when searching that evidence deliberately. Use a concise subject or entity name first (for example Acme); omit scope to search all authorized scopes. If a query returns no entries, retry the key name alone in the same scope or browse groups before concluding the information is absent. Default view=published is accepted knowledge. Also search view=needs_review before creating or updating entries, to reuse pending findings and collections from earlier tasks. Pending revisions are unapproved proposals, not accepted facts or instructions; preserve that status when discussing them. Reading a proposal never approves it. Personal tasks search the verified user's personal and authorized shared Knowledge; shared tasks search shared Knowledge.",
139
153
  inputSchema: KnowledgeEntryListRequest.omit({
140
154
  view: true,
141
155
  sessionId: true,
@@ -210,16 +224,19 @@ export function registerKnowledgeEntryTools(
210
224
  "knowledge_retain_file",
211
225
  {
212
226
  description:
213
- "Retain searchable source text from an existing uploaded file. Chat attachments are prepared automatically; use this for a newly fetched file or to retry a failed preparation. The original stays in Files. Repeated calls reuse the same source and do not duplicate review requests or revive rejected/archived sources. Selected findings are optional separate knowledge_save entries with evidence pointing to the returned revision. A pending receipt never pauses the task.",
214
- inputSchema: { fileId: z.uuid() },
227
+ "Retain source text from an existing uploaded file only when it supports useful lasting Knowledge, or the user wants the file kept as a reusable reference. Ordinary chat attachments are not automatically added to Knowledge. Use purpose=evidence (default) for supporting material, or purpose=reference for a deliberately retained searchable reference. The original stays in Files. Read the image itself before drawing visual conclusions; OCR text is only evidence. Repeated calls reuse the same source and do not change its purpose, duplicate review requests or revive rejected/archived sources. To make previously retained evidence a searchable reference, read its current revision with knowledge_get and use knowledge_save to set source.purpose=reference while preserving content and links. Selected findings are optional separate knowledge_save entries with evidence pointing to the returned revision. A pending receipt never pauses the task.",
228
+ inputSchema: {
229
+ fileId: z.uuid(),
230
+ purpose: z.enum(["evidence", "reference"]).default("evidence"),
231
+ },
215
232
  },
216
- ({ fileId }) => run((context) => prepareKnowledgeFile(deps, context, fileId)),
233
+ ({ fileId, purpose }) => run((context) => prepareKnowledgeFile(deps, context, fileId, purpose)),
217
234
  );
218
235
  server.registerTool(
219
236
  "knowledge_retain_message",
220
237
  {
221
238
  description:
222
- "Retain the exact text of a user message from this conversation as a source entry. Use when a user supplies or confirms a durable fact, before saving a finding based on it. Omit messageId for the user message that triggered this turn, or pass an earlier user message event ID from this same conversation. The server copies the actual message, preserves its identity and follows this task's learning policy. Repeated calls reuse the source. Cite the returned entryId/revisionId as evidence in knowledge_save, with location.messageIds=[messageId]. Retaining a message does not prove every statement in it or approve a pending finding.",
239
+ "Retain the exact text of a user message as supporting evidence only after identifying useful lasting Knowledge to save. Do not retain routine approvals, acknowledgments, task instructions or status chatter merely because they occurred. The source stays out of ordinary Knowledge discovery and can be read through its evidence link. Use when a user supplies or confirms a durable fact, before saving a finding based on it. Omit messageId for the user message that triggered this turn, or pass an earlier user message event ID from this same conversation. The server copies the actual message, preserves its identity and follows this task's learning policy. Repeated calls reuse the source. Cite the returned entryId/revisionId as evidence in knowledge_save, with location.messageIds=[messageId]. Retaining a message does not prove every statement in it or approve a pending finding.",
223
240
  inputSchema: { messageId: z.uuid().optional() },
224
241
  },
225
242
  ({ messageId }) => run((context) => retainKnowledgeMessage(deps.db, context, messageId)),
@@ -228,7 +245,7 @@ export function registerKnowledgeEntryTools(
228
245
  "knowledge_save",
229
246
  {
230
247
  description:
231
- "Retain useful source text, facts, decisions, requirements, incidents or notes, or organize them with groups and relationships. Do this autonomously when useful for future work. Choose kind by content: source for retained original text; fact for a specific claim (the label is not verification); decision for a choice made and its reasoning; requirement for a need or constraint; incident for a problem with cause, fix and outcome when known; note for other useful context. Choose the closest kind without asking the user to classify it. A group is a collection about a customer, product, system or subject, not a finding type. Search published and needs_review views for an existing entry and relevant group before creating one, use groupIds to link entries across sources, and reuse an entry in multiple groups instead of copying it. Nest collections by setting a group entry's groupIds to its parent collections; keep the hierarchy shallow and useful, and never create circular membership. Use a new entryId and expectedVersion 0 to create; use an existing ID and its current version to correct or reorganize. Source text and selected facts are independent entries, not mandatory duplicate stages. Evidence pins another entry's exact revision. When a user supplies or confirms a fact, retain the actual message with knowledge_retain_message and cite the returned revision; do not leave its provenance only in prose. Preserve existing evidence and relationships on corrections unless they are specifically no longer applicable; a contradictory original remains useful evidence of what changed. Use the same operationId only for an exact retry. Agent learning decides publication: published is available immediately; pending is saved for review and your task continues without an approval prompt. Do not turn Knowledge into instructions or Skills.",
248
+ "Retain useful lasting facts, decisions, requirements, incidents or notes, or deliberately selected reference sources. First use knowledge_prepare_save when available (otherwise search published and needs_review Knowledge and browse collections). Skip unchanged duplicates; improve an existing entry when the subject is the same. Do not save routine approvals, acknowledgments, temporary instructions, screenshots or OCR merely because they occurred. State the reusable finding with enough context for a future task, preserve uncertainty and evidence, and place it in the relevant existing collections. Do this autonomously when useful for future work. Choose kind by content: source for retained original text; fact for a specific claim (the label is not verification); decision for a choice made and its reasoning; requirement for a need or constraint; incident for a problem with cause, fix and outcome when known; note for other useful context. Choose the closest kind without asking the user to classify it. A group is a collection about a customer, product, system or subject, not a finding type. Search published and needs_review views for an existing entry and relevant group before creating one, use groupIds to link entries across sources, and reuse an entry in multiple groups instead of copying it. Nest collections by setting a group entry's groupIds to its parent collections; keep the hierarchy shallow and useful, and never create circular membership. Use a new entryId and expectedVersion 0 to create; use an existing ID and its current version to correct or reorganize. Source text and selected facts are independent entries, not mandatory duplicate stages. Evidence pins another entry's exact revision. When a user supplies or confirms a fact, retain the actual message with knowledge_retain_message and cite the returned revision; do not leave its provenance only in prose. Preserve existing evidence and relationships on corrections unless they are specifically no longer applicable; a contradictory original remains useful evidence of what changed. Use the same operationId only for an exact retry. Agent learning decides publication: published is available immediately; pending is saved for review and your task continues without an approval prompt. Do not turn Knowledge into instructions or Skills.",
232
249
  inputSchema: KnowledgeEntrySaveRequest.omit({ scope: true }).shape,
233
250
  },
234
251
  (input) => run((context) => saveKnowledgeEntry(deps.db, context, input)),
@@ -7,6 +7,7 @@ import {
7
7
  } from "@opengeni/capabilities";
8
8
  import {
9
9
  ApiIntegrationPreview,
10
+ OrganizationIntegrationDeniedError,
10
11
  ListIntegrationDefinitionsResponse,
11
12
  ApiIntegrationUninstallPreview,
12
13
  InstallApiIntegrationRequest,
@@ -17,12 +18,18 @@ import {
17
18
  UninstallApiIntegrationResult,
18
19
  type AccessGrant,
19
20
  } from "@opengeni/contracts";
20
- import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
21
+ import {
22
+ requireAccessGrant,
23
+ integrationSourceForOrganizationPolicy,
24
+ type ApiRouteDeps,
25
+ } from "@opengeni/core";
26
+ import { withOrganizationIntegrationPolicyFence } from "@opengeni/db/organization-integration-policy";
21
27
  import {
22
28
  ApiIntegrationInstallationVersionConflictError,
23
29
  buildConnectionTokenResolver,
24
30
  getApiIntegrationUninstallPreview,
25
31
  getConnectionMetadata,
32
+ getApiIntegrationReconciliationSnapshot,
26
33
  installApiIntegration,
27
34
  IntegrationFacetBindingOwnershipConflictError,
28
35
  IntegrationFacetBindingVersionConflictError,
@@ -209,6 +216,7 @@ export function registerApiIntegrationRoutes(
209
216
  const resolved = await resolveForRoute({
210
217
  deps,
211
218
  transport,
219
+ accountId: grant.accountId,
212
220
  workspaceId,
213
221
  subjectId: grant.subjectId,
214
222
  payload,
@@ -223,6 +231,7 @@ export function registerApiIntegrationRoutes(
223
231
  const resolved = await resolveForRoute({
224
232
  deps,
225
233
  transport,
234
+ accountId: grant.accountId,
226
235
  workspaceId,
227
236
  subjectId: grant.subjectId,
228
237
  payload,
@@ -308,21 +317,45 @@ export function registerApiIntegrationRoutes(
308
317
  export async function resolveForRoute(input: {
309
318
  deps: ApiRouteDeps;
310
319
  transport: ReturnType<typeof createPinnedIntegrationTransport>;
320
+ accountId: string;
311
321
  workspaceId: string;
312
322
  subjectId: string;
313
323
  payload: PreviewApiIntegrationRequest | InstallApiIntegrationRequest;
314
324
  }): ReturnType<typeof resolveApiIntegrationPreview> {
325
+ const payload = structuredClone(input.payload);
326
+ const preparation = await withOrganizationIntegrationPolicyFence(
327
+ input.deps.db,
328
+ input,
329
+ async (tx, policy) => {
330
+ try {
331
+ return { source: integrationSourceForOrganizationPolicy(policy, payload.source) };
332
+ } catch (error) {
333
+ if (!(error instanceof OrganizationIntegrationDeniedError)) throw error;
334
+ const install = InstallApiIntegrationRequest.safeParse(payload);
335
+ if (install.success) {
336
+ const resolved = await storedReconciliationPreview(
337
+ { ...input, deps: { ...input.deps, db: tx } },
338
+ install.data,
339
+ );
340
+ if (resolved) return { resolved };
341
+ }
342
+ throw error;
343
+ }
344
+ },
345
+ );
346
+ if (preparation.resolved) return preparation.resolved;
347
+ const source = preparation.source!;
315
348
  try {
316
- const connection = input.payload.connectionId
349
+ const connection = payload.connectionId
317
350
  ? await requireVisibleConnection(
318
351
  input.deps,
319
352
  input.workspaceId,
320
353
  input.subjectId,
321
- input.payload.connectionId,
354
+ payload.connectionId,
322
355
  )
323
356
  : null;
324
357
  return await resolveApiIntegrationPreview({
325
- source: input.payload.source,
358
+ source,
326
359
  connection: connectionDescriptor(connection),
327
360
  transport: input.transport,
328
361
  authority: {
@@ -353,6 +386,85 @@ export async function resolveForRoute(input: {
353
386
  }
354
387
  }
355
388
 
389
+ async function storedReconciliationPreview(
390
+ input: { deps: ApiRouteDeps; accountId: string; workspaceId: string; subjectId: string },
391
+ payload: InstallApiIntegrationRequest,
392
+ ): Promise<ResolvedApiIntegrationPreview | null> {
393
+ const snapshot = await getApiIntegrationReconciliationSnapshot(input.deps.db, {
394
+ accountId: input.accountId,
395
+ workspaceId: input.workspaceId,
396
+ subjectId: input.subjectId,
397
+ source: payload.source,
398
+ expectedRevisionId: payload.expectedRevisionId,
399
+ expectedContentSha256: payload.expectedContentSha256,
400
+ ...(payload.connectionId !== undefined ? { connectionId: payload.connectionId } : {}),
401
+ ...(payload.instanceKey !== undefined ? { instanceKey: payload.instanceKey } : {}),
402
+ ...(payload.allowedTools !== undefined ? { allowedTools: payload.allowedTools } : {}),
403
+ });
404
+ if (!snapshot) return null;
405
+ const { runtime, baseServerId, name, provider, requiredScopes } = snapshot;
406
+ const connection = payload.connectionId
407
+ ? await requireVisibleConnection(
408
+ input.deps,
409
+ input.workspaceId,
410
+ input.subjectId,
411
+ payload.connectionId,
412
+ )
413
+ : null;
414
+ const scheme = runtime.authScheme;
415
+ const auth = !apiIntegrationRequiresConnection(scheme)
416
+ ? { kind: "none" }
417
+ : scheme.kind === "oauth2"
418
+ ? { kind: "oauth2", providerDomain: runtime.providerDomain, scopes: requiredScopes }
419
+ : scheme.kind === "api_key"
420
+ ? {
421
+ kind: "api_key",
422
+ providerDomain: runtime.providerDomain,
423
+ carrier: scheme.carrier,
424
+ name: scheme.name,
425
+ }
426
+ : scheme.kind === "http"
427
+ ? { kind: "http", providerDomain: runtime.providerDomain, scheme: scheme.scheme }
428
+ : null;
429
+ if (!auth) return null;
430
+ return {
431
+ preview: ApiIntegrationPreview.parse({
432
+ source: payload.source,
433
+ definitionId: runtime.definitionId,
434
+ definitionProvenance: runtime.definitionProvenance,
435
+ protocol: runtime.protocol,
436
+ capabilityId: runtime.capabilityId,
437
+ pluginKey: runtime.pluginKey,
438
+ serverId: baseServerId,
439
+ name,
440
+ description: runtime.description,
441
+ provider,
442
+ providerDomain: runtime.providerDomain,
443
+ baseUrl: runtime.baseUrl,
444
+ sourceUrl: runtime.sourceUrl,
445
+ revisionId: runtime.revision.id,
446
+ contentSha256: runtime.revision.contentSha256,
447
+ auth,
448
+ connectionId: connection?.id ?? null,
449
+ connectionOwnership: connection ? (connection.subjectId ? "personal" : "workspace") : null,
450
+ tools: runtime.revision.tools.map((tool) => ({
451
+ id: tool.id,
452
+ operationKey: tool.operationKey,
453
+ name: tool.name,
454
+ description: tool.description,
455
+ safety: tool.safety,
456
+ approvalMode: tool.approvalMode,
457
+ deprecated: tool.deprecated,
458
+ })),
459
+ warnings: [],
460
+ }),
461
+ revision: runtime.revision,
462
+ provider,
463
+ requiredScopes,
464
+ authScheme: runtime.authScheme,
465
+ };
466
+ }
467
+
356
468
  async function requireVisibleConnection(
357
469
  deps: ApiRouteDeps,
358
470
  workspaceId: string,