@opengeni/api-router 0.30.3 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -0,0 +1,246 @@
1
+ import type { PrReviewProvider } from "@opengeni/contracts";
2
+ import {
3
+ pinnedFetch,
4
+ readResponseJsonBounded,
5
+ readResponseTextBounded,
6
+ type FetchLike,
7
+ type OutboundNetworkSettings,
8
+ } from "@opengeni/network";
9
+
10
+ const providerVerificationTimeoutMs = 15_000;
11
+ const providerVerificationResponseMaxBytes = 256 * 1024;
12
+ const providerVerificationErrorMaxBytes = 8 * 1024;
13
+
14
+ export class PrReviewProviderRepositoryError extends Error {
15
+ constructor(
16
+ readonly reason: "denied" | "identity_mismatch" | "unavailable",
17
+ message: string,
18
+ ) {
19
+ super(message);
20
+ this.name = "PrReviewProviderRepositoryError";
21
+ }
22
+ }
23
+
24
+ export type VerifiedPrReviewProviderRepository = {
25
+ repositoryUri: string;
26
+ repositoryFullName: string;
27
+ providerRepositoryId: string;
28
+ projectId: string;
29
+ };
30
+
31
+ export async function verifyPrReviewProviderRepository(input: {
32
+ provider: Exclude<PrReviewProvider, "github">;
33
+ providerBaseUrl: string;
34
+ providerRepositoryId: string;
35
+ projectId?: string;
36
+ token: string;
37
+ username: string | null;
38
+ settings: OutboundNetworkSettings;
39
+ fetchImpl?: FetchLike;
40
+ }): Promise<VerifiedPrReviewProviderRepository> {
41
+ return input.provider === "gitlab"
42
+ ? await verifyGitLabRepository(input)
43
+ : await verifyAzureDevOpsRepository(input);
44
+ }
45
+
46
+ async function verifyGitLabRepository(
47
+ input: Parameters<typeof verifyPrReviewProviderRepository>[0],
48
+ ): Promise<VerifiedPrReviewProviderRepository> {
49
+ if (!/^\d+$/.test(input.providerRepositoryId)) {
50
+ throw new PrReviewProviderRepositoryError(
51
+ "identity_mismatch",
52
+ "GitLab repository bindings require the numeric project ID",
53
+ );
54
+ }
55
+ const url = providerApiUrl(
56
+ input.providerBaseUrl,
57
+ `api/v4/projects/${encodeURIComponent(input.providerRepositoryId)}`,
58
+ );
59
+ const payload = await providerJson(input, url, {
60
+ authorization: `Bearer ${input.token}`,
61
+ });
62
+ const id = scalarId(payload.id);
63
+ const fullName = nonEmptyText(payload.path_with_namespace);
64
+ const repositoryUri = credentialFreeHttps(payload.http_url_to_repo, input.providerBaseUrl);
65
+ if (id !== input.providerRepositoryId || !fullName || !repositoryUri) {
66
+ throw new PrReviewProviderRepositoryError(
67
+ "identity_mismatch",
68
+ "GitLab returned a different or incomplete project identity",
69
+ );
70
+ }
71
+ return {
72
+ repositoryUri,
73
+ repositoryFullName: fullName,
74
+ providerRepositoryId: id,
75
+ projectId: id,
76
+ };
77
+ }
78
+
79
+ async function verifyAzureDevOpsRepository(
80
+ input: Parameters<typeof verifyPrReviewProviderRepository>[0],
81
+ ): Promise<VerifiedPrReviewProviderRepository> {
82
+ if (!input.projectId) {
83
+ throw new PrReviewProviderRepositoryError(
84
+ "identity_mismatch",
85
+ "Azure DevOps repository bindings require the exact project ID",
86
+ );
87
+ }
88
+ const url = providerApiUrl(
89
+ input.providerBaseUrl,
90
+ `${encodeURIComponent(input.projectId)}/_apis/git/repositories/${encodeURIComponent(input.providerRepositoryId)}`,
91
+ );
92
+ url.searchParams.set("api-version", "7.1");
93
+ const authorization = Buffer.from(`${input.username ?? "pr-review"}:${input.token}`).toString(
94
+ "base64",
95
+ );
96
+ const payload = await providerJson(input, url, {
97
+ authorization: `Basic ${authorization}`,
98
+ });
99
+ const project = record(payload.project);
100
+ const id = scalarId(payload.id);
101
+ const projectId = scalarId(project?.id);
102
+ const repositoryName = nonEmptyText(payload.name);
103
+ const projectName = nonEmptyText(project?.name);
104
+ const repositoryUri = credentialFreeHttps(payload.remoteUrl, input.providerBaseUrl);
105
+ if (
106
+ !id ||
107
+ id.toLowerCase() !== input.providerRepositoryId.toLowerCase() ||
108
+ !projectId ||
109
+ projectId.toLowerCase() !== input.projectId.toLowerCase() ||
110
+ !repositoryName ||
111
+ !repositoryUri
112
+ ) {
113
+ throw new PrReviewProviderRepositoryError(
114
+ "identity_mismatch",
115
+ "Azure DevOps returned a different or incomplete repository identity",
116
+ );
117
+ }
118
+ return {
119
+ repositoryUri,
120
+ repositoryFullName: projectName ? `${projectName}/${repositoryName}` : repositoryName,
121
+ providerRepositoryId: id,
122
+ projectId,
123
+ };
124
+ }
125
+
126
+ async function providerJson(
127
+ input: Parameters<typeof verifyPrReviewProviderRepository>[0],
128
+ url: URL,
129
+ headers: Record<string, string>,
130
+ ): Promise<Record<string, unknown>> {
131
+ const signal = AbortSignal.timeout(providerVerificationTimeoutMs);
132
+ let response: Response;
133
+ try {
134
+ const requestInit = {
135
+ headers: { accept: "application/json", ...headers },
136
+ signal,
137
+ } satisfies RequestInit;
138
+ response = input.fetchImpl
139
+ ? await input.fetchImpl(url, requestInit)
140
+ : await pinnedFetch(url, requestInit, input.settings, {
141
+ label: `${input.provider} PR Review repository verification`,
142
+ requireHttpsOutsideLocalTest: true,
143
+ });
144
+ } catch {
145
+ throw new PrReviewProviderRepositoryError(
146
+ "unavailable",
147
+ `${input.provider} repository verification is unavailable`,
148
+ );
149
+ }
150
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
151
+ await readResponseTextBounded(
152
+ response,
153
+ providerVerificationErrorMaxBytes,
154
+ `${input.provider} repository verification error`,
155
+ { signal },
156
+ ).catch(() => undefined);
157
+ throw new PrReviewProviderRepositoryError(
158
+ "denied",
159
+ `${input.provider} credential cannot access the requested repository`,
160
+ );
161
+ }
162
+ if (!response.ok) {
163
+ await readResponseTextBounded(
164
+ response,
165
+ providerVerificationErrorMaxBytes,
166
+ `${input.provider} repository verification error`,
167
+ { signal },
168
+ ).catch(() => undefined);
169
+ throw new PrReviewProviderRepositoryError(
170
+ "unavailable",
171
+ `${input.provider} repository verification failed`,
172
+ );
173
+ }
174
+ let payload: unknown;
175
+ try {
176
+ payload = await readResponseJsonBounded(
177
+ response,
178
+ providerVerificationResponseMaxBytes,
179
+ `${input.provider} repository metadata`,
180
+ { signal },
181
+ );
182
+ } catch {
183
+ throw new PrReviewProviderRepositoryError(
184
+ "unavailable",
185
+ `${input.provider} returned invalid repository metadata`,
186
+ );
187
+ }
188
+ const parsed = record(payload);
189
+ if (!parsed) {
190
+ throw new PrReviewProviderRepositoryError(
191
+ "unavailable",
192
+ `${input.provider} returned invalid repository metadata`,
193
+ );
194
+ }
195
+ return parsed;
196
+ }
197
+
198
+ function providerApiUrl(base: string, suffix: string): URL {
199
+ const url = new URL(base);
200
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/${suffix}`;
201
+ url.search = "";
202
+ url.hash = "";
203
+ return url;
204
+ }
205
+
206
+ function credentialFreeHttps(value: unknown, providerBaseUrl: string): string | null {
207
+ if (typeof value !== "string") return null;
208
+ let candidate: URL;
209
+ const provider = new URL(providerBaseUrl);
210
+ try {
211
+ candidate = new URL(value);
212
+ } catch {
213
+ return null;
214
+ }
215
+ const providerPath = provider.pathname.replace(/\/+$/, "");
216
+ if (
217
+ candidate.protocol !== "https:" ||
218
+ candidate.username ||
219
+ candidate.password ||
220
+ candidate.href.length > 2048 ||
221
+ candidate.host.toLowerCase() !== provider.host.toLowerCase() ||
222
+ (providerPath !== "" &&
223
+ providerPath !== "/" &&
224
+ candidate.pathname !== providerPath &&
225
+ !candidate.pathname.startsWith(`${providerPath}/`))
226
+ ) {
227
+ return null;
228
+ }
229
+ return candidate.href;
230
+ }
231
+
232
+ function record(value: unknown): Record<string, unknown> | null {
233
+ return typeof value === "object" && value !== null && !Array.isArray(value)
234
+ ? (value as Record<string, unknown>)
235
+ : null;
236
+ }
237
+
238
+ function scalarId(value: unknown): string | null {
239
+ if (typeof value === "string" && value.length > 0 && value.length <= 512) return value;
240
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
241
+ return null;
242
+ }
243
+
244
+ function nonEmptyText(value: unknown): string | null {
245
+ return typeof value === "string" && value.length > 0 && value.length <= 1024 ? value : null;
246
+ }
@@ -37,11 +37,23 @@ import {
37
37
  } from "@opengeni/network";
38
38
  import { HTTPException } from "hono/http-exception";
39
39
 
40
+ import {
41
+ assertConnectionOwnershipAllowedForPrincipal,
42
+ personalOwnerStateAccepted,
43
+ personalOwnerVerifiedInState,
44
+ PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
45
+ } from "../connection-ownership";
40
46
  import {
41
47
  integrationBaseUrl,
42
48
  oauthStateTtlMs,
43
49
  requireIntegrationsStateSecret,
44
50
  } from "./oauth-client";
51
+ import {
52
+ assertOwnershipAllowed,
53
+ builtInOAuthProfileFor,
54
+ DEFAULT_OAUTH_PROFILE,
55
+ type OAuthProviderProfile,
56
+ } from "./oauth-profiles";
45
57
 
46
58
  const PROVIDER_OAUTH_CALLBACK_PATH = "/v1/integrations/provider-oauth/callback";
47
59
  const PROVIDER_OAUTH_TIMEOUT_MS = 15_000;
@@ -60,6 +72,7 @@ type ProviderOAuthState = {
60
72
  workspaceId: string;
61
73
  subjectId: string;
62
74
  ownership: ConnectionOwnership;
75
+ personalOwnerVerified: boolean;
63
76
  definitionId: string;
64
77
  definitionFingerprint: string;
65
78
  providerDomain: string;
@@ -117,6 +130,12 @@ export async function startApiIntegrationProviderOAuth(
117
130
  accountId: string;
118
131
  workspaceId: string;
119
132
  subjectId: string;
133
+ /**
134
+ * False for every principal that cannot own a personal Connection (API
135
+ * keys, the configured key, services, agent attempts). Resolved by the
136
+ * route from the live authenticated principal, never inferred here.
137
+ */
138
+ personalOwnershipAllowed: boolean;
120
139
  requestUrl: string;
121
140
  payload: ApiIntegrationOAuthStartRequest;
122
141
  },
@@ -155,7 +174,33 @@ export async function startApiIntegrationProviderOAuth(
155
174
  message: "The selected Connection ownership does not match this OAuth request",
156
175
  });
157
176
  }
158
- const ownership = existingOwnership ?? input.payload.ownership ?? "personal";
177
+ // This flow used to resolve an omitted ownership to `personal`, inverting the
178
+ // documented workspace-owned default. Resolving it to `workspace` instead
179
+ // would have been the opposite defect: an executed probe on
180
+ // `microsoft-outlook-mail` confirmed it flips a newly connected mailbox from
181
+ // subject-scoped to workspace-shared for API/SDK callers, which is a real
182
+ // narrow -> broad widening. So an ambiguous omission is refused outright and
183
+ // the caller must choose. A profile that allows exactly one ownership is not
184
+ // ambiguous (Gmail and hosted Slack MCP are personal-only), and a reconnect
185
+ // takes the existing row's ownership.
186
+ const profile = providerOAuthProfile(definition, providerDomain);
187
+ const ownership =
188
+ existingOwnership ?? input.payload.ownership ?? soleAllowedOwnership(profile) ?? null;
189
+ if (ownership === null) {
190
+ throw new HTTPException(422, {
191
+ message:
192
+ 'ownership is required: choose "workspace" to share this Connection with the workspace, ' +
193
+ 'or "personal" to connect only for yourself',
194
+ });
195
+ }
196
+ // Inert by construction today, and deliberately kept: no curated Definition
197
+ // resolves to a personal-only profile (all five are Google Workspace and
198
+ // Microsoft Graph APIs, so `providerOAuthProfile` returns the default), which
199
+ // is why disabling this line reddens nothing. It is the fence that keeps a
200
+ // future Definition targeting slack.com or Gmail from minting an ownership
201
+ // its profile forbids - read it as pre-wiring, not as an untested gap.
202
+ assertOwnershipAllowed(profile, ownership);
203
+ assertConnectionOwnershipAllowedForPrincipal(ownership, input.personalOwnershipAllowed);
159
204
  const authorizeScopes = uniqueStrings([
160
205
  ...(existing?.grantedScopes ?? []),
161
206
  ...definition.authentication.scopes,
@@ -164,7 +209,7 @@ export async function startApiIntegrationProviderOAuth(
164
209
  const verifier = randomBytes(48).toString("base64url");
165
210
  const key = requireEnvironmentEncryption(deps.settings);
166
211
  const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
167
- const redirectUri = `${baseUrl}${PROVIDER_OAUTH_CALLBACK_PATH}`;
212
+ const redirectUri = `${baseUrl}${providerOAuthCallbackPath(definition)}`;
168
213
  const returnPath = safeReturnPath(
169
214
  input.payload.returnPath ?? `/workspaces/${input.workspaceId}/capabilities`,
170
215
  );
@@ -173,6 +218,9 @@ export async function startApiIntegrationProviderOAuth(
173
218
  workspaceId: input.workspaceId,
174
219
  subjectId: input.subjectId,
175
220
  ownership,
221
+ // Signed record that a live principal was checked; the callback has no
222
+ // principal of its own and enforces exactly this decision.
223
+ [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: input.personalOwnershipAllowed,
176
224
  definitionId: definition.id,
177
225
  definitionFingerprint: providerDefinitionFingerprint(definition),
178
226
  providerDomain,
@@ -227,6 +275,7 @@ export async function completeApiIntegrationProviderOAuth(
227
275
  let state: ProviderOAuthState | null = null;
228
276
  try {
229
277
  state = readProviderOAuthState(input.state, deps.settings);
278
+ requireProviderOAuthOwner(state);
230
279
  await requireProviderOAuthGrant(deps, state);
231
280
  const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
232
281
  accountId: state.accountId,
@@ -258,7 +307,7 @@ export async function completeApiIntegrationProviderOAuth(
258
307
  }
259
308
  const key = requireEnvironmentEncryption(deps.settings);
260
309
  const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
261
- const redirectUri = `${apiBaseUrl}${PROVIDER_OAUTH_CALLBACK_PATH}`;
310
+ const redirectUri = `${apiBaseUrl}${providerOAuthCallbackPath(definition)}`;
262
311
  const token = await exchangeProviderAuthorizationCode(deps, definition, client, {
263
312
  code: input.code,
264
313
  verifier,
@@ -387,6 +436,28 @@ export async function completeApiIntegrationProviderOAuth(
387
436
  }
388
437
  }
389
438
 
439
+ /**
440
+ * The ownership fences that apply to a curated Definition, read from the same
441
+ * profile table the MCP OAuth start uses. No curated Definition targets a
442
+ * personal-only provider today (they are Google Workspace and Microsoft Graph
443
+ * APIs), so this resolves to the default profile and its workspace default;
444
+ * matching by provider domain keeps the Slack/Gmail fences in force if one ever
445
+ * does, instead of letting this flow mint an ownership their profile forbids.
446
+ */
447
+ /** The profile's ownership when it allows exactly one, else null (ambiguous). */
448
+ function soleAllowedOwnership(profile: OAuthProviderProfile): ConnectionOwnership | null {
449
+ return profile.allowedOwnership.length === 1 ? profile.allowedOwnership[0]! : null;
450
+ }
451
+
452
+ function providerOAuthProfile(
453
+ definition: IntegrationDefinition,
454
+ providerDomain: string,
455
+ ): OAuthProviderProfile {
456
+ return (
457
+ builtInOAuthProfileFor({ mcpUrl: definition.baseUrl, providerDomain }) ?? DEFAULT_OAUTH_PROFILE
458
+ );
459
+ }
460
+
390
461
  function requiredDefinition(id: string): IntegrationDefinition {
391
462
  const definition = integrationDefinitionById(id);
392
463
  if (!definition) throw new HTTPException(404, { message: "Unknown Integration definition" });
@@ -434,6 +505,28 @@ function providerClientForDefinition(
434
505
  };
435
506
  }
436
507
 
508
+ export function isApiIntegrationProviderOAuthState(
509
+ value: string | undefined,
510
+ settings: Settings,
511
+ ): boolean {
512
+ try {
513
+ readProviderOAuthState(value, settings);
514
+ return true;
515
+ } catch {
516
+ return false;
517
+ }
518
+ }
519
+
520
+ function providerOAuthCallbackPath(definition: IntegrationDefinition): string {
521
+ // Google definitions share the established MCP OAuth callback. The
522
+ // route distinguishes signed provider state from MCP state, while the
523
+ // separate google-drive callback remains available for the legacy Drive
524
+ // installation flow.
525
+ return definition.provider.id === "google"
526
+ ? "/v1/integrations/oauth/callback"
527
+ : PROVIDER_OAUTH_CALLBACK_PATH;
528
+ }
529
+
437
530
  function providerClientKeys(definition: IntegrationDefinition): string[] {
438
531
  const authorization = new URL(definition.authentication.authorizationUrl);
439
532
  const token = new URL(definition.authentication.tokenUrl);
@@ -503,6 +596,7 @@ function readProviderOAuthState(raw: string | undefined, settings: Settings): Pr
503
596
  workspaceId: requiredStateString(payload.workspaceId),
504
597
  subjectId: requiredStateString(payload.subjectId),
505
598
  ownership,
599
+ personalOwnerVerified: personalOwnerVerifiedInState(payload),
506
600
  definitionId: requiredStateString(payload.definitionId),
507
601
  definitionFingerprint: requiredStateString(payload.definitionFingerprint),
508
602
  providerDomain: requiredStateString(payload.providerDomain),
@@ -657,6 +751,18 @@ async function requireProviderOAuthGrant(
657
751
  }
658
752
  }
659
753
 
754
+ /**
755
+ * A state minted before the start-side principal fence existed can still be in
756
+ * flight for one `oauthStateTtlMs` window, and it carries no
757
+ * `personalOwnerVerified` claim. Such a state must not land a personal
758
+ * Connection.
759
+ */
760
+ function requireProviderOAuthOwner(state: ProviderOAuthState): void {
761
+ if (!personalOwnerStateAccepted(state)) {
762
+ throw new ProviderOAuthCallbackError("connection_conflict");
763
+ }
764
+ }
765
+
660
766
  function requireProviderOAuthConnection(
661
767
  connection: NonNullable<Awaited<ReturnType<typeof getConnectionMetadata>>>,
662
768
  input: {
@@ -686,8 +792,18 @@ function providerScopesInclude(
686
792
  fallbackScopes: string[],
687
793
  ): boolean {
688
794
  const effective = returnedScopes.length > 0 ? returnedScopes : fallbackScopes;
689
- const normalize = (value: string) =>
690
- definition.provider.id === "microsoft" ? value.toLowerCase() : value;
795
+ const normalize = (value: string) => {
796
+ if (definition.provider.id === "microsoft") return value.toLowerCase();
797
+ if (definition.provider.id === "google") {
798
+ if (value === "email" || value === "https://www.googleapis.com/auth/userinfo.email") {
799
+ return "email";
800
+ }
801
+ if (value === "profile" || value === "https://www.googleapis.com/auth/userinfo.profile") {
802
+ return "profile";
803
+ }
804
+ }
805
+ return value;
806
+ };
691
807
  const granted = new Set(effective.map(normalize));
692
808
  return definition.authentication.scopes.every((scope) => granted.has(normalize(scope)));
693
809
  }