@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
@@ -88,6 +88,7 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
88
88
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
89
89
  requirePermission(grant, "secrets:write");
90
90
  const request = CreateAutomationSourceRequest.parse(await c.req.json());
91
+ assertGenericTriggerMutable(request);
91
92
  requireAutomationAdapter(request.adapterId).validateSourceConfiguration(request.configuration);
92
93
  const key = requireEncryptionKey(deps);
93
94
  const source = await createAutomationSource(deps.db, {
@@ -176,11 +177,6 @@ export function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void {
176
177
  grant,
177
178
  request.sessionTemplate.bundledSkillIds,
178
179
  );
179
- if (request.packInstallationId || request.packTemplateId) {
180
- throw new HTTPException(409, {
181
- message: "Pack-owned automation triggers must be created through their Pack setup API",
182
- });
183
- }
184
180
  const source = await requireSource(deps, grant.accountId, workspaceId, request.sourceId);
185
181
  assertGenericSourceMutable(source);
186
182
  if (source.status !== "active") {
@@ -667,17 +663,13 @@ async function requireSource(
667
663
  }
668
664
 
669
665
  function assertGenericSourceMutable(source: AutomationSourceSecret): void {
670
- if (source.packInstallationId) {
671
- throw new HTTPException(409, {
672
- message: "Pack-owned automation sources must be managed through their Pack setup API",
673
- });
674
- }
666
+ assertGenericTriggerMutable(source);
675
667
  }
676
668
 
677
- function assertGenericTriggerMutable(trigger: { packInstallationId: string | null }): void {
678
- if (trigger.packInstallationId) {
669
+ function assertGenericTriggerMutable(trigger: { adapterId: string }): void {
670
+ if (trigger.adapterId === "source-control.pull-request.v1") {
679
671
  throw new HTTPException(409, {
680
- message: "Pack-owned automation triggers must be managed through their Pack setup API",
672
+ message: "PR Review automations must be managed through the PR Review setup API",
681
673
  });
682
674
  }
683
675
  }
@@ -3,6 +3,8 @@ import {
3
3
  CreateBillingPortalResponse,
4
4
  CreateCheckoutRequest,
5
5
  CreateCheckoutResponse,
6
+ OrganizationUsageQuery,
7
+ OrganizationUsageWorkspacePageQuery,
6
8
  type AccessContext,
7
9
  type Permission,
8
10
  } from "@opengeni/contracts";
@@ -14,6 +16,9 @@ import {
14
16
  hasCreditLedgerEntry,
15
17
  isStripeWebhookProcessed,
16
18
  listUsageEvents,
19
+ getOrganizationUsageSummary,
20
+ getOrganizationUsageWorkspacePage,
21
+ withSessionRlsActorContext,
17
22
  getManagedAccount,
18
23
  markStripeWebhookProcessed,
19
24
  recordStripeWebhookEvent,
@@ -24,6 +29,7 @@ import { HTTPException } from "hono/http-exception";
24
29
  import Stripe from "stripe";
25
30
  import { requireAccessContext } from "@opengeni/core";
26
31
  import type { ApiRouteDeps } from "@opengeni/core";
32
+ import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
27
33
 
28
34
  export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
29
35
  app.get("/v1/billing", async (c) => {
@@ -57,6 +63,33 @@ export function registerBillingRoutes(app: Hono, deps: ApiRouteDeps): void {
57
63
  });
58
64
  });
59
65
 
66
+ app.get("/v1/billing/usage-summary", async (c) => {
67
+ const context = await requireAccessContext(c, deps);
68
+ const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
69
+ const parsed = OrganizationUsageQuery.safeParse(c.req.query());
70
+ if (!parsed.success) {
71
+ throw new HTTPException(400, {
72
+ message: parsed.error.issues[0]?.message ?? "invalid usage query",
73
+ });
74
+ }
75
+ return await withBillingUsageActor(deps, context, accountId, async () =>
76
+ c.json(await getOrganizationUsageSummary(deps.db, { accountId, ...parsed.data })),
77
+ );
78
+ });
79
+
80
+ app.get("/v1/billing/usage-workspaces", async (c) => {
81
+ const context = await requireAccessContext(c, deps);
82
+ const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
83
+ const parsed = OrganizationUsageWorkspacePageQuery.safeParse(c.req.query());
84
+ if (!parsed.success)
85
+ throw new HTTPException(400, {
86
+ message: parsed.error.issues[0]?.message ?? "invalid usage page query",
87
+ });
88
+ return await withBillingUsageActor(deps, context, accountId, async () =>
89
+ c.json(await getOrganizationUsageWorkspacePage(deps.db, { accountId, ...parsed.data })),
90
+ );
91
+ });
92
+
60
93
  app.get("/v1/billing/entitlements", async (c) => {
61
94
  const context = await requireAccessContext(c, deps);
62
95
  const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
@@ -638,6 +671,25 @@ export function stripeCustomerProvider(
638
671
  : "stripe:test";
639
672
  }
640
673
 
674
+ /** Billing routes do not traverse the workspace actor middleware. Always bind
675
+ * these reads explicitly; only a revalidated live attempt may supply a human
676
+ * initiator. Neither query parameters nor serviceInitiator claims are proof. */
677
+ async function withBillingUsageActor<T>(
678
+ deps: ApiRouteDeps,
679
+ context: AccessContext,
680
+ accountId: string,
681
+ read: () => Promise<T>,
682
+ ): Promise<T> {
683
+ const attemptGrant = context.workspaceGrants.find(
684
+ (grant) =>
685
+ grant.accountId === accountId &&
686
+ grant.subjectId === context.subjectId &&
687
+ grant.principalKind === "agent_attempt",
688
+ );
689
+ if (attemptGrant) return await withAccessGrantSessionRlsContext(deps, attemptGrant, read);
690
+ return await withSessionRlsActorContext({ subjectId: context.subjectId }, read);
691
+ }
692
+
641
693
  function requireSelectedAccount(
642
694
  context: AccessContext,
643
695
  requested: string | undefined,
@@ -23,6 +23,9 @@ import {
23
23
  import type { Context, Hono } from "hono";
24
24
  import { HTTPException } from "hono/http-exception";
25
25
  import { z } from "zod";
26
+ import { admitManagedSignInMutation } from "./managed-sign-in-methods";
27
+ import { managedAuthSecretRequestDigest } from "@opengeni/core/managed-auth-session-sets";
28
+ import { deliverManagedSignInNotification } from "../auth/managed-sign-in-notifications";
26
29
 
27
30
  const BindingId = z.string().uuid();
28
31
 
@@ -86,6 +89,7 @@ async function mutate(
86
89
  operationId: string;
87
90
  expectedIdentityRevision: number;
88
91
  reason: string;
92
+ verifiedManagedRecovery?: boolean;
89
93
  },
90
94
  ): Promise<Response> {
91
95
  const identity = await requestIdentity(context, deps);
@@ -104,10 +108,27 @@ async function mutate(
104
108
  ...(input.providerId ? { providerId: input.providerId } : {}),
105
109
  ...(input.providerAccountId ? { providerAccountId: input.providerAccountId } : {}),
106
110
  reason: input.reason,
111
+ ...(input.verifiedManagedRecovery
112
+ ? {
113
+ verifiedRecovery: {
114
+ authSessionId: identity.authSessionId,
115
+ requestDigest: managedAuthSecretRequestDigest(
116
+ deps.settings.betterAuthSecret!,
117
+ input,
118
+ ),
119
+ },
120
+ }
121
+ : {}),
107
122
  ...(actorFence ? { actorFence } : {}),
108
123
  }),
109
124
  );
110
125
  if (actorFence) markManagedAuthRequestActorTransitionApplied(context.req.raw);
126
+ if (input.verifiedManagedRecovery)
127
+ await deliverManagedSignInNotification(
128
+ deps.db,
129
+ deps.managedEmailTransport,
130
+ input.operationId,
131
+ );
111
132
  return context.json(response);
112
133
  } catch (error) {
113
134
  return identityError(context, error);
@@ -132,6 +153,11 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
132
153
 
133
154
  app.post(`${base}/login-bindings`, async (context) => {
134
155
  const request = await parseBody(context, LinkCanonicalHumanLoginBindingRequest);
156
+ if (["google", "github", "credential"].includes(request.providerId.trim().toLowerCase())) {
157
+ throw new HTTPException(403, {
158
+ message: "Use personal sign-in method settings with fresh provider proof",
159
+ });
160
+ }
135
161
  return await mutate(context, deps, {
136
162
  operationType: "link",
137
163
  ...request,
@@ -141,6 +167,19 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
141
167
 
142
168
  app.delete(`${base}/login-bindings/:bindingId`, async (context) => {
143
169
  const request = await parseBody(context, CanonicalHumanBindingOperationRequest);
170
+ const actor = await requestIdentity(context, deps);
171
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
172
+ if (
173
+ projection.loginBindings.some(
174
+ (binding) =>
175
+ binding.id === bindingId(context) &&
176
+ ["google", "github", "credential"].includes(binding.providerId),
177
+ )
178
+ ) {
179
+ throw new HTTPException(403, {
180
+ message: "Use personal sign-in method settings with last-method protection",
181
+ });
182
+ }
144
183
  return await mutate(context, deps, {
145
184
  operationType: "unlink",
146
185
  bindingId: bindingId(context),
@@ -151,6 +190,19 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
151
190
 
152
191
  app.post(`${base}/login-bindings/:bindingId/recovery`, async (context) => {
153
192
  const request = await parseBody(context, CanonicalHumanBindingOperationRequest);
193
+ const actor = await requestIdentity(context, deps);
194
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
195
+ if (
196
+ projection.loginBindings.some(
197
+ (binding) =>
198
+ binding.id === bindingId(context) &&
199
+ ["google", "github", "credential"].includes(binding.providerId),
200
+ )
201
+ ) {
202
+ throw new HTTPException(403, {
203
+ message: "Managed-provider recovery cannot be initiated through raw binding management",
204
+ });
205
+ }
154
206
  return await mutate(context, deps, {
155
207
  operationType: "begin_recovery",
156
208
  bindingId: bindingId(context),
@@ -161,11 +213,20 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
161
213
 
162
214
  app.post(`${base}/login-bindings/:bindingId/recovery/complete`, async (context) => {
163
215
  const request = await parseBody(context, CanonicalHumanBindingOperationRequest);
216
+ const actor = await requestIdentity(context, deps);
217
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
218
+ const managed = projection.loginBindings.some(
219
+ (binding) =>
220
+ binding.id === bindingId(context) &&
221
+ ["google", "github", "credential"].includes(binding.providerId),
222
+ );
223
+ if (managed) await admitManagedSignInMutation(context, deps);
164
224
  return await mutate(context, deps, {
165
225
  operationType: "recover",
166
226
  bindingId: bindingId(context),
167
227
  ...request,
168
228
  operationId: request.operationId ?? randomUUID(),
229
+ ...(managed ? { verifiedManagedRecovery: true } : {}),
169
230
  });
170
231
  });
171
232
  }
@@ -3,9 +3,16 @@ import {
3
3
  CreateCapabilityCatalogItemRequest,
4
4
  DiscoverMcpCapabilitiesResponse,
5
5
  EnableCapabilityRequest,
6
+ ConnectorToolPermissionsResponse,
7
+ UpdateConnectorToolPermissionsRequest,
6
8
  } from "@opengeni/contracts";
7
9
  import type { Hono } from "hono";
8
- import { requireAccessGrant } from "@opengeni/core";
10
+ import {
11
+ requireAccessGrant,
12
+ requireAccessGrantAuthorization,
13
+ getConnectorToolPermissions,
14
+ updateConnectorToolPermissions,
15
+ } from "@opengeni/core";
9
16
  import type { ApiRouteDeps } from "@opengeni/core";
10
17
  import {
11
18
  buildCapabilityCatalog,
@@ -15,6 +22,7 @@ import {
15
22
  enableCapability,
16
23
  officialMcpRegistryUrl,
17
24
  } from "@opengeni/core";
25
+ import { isPersonalConnectionOwnerPrincipal } from "../connection-ownership";
18
26
  import { boundedLimit } from "../http/common";
19
27
  import { z } from "zod";
20
28
  import pluginSnapshot from "../../../../data/catalog/plugins-snapshot.json";
@@ -110,6 +118,48 @@ export function registerCapabilityRoutes(app: Hono, deps: ApiRouteDeps): void {
110
118
  );
111
119
  });
112
120
 
121
+ app.get("/v1/workspaces/:workspaceId/capabilities/:capabilityId/tool-permissions", async (c) => {
122
+ const workspaceId = c.req.param("workspaceId");
123
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "workspace:read");
124
+ return c.json(
125
+ ConnectorToolPermissionsResponse.parse(
126
+ await getConnectorToolPermissions({
127
+ db,
128
+ settings,
129
+ workspaceId,
130
+ grant: access.grant,
131
+ capabilityId: decodeURIComponent(c.req.param("capabilityId")),
132
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(access),
133
+ }),
134
+ ),
135
+ );
136
+ });
137
+
138
+ app.patch(
139
+ "/v1/workspaces/:workspaceId/capabilities/:capabilityId/tool-permissions",
140
+ async (c) => {
141
+ const workspaceId = c.req.param("workspaceId");
142
+ const access = await requireAccessGrantAuthorization(
143
+ c,
144
+ deps,
145
+ workspaceId,
146
+ "capabilities:manage",
147
+ );
148
+ const payload = UpdateConnectorToolPermissionsRequest.safeParse(await c.req.json());
149
+ if (!payload.success) return c.json({ error: "Invalid connector permission target" }, 400);
150
+ await updateConnectorToolPermissions({
151
+ db,
152
+ settings,
153
+ workspaceId,
154
+ grant: access.grant,
155
+ capabilityId: decodeURIComponent(c.req.param("capabilityId")),
156
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(access),
157
+ payload: payload.data,
158
+ });
159
+ return c.json({ saved: true });
160
+ },
161
+ );
162
+
113
163
  app.post("/v1/workspaces/:workspaceId/capabilities/:capabilityId/enable", async (c) => {
114
164
  const workspaceId = c.req.param("workspaceId");
115
165
  const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
@@ -84,7 +84,7 @@ const CODEX_PROVIDER_LABEL = "Codex subscription · no credits";
84
84
  // The wire shape for one Codex account (metadata only; never the secret column).
85
85
  // P2: fiveHour/weekly ride along, built from the CACHED usage columns (zero
86
86
  // provider calls, zero decrypts) so the bars render instantly off this read.
87
- function codexAccountJson(
87
+ export function codexAccountJson(
88
88
  row: CodexAccountStatus,
89
89
  options: {
90
90
  appsCredentialId?: string | null;
@@ -1214,7 +1214,9 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
1214
1214
  accountId: grant.accountId,
1215
1215
  workspaceId,
1216
1216
  subjectId: grant.subjectId,
1217
- mode: "workspace",
1217
+ // Connecting local capacity does not override an explicit source choice.
1218
+ // Automatic naturally prefers the newly connected workspace pool.
1219
+ mode: sourceBeforeConnect.mode,
1218
1220
  effectiveSourceBeforeMutation: sourceBeforeConnect.effectiveSource,
1219
1221
  });
1220
1222
  const rotation = await getCodexRotationSettings(tx, workspaceId);
@@ -1,6 +1,7 @@
1
1
  import { createHash, createHmac, randomUUID } from "node:crypto";
2
2
  import type { Hono } from "hono";
3
3
  import { HTTPException } from "hono/http-exception";
4
+ import { getConnectBeginReplay } from "@opengeni/db/connect-attempts";
4
5
  import {
5
6
  AdvanceConnectRequest,
6
7
  BeginConnectRequest,
@@ -12,15 +13,15 @@ import {
12
13
  API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
13
14
  InstallApiIntegrationRequest,
14
15
  stableJson,
16
+ assertOrganizationIntegrationAllowed,
15
17
  OPENGENI_PERSONAL_SLACK_MCP_URL,
16
- OPENGENI_PR_REVIEW_PACK_ID,
17
18
  } from "@opengeni/contracts";
18
19
  import {
19
20
  CORE_INTEGRATION_DEFINITIONS,
20
21
  createPinnedIntegrationTransport,
21
22
  } from "@opengeni/capabilities";
22
23
  import {
23
- beginConnectAttempt,
24
+ beginConnectAttempt as persistConnectAttempt,
24
25
  getConnectAttempt,
25
26
  listPendingConnectAttempts,
26
27
  withWorkspaceSubjectRls,
@@ -30,9 +31,8 @@ import {
30
31
  createConnection,
31
32
  updateConnection,
32
33
  encryptEnvironmentValue,
33
- normalizedHostCredentialHeaders,
34
+ normalizedCredentialHeaders,
34
35
  listGitHubInstallationAccessForWorkspace,
35
- getPackInstallation,
36
36
  listPrReviewAppRegistrations,
37
37
  listSocialConnections,
38
38
  getSocialConnection,
@@ -46,6 +46,7 @@ import {
46
46
  isOpenGeniSlackBotConnection,
47
47
  prepareCapabilityEnable,
48
48
  buildCapabilityCatalog,
49
+ integrationKeyForConnectProvider,
49
50
  type ApiRouteDeps,
50
51
  } from "@opengeni/core";
51
52
  import { isPersonalConnectionOwnerPrincipal } from "../connection-ownership";
@@ -74,6 +75,7 @@ import {
74
75
  } from "../integrations/provider-oauth";
75
76
  import { resolveForRoute, validatedIntegrationInstallInput } from "./api-integrations";
76
77
  import { executeConnectOperation } from "@opengeni/core";
78
+ import { withOrganizationIntegrationPolicyFence } from "@opengeni/db/organization-integration-policy";
77
79
  import { startMcpOAuth, requireIntegrationsStateSecret } from "../integrations/oauth-client";
78
80
  import { OFFICIAL_GMAIL_MCP_URL } from "../integrations/oauth-profiles";
79
81
  import { z } from "zod";
@@ -101,13 +103,13 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
101
103
  "workspace:read",
102
104
  );
103
105
  const external = authorization.contextIntegrity;
104
- const canWrite = hasPermission(authorization.grant.permissions, "connections:write");
105
- const personal = isPersonalConnectionOwnerPrincipal(authorization);
106
- const lensPack = await getPackInstallation(
106
+ const policy = await withOrganizationIntegrationPolicyFence(
107
107
  deps.db,
108
- authorization.grant.workspaceId,
109
- OPENGENI_PR_REVIEW_PACK_ID,
108
+ authorization.grant,
109
+ async (_tx, current) => current,
110
110
  );
111
+ const canWrite = hasPermission(authorization.grant.permissions, "connections:write");
112
+ const personal = isPersonalConnectionOwnerPrincipal(authorization);
111
113
  let mcpConfigured = false;
112
114
  let credentialConfigured = false;
113
115
  try {
@@ -118,7 +120,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
118
120
  } catch {
119
121
  /* Missing operator configuration is catalog readiness, not actor authority. */
120
122
  }
121
- return c.json([
123
+ const providers = [
122
124
  ...(["x", "reddit"] as const).map((providerId) => {
123
125
  let configured = mcpConfigured;
124
126
  try {
@@ -233,27 +235,27 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
233
235
  label: "Gmail",
234
236
  family: "google",
235
237
  readiness:
236
- !external || !canWrite || !personal
238
+ !external || !canWrite
237
239
  ? "unsupported"
238
240
  : mcpConfigured
239
241
  ? "available"
240
242
  : "needs_configuration",
241
- ownership: personal ? ["personal"] : [],
243
+ ownership: personal ? ["workspace", "personal"] : ["workspace"],
242
244
  setup: ["oauth"],
243
245
  }),
244
246
  ConnectProvider.parse({
245
247
  id: "slack-personal",
246
- label: "My Slack account",
248
+ label: "Slack account",
247
249
  family: "slack",
248
250
  readiness:
249
- !external || !canWrite || !personal
251
+ !external || !canWrite
250
252
  ? "unsupported"
251
253
  : mcpConfigured &&
252
254
  deps.settings.slackClientId?.trim() &&
253
255
  deps.settings.slackClientSecret?.trim()
254
256
  ? "available"
255
257
  : "needs_configuration",
256
- ownership: personal ? ["personal"] : [],
258
+ ownership: personal ? ["workspace", "personal"] : ["workspace"],
257
259
  setup: ["oauth"],
258
260
  }),
259
261
  ConnectProvider.parse({
@@ -296,7 +298,6 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
296
298
  !hasPermission(authorization.grant.permissions, "secrets:write")
297
299
  ? "unsupported"
298
300
  : credentialConfigured &&
299
- lensPack?.status === "active" &&
300
301
  deps.settings.sandboxBackend !== "selfhosted" &&
301
302
  prReviewGitHubAppMissingSettings(deps.settings).length === 0
302
303
  ? "available"
@@ -391,7 +392,16 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
391
392
  ownership: personal ? ["workspace", "personal"] : ["workspace"],
392
393
  setup: ["credentials"],
393
394
  }),
394
- ]);
395
+ ];
396
+ return c.json(
397
+ providers.filter(
398
+ (provider) =>
399
+ policy.mode === "unrestricted" ||
400
+ policy.allowedIntegrationKeys.includes(
401
+ integrationKeyForConnectProvider(provider.id) ?? "",
402
+ ),
403
+ ),
404
+ );
395
405
  });
396
406
  app.get("/v1/workspaces/:workspaceId/connect/accounts", async (c) => {
397
407
  const workspaceId = c.req.param("workspaceId");
@@ -717,7 +727,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
717
727
  await requireConnectOwnerAuthority(tx, scope, installationPermission, origin);
718
728
  if (before.providerId === "github-lens") {
719
729
  await requireConnectOwnerAuthority(tx, scope, "secrets:write", origin);
720
- await requireGitHubLensConnect({ ...deps, db: tx }, workspaceId);
730
+ await requireGitHubLensConnect({ ...deps, db: tx });
721
731
  }
722
732
  },
723
733
  execute: async (attempt) =>
@@ -754,6 +764,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
754
764
  const resolved = await resolveForRoute({
755
765
  deps,
756
766
  transport,
767
+ accountId: scope.accountId,
757
768
  workspaceId,
758
769
  subjectId: scope.subjectId,
759
770
  payload: {
@@ -904,7 +915,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
904
915
  try {
905
916
  credentialHeaders =
906
917
  "headers" in values
907
- ? normalizedHostCredentialHeaders(
918
+ ? normalizedCredentialHeaders(
908
919
  z.record(z.string(), z.string()).parse(JSON.parse(values.headers)),
909
920
  )
910
921
  : { authorization: `Bearer ${values.token}` };
@@ -1052,6 +1063,9 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1052
1063
  throw new HTTPException(409, { message: "Reload the current connection setup" });
1053
1064
  const started = await startMcpOAuth(deps, {
1054
1065
  ...scope,
1066
+ ...(before.providerId === "gmail" || before.providerId === "slack-personal"
1067
+ ? { integrationKey: before.providerId }
1068
+ : {}),
1055
1069
  ...(continuation ? { externalContinuation: continuation } : {}),
1056
1070
  connectAttemptId: attempt.id,
1057
1071
  personalOwnershipAllowed: isPersonalConnectionOwnerPrincipal(authorization),
@@ -1144,6 +1158,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1144
1158
  const resolved = await resolveForRoute({
1145
1159
  deps,
1146
1160
  transport,
1161
+ accountId: scope.accountId,
1147
1162
  workspaceId,
1148
1163
  subjectId: scope.subjectId,
1149
1164
  payload: previewRequest,
@@ -1237,6 +1252,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1237
1252
  inputDigest: createHash("sha256")
1238
1253
  .update(stableJson({ ...input, action: "cancel" }))
1239
1254
  .digest("hex"),
1255
+ purpose: "cancellation",
1240
1256
  authorize: async (tx, attempt, origin) => {
1241
1257
  const permission =
1242
1258
  attempt.providerId === "mcp-install"
@@ -1320,8 +1336,59 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1320
1336
  ...(continuation ? { externalContinuation: continuation } : {}),
1321
1337
  };
1322
1338
  return c.json(
1323
- await withWorkspaceSubjectRls(deps.db, workspaceId, scope.subjectId, async (tx) => {
1324
- await requireConnectOwnerAuthority(tx, scope, setupPermission);
1339
+ await (async () => {
1340
+ // Remote setup preparation stays outside the policy and actor transactions.
1341
+ // Repeat both admissions when persisting the resulting attempt.
1342
+ const tx = deps.db;
1343
+ const admit = () =>
1344
+ withOrganizationIntegrationPolicyFence(deps.db, scope, async (policyTx, policy) => {
1345
+ return withWorkspaceSubjectRls(
1346
+ policyTx,
1347
+ workspaceId,
1348
+ scope.subjectId,
1349
+ async (actorTx) => {
1350
+ await requireConnectOwnerAuthority(actorTx, scope, setupPermission);
1351
+ const replay = await getConnectBeginReplay(actorTx, scope, {
1352
+ idempotencyKey: input.idempotencyKey,
1353
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1354
+ returnUrl: input.returnUrl,
1355
+ ...(continuation ? { externalContinuation: continuation } : {}),
1356
+ });
1357
+ if (replay) return replay;
1358
+ assertOrganizationIntegrationAllowed(
1359
+ policy,
1360
+ integrationKeyForConnectProvider(input.providerId),
1361
+ );
1362
+ return null;
1363
+ },
1364
+ );
1365
+ });
1366
+ const replay = await admit();
1367
+ if (replay) return replay;
1368
+ const beginConnectAttempt = async (
1369
+ _db: Parameters<typeof persistConnectAttempt>[0],
1370
+ _scope: Parameters<typeof persistConnectAttempt>[1],
1371
+ attemptInput: Parameters<typeof persistConnectAttempt>[2],
1372
+ ) =>
1373
+ withOrganizationIntegrationPolicyFence(deps.db, scope, async (policyTx, policy) => {
1374
+ return withWorkspaceSubjectRls(
1375
+ policyTx,
1376
+ workspaceId,
1377
+ scope.subjectId,
1378
+ async (actorTx) => {
1379
+ await requireConnectOwnerAuthority(actorTx, scope, setupPermission);
1380
+ return persistConnectAttempt(actorTx, scope, {
1381
+ ...attemptInput,
1382
+ authorizeAcquisition: async () => {
1383
+ assertOrganizationIntegrationAllowed(
1384
+ policy,
1385
+ integrationKeyForConnectProvider(input.providerId),
1386
+ );
1387
+ },
1388
+ });
1389
+ },
1390
+ );
1391
+ });
1325
1392
  const id = randomUUID();
1326
1393
  if (input.providerId === "mcp-install") {
1327
1394
  if (
@@ -1467,7 +1534,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1467
1534
  if (!hasPermission(authorization.grant.permissions, "secrets:write"))
1468
1535
  throw new HTTPException(403, { message: "secrets:write required" });
1469
1536
  await requireConnectOwnerAuthority(tx, scope, "secrets:write");
1470
- await requireGitHubLensConnect({ ...deps, db: tx }, workspaceId);
1537
+ await requireGitHubLensConnect({ ...deps, db: tx });
1471
1538
  }
1472
1539
  const navigation = githubAppConnectNavigation(
1473
1540
  deps,
@@ -1595,14 +1662,6 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1595
1662
  "fiken-token",
1596
1663
  ].includes(input.providerId)
1597
1664
  ) {
1598
- if (input.providerId === "slack-personal" && input.ownership !== "personal")
1599
- throw new HTTPException(422, {
1600
- message: "Hosted Slack MCP requires personal ownership",
1601
- });
1602
- if (input.providerId === "gmail" && input.ownership !== "personal")
1603
- throw new HTTPException(422, {
1604
- message: "Gmail requires personal ownership; each user connects their own account",
1605
- });
1606
1665
  if (input.providerId === "fiken-token" && input.ownership !== "workspace")
1607
1666
  throw new HTTPException(422, { message: "Fiken is workspace-owned" });
1608
1667
  requireEnvironmentEncryption(deps.settings);
@@ -1867,7 +1926,7 @@ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
1867
1926
  expiresAt: started.expiresAt,
1868
1927
  },
1869
1928
  });
1870
- }),
1929
+ })(),
1871
1930
  );
1872
1931
  });
1873
1932
  app.get("/v1/workspaces/:workspaceId/connect/attempts", async (c) => {