@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,477 @@
1
+ import { OPENGENI_PERSONAL_SLACK_MCP_URL, type ConnectionOwnership } from "@opengeni/contracts";
2
+ import type { Settings } from "@opengeni/config";
3
+ import { HTTPException } from "hono/http-exception";
4
+ import { z } from "zod";
5
+ import { canonicalProviderDomain } from "./provider-domain";
6
+
7
+ /**
8
+ * Provider OAuth quirks as data.
9
+ *
10
+ * Some providers do not support DCR or CIMD and need a pre-registered client
11
+ * with pinned metadata; some issue personal tokens only; some reject RFC 8707's
12
+ * `resource` parameter. Each used to be a hand-written branch set inside
13
+ * `oauth-client.ts`; every quirk is now a field on an `OAuthProviderProfile`.
14
+ * The flow reads exactly one resolved profile and contains no provider-name
15
+ * conditional.
16
+ *
17
+ * Profiles come from two layers:
18
+ *
19
+ * 1. Built-in profiles below, for the providers whose fences are security
20
+ * invariants (hosted Slack MCP and official Gmail are personal-only, their
21
+ * authorization servers are origin-pinned). These live in code as data so
22
+ * the fences never depend on catalog import state.
23
+ * 2. A validated `oauthProfile` object on a global catalog row (curated
24
+ * overlay -> importer -> `capability_catalog_items.metadata`). A catalog
25
+ * profile applies only when no built-in matches, and it can only narrow the
26
+ * default behavior, never loosen a built-in fence.
27
+ */
28
+
29
+ export const OFFICIAL_SLACK_MCP_URL = OPENGENI_PERSONAL_SLACK_MCP_URL;
30
+ export const OFFICIAL_GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1";
31
+ export const OFFICIAL_GMAIL_MCP_SCOPES = [
32
+ "https://www.googleapis.com/auth/gmail.readonly",
33
+ "https://www.googleapis.com/auth/gmail.compose",
34
+ "https://www.googleapis.com/auth/gmail.modify",
35
+ ] as const;
36
+
37
+ const SLACK_OAUTH_ORIGIN = "https://slack.com";
38
+ const SLACK_MCP_ORIGIN = "https://mcp.slack.com";
39
+ const GOOGLE_OAUTH_ISSUER_ORIGIN = "https://accounts.google.com";
40
+ const GOOGLE_TOKEN_ORIGIN = "https://oauth2.googleapis.com";
41
+
42
+ /** The slice of discovered authorization-server metadata the pins constrain. */
43
+ export type PinnableAuthorizationServer = {
44
+ issuer: string;
45
+ authorizationServer: string;
46
+ authorizationEndpoint: string;
47
+ tokenEndpoint: string;
48
+ };
49
+
50
+ export type AuthorizationServerPins = {
51
+ /** Origins `issuer` and `authorizationServer` must both belong to. */
52
+ issuerOrigins: readonly string[];
53
+ /** Origins the authorization endpoint must belong to. */
54
+ authorizationEndpointOrigins: readonly string[];
55
+ /** Origins the token endpoint must belong to. */
56
+ tokenEndpointOrigins: readonly string[];
57
+ /** Exact 422 message when a pin fails. */
58
+ message: string;
59
+ /** Skip enforcement in a local test environment (loopback fixtures). */
60
+ skipInLocalTest: boolean;
61
+ };
62
+
63
+ export type OAuthProviderProfile = {
64
+ /** Stable identity for guards, logs, and tests. */
65
+ key: string;
66
+ /** Built-in matching; catalog profiles match their own row's exact mcpUrl. */
67
+ match: {
68
+ mcpUrls?: readonly string[];
69
+ providerDomains?: readonly string[];
70
+ };
71
+ /**
72
+ * Canonical provider identity forced when the profile matches by URL, so a
73
+ * caller cannot relabel a pinned resource under another domain.
74
+ */
75
+ canonicalProviderDomain?: string;
76
+ /** Reject a caller-supplied manual OAuth client (deployment-managed only). */
77
+ rejectCallerOAuthClient?: { message: string };
78
+ /** The payload's explicit providerDomain must canonicalize to this domain. */
79
+ requireProviderDomain?: { domain: string; message: string };
80
+ /** Outside a local test environment, the start URL must be exactly this. */
81
+ requireExactMcpUrl?: { url: string; message: string };
82
+ /** Deployment-managed client credentials must be configured (503 otherwise). */
83
+ requireDeploymentClient?: { key: DeploymentManagedClientKey; message: string };
84
+ /** Ownerships a connection may take; a singleton also sets the default. */
85
+ allowedOwnership: readonly ConnectionOwnership[];
86
+ /** Exact 422 message when an explicit disallowed ownership is requested. */
87
+ ownershipMessage?: string;
88
+ /** Bind reconnect/dedupe to the exact mcpUrl, not just the provider domain. */
89
+ exactMcpBinding: boolean;
90
+ /** How an existing connection is chosen for reconnect coalescing. */
91
+ connectionSelection: "canonical_personal" | "first_active";
92
+ /** Post-discovery authorization-server origin pins. */
93
+ authorizationServer?: AuthorizationServerPins;
94
+ /**
95
+ * Post-discovery identity check: the resolved providerDomain must equal this
96
+ * domain (the reserved-authorization-server guard shares its message).
97
+ */
98
+ postDiscoveryProviderDomain?: { domain: string; message: string };
99
+ /**
100
+ * Client registration preference. `dcr` or `cimd` forces that advertised
101
+ * self-registration mechanism after operator/deployment clients. Without an
102
+ * explicit preference, DCR wins when both mechanisms are advertised.
103
+ */
104
+ clientSource?: "deployment_managed" | "cimd" | "dcr";
105
+ /** Send RFC 8707 `resource` on authorize and token requests. */
106
+ sendResourceParameter: boolean;
107
+ /** Extra authorize-URL query parameters (e.g. offline-consent opts). */
108
+ extraAuthorizeParams?: Readonly<Record<string, string>>;
109
+ /** Exact scope override; the caller can never widen past it. */
110
+ requestedScopes?: readonly string[];
111
+ };
112
+
113
+ export const DEFAULT_OAUTH_PROFILE: OAuthProviderProfile = {
114
+ key: "default",
115
+ match: {},
116
+ allowedOwnership: ["workspace", "personal"],
117
+ exactMcpBinding: false,
118
+ connectionSelection: "first_active",
119
+ sendResourceParameter: true,
120
+ };
121
+
122
+ const HOSTED_SLACK_PROFILE: OAuthProviderProfile = {
123
+ key: "hosted-slack-mcp",
124
+ match: { mcpUrls: [OFFICIAL_SLACK_MCP_URL], providerDomains: ["slack.com"] },
125
+ canonicalProviderDomain: "slack.com",
126
+ rejectCallerOAuthClient: {
127
+ message: "Slack OAuth client credentials are deployment-managed",
128
+ },
129
+ requireProviderDomain: {
130
+ domain: "slack.com",
131
+ message: "Slack provider identity does not match slack.com",
132
+ },
133
+ requireExactMcpUrl: {
134
+ url: OFFICIAL_SLACK_MCP_URL,
135
+ message: `Slack MCP OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
136
+ },
137
+ requireDeploymentClient: {
138
+ key: "slack",
139
+ message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
140
+ },
141
+ allowedOwnership: ["personal"],
142
+ ownershipMessage:
143
+ "Slack's hosted MCP connection is personal only; install the OpenGeni Slack bot for workspace access",
144
+ exactMcpBinding: true,
145
+ connectionSelection: "canonical_personal",
146
+ authorizationServer: {
147
+ issuerOrigins: [SLACK_OAUTH_ORIGIN, SLACK_MCP_ORIGIN],
148
+ authorizationEndpointOrigins: [SLACK_OAUTH_ORIGIN],
149
+ tokenEndpointOrigins: [SLACK_OAUTH_ORIGIN],
150
+ message: "Slack MCP authorization metadata did not remain bound to slack.com",
151
+ skipInLocalTest: true,
152
+ },
153
+ sendResourceParameter: true,
154
+ };
155
+
156
+ const OFFICIAL_GMAIL_PROFILE: OAuthProviderProfile = {
157
+ key: "official-gmail",
158
+ match: { mcpUrls: [OFFICIAL_GMAIL_MCP_URL] },
159
+ allowedOwnership: ["personal"],
160
+ ownershipMessage:
161
+ "Gmail connections are personal only; each workspace member must connect their own mailbox",
162
+ exactMcpBinding: true,
163
+ connectionSelection: "first_active",
164
+ authorizationServer: {
165
+ issuerOrigins: [GOOGLE_OAUTH_ISSUER_ORIGIN],
166
+ authorizationEndpointOrigins: [GOOGLE_OAUTH_ISSUER_ORIGIN],
167
+ tokenEndpointOrigins: [GOOGLE_TOKEN_ORIGIN],
168
+ message: "Gmail MCP authorization metadata did not remain bound to Google",
169
+ skipInLocalTest: false,
170
+ },
171
+ postDiscoveryProviderDomain: {
172
+ domain: "gmailmcp.googleapis.com",
173
+ message: `Google OAuth is allowed only for ${OFFICIAL_GMAIL_MCP_URL}`,
174
+ },
175
+ // Google's OAuth endpoints do not implement RFC 8707's `resource` parameter.
176
+ sendResourceParameter: false,
177
+ // Explicit offline consent is required to obtain the refresh token used by
178
+ // the durable connection broker. These are Gmail profile data, not behavior
179
+ // implied by suppressing the resource parameter.
180
+ extraAuthorizeParams: {
181
+ access_type: "offline",
182
+ include_granted_scopes: "true",
183
+ prompt: "consent",
184
+ },
185
+ // The Gmail PRM advertises broader grants, including full-mail access. The
186
+ // reviewed connector never lets a caller widen the capability contract.
187
+ requestedScopes: OFFICIAL_GMAIL_MCP_SCOPES,
188
+ };
189
+
190
+ const BUILT_IN_OAUTH_PROFILES: readonly OAuthProviderProfile[] = [
191
+ HOSTED_SLACK_PROFILE,
192
+ OFFICIAL_GMAIL_PROFILE,
193
+ ];
194
+
195
+ /** Exact built-in profile by its stable key; throws on an unknown key. */
196
+ export function builtInOAuthProfileByKey(key: string): OAuthProviderProfile {
197
+ const profile = BUILT_IN_OAUTH_PROFILES.find((candidate) => candidate.key === key);
198
+ if (!profile) {
199
+ throw new Error(`missing built-in OAuth profile: ${key}`);
200
+ }
201
+ return profile;
202
+ }
203
+
204
+ /**
205
+ * Authorization servers reserved for exactly one profile: discovering one of
206
+ * these identities on any other flow is rejected before registration.
207
+ */
208
+ const RESERVED_AUTHORIZATION_SERVERS: readonly {
209
+ issuerOrigins: readonly string[];
210
+ allowedProfileKey: string;
211
+ message: string;
212
+ }[] = [
213
+ {
214
+ issuerOrigins: [GOOGLE_OAUTH_ISSUER_ORIGIN],
215
+ allowedProfileKey: OFFICIAL_GMAIL_PROFILE.key,
216
+ message: `Google OAuth is allowed only for ${OFFICIAL_GMAIL_MCP_URL}`,
217
+ },
218
+ {
219
+ // A URL variant that dodges the hosted-Slack profile match (for example a
220
+ // trailing slash yielding providerDomain mcp.slack.com) must not reach
221
+ // Slack's authorization server as a default-profile flow: that would mint
222
+ // a workspace-ownable Slack identity with the deployment client.
223
+ issuerOrigins: [SLACK_OAUTH_ORIGIN, SLACK_MCP_ORIGIN],
224
+ allowedProfileKey: HOSTED_SLACK_PROFILE.key,
225
+ message: `Slack OAuth is allowed only for ${OFFICIAL_SLACK_MCP_URL}`,
226
+ },
227
+ ];
228
+
229
+ export type DeploymentManagedClientKey = "slack";
230
+
231
+ /**
232
+ * Pre-registered deployment clients resolved from dedicated settings, keyed by
233
+ * the authorization-server origins they serve. Consulted before the operator
234
+ * clients JSON so a deployment-managed provider can never be shadowed.
235
+ */
236
+ export const DEPLOYMENT_MANAGED_CLIENTS: readonly {
237
+ key: DeploymentManagedClientKey;
238
+ issuerOrigins: readonly string[];
239
+ resolve: (settings: Settings) => {
240
+ clientId: string;
241
+ clientSecret: string;
242
+ tokenEndpointAuthMethod: "client_secret_post";
243
+ } | null;
244
+ }[] = [
245
+ {
246
+ key: "slack",
247
+ issuerOrigins: [SLACK_OAUTH_ORIGIN, SLACK_MCP_ORIGIN],
248
+ resolve: (settings) =>
249
+ settings.slackClientId?.trim() && settings.slackClientSecret?.trim()
250
+ ? {
251
+ clientId: settings.slackClientId.trim(),
252
+ clientSecret: settings.slackClientSecret.trim(),
253
+ tokenEndpointAuthMethod: "client_secret_post",
254
+ }
255
+ : null,
256
+ },
257
+ ];
258
+
259
+ export function deploymentManagedClientFor(
260
+ settings: Settings,
261
+ key: DeploymentManagedClientKey,
262
+ ): ReturnType<(typeof DEPLOYMENT_MANAGED_CLIENTS)[number]["resolve"]> {
263
+ const entry = DEPLOYMENT_MANAGED_CLIENTS.find((candidate) => candidate.key === key);
264
+ return entry ? entry.resolve(settings) : null;
265
+ }
266
+
267
+ /** Built-in profile for a start target, or null when only the default applies. */
268
+ export function builtInOAuthProfileFor(input: {
269
+ mcpUrl: string;
270
+ providerDomain?: string | undefined;
271
+ }): OAuthProviderProfile | null {
272
+ for (const profile of BUILT_IN_OAUTH_PROFILES) {
273
+ if (profile.match.mcpUrls?.includes(input.mcpUrl)) {
274
+ return profile;
275
+ }
276
+ if (
277
+ input.providerDomain !== undefined &&
278
+ profile.match.providerDomains?.includes(canonicalProviderDomain(input.providerDomain))
279
+ ) {
280
+ return profile;
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /** Ownership an omitted request defaults to under a profile. */
287
+ export function defaultOwnershipFor(profile: OAuthProviderProfile): ConnectionOwnership {
288
+ return profile.allowedOwnership.length === 1 && profile.allowedOwnership[0] === "personal"
289
+ ? "personal"
290
+ : "workspace";
291
+ }
292
+
293
+ /** Rejects an ownership outside the profile's allowed set with its exact message. */
294
+ export function assertOwnershipAllowed(
295
+ profile: OAuthProviderProfile,
296
+ ownership: ConnectionOwnership,
297
+ ): void {
298
+ if (profile.allowedOwnership.includes(ownership)) {
299
+ return;
300
+ }
301
+ throw new HTTPException(422, {
302
+ message:
303
+ profile.ownershipMessage ??
304
+ `this integration allows only ${profile.allowedOwnership.join(" or ")} connections`,
305
+ });
306
+ }
307
+
308
+ /** Enforces a profile's authorization-server origin pins with its exact message. */
309
+ export function assertAuthorizationServerPins(
310
+ as: PinnableAuthorizationServer,
311
+ pins: Pick<
312
+ AuthorizationServerPins,
313
+ "issuerOrigins" | "authorizationEndpointOrigins" | "tokenEndpointOrigins" | "message"
314
+ >,
315
+ ): void {
316
+ const issuerOrigins = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
317
+ const authorizationOrigin = new URL(as.authorizationEndpoint).origin;
318
+ const tokenOrigin = new URL(as.tokenEndpoint).origin;
319
+ if (
320
+ issuerOrigins.some((origin) => !pins.issuerOrigins.includes(origin)) ||
321
+ !pins.authorizationEndpointOrigins.includes(authorizationOrigin) ||
322
+ !pins.tokenEndpointOrigins.includes(tokenOrigin)
323
+ ) {
324
+ throw new HTTPException(422, { message: pins.message });
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Reserved-authorization-server guard: a discovered identity claimed by one
330
+ * profile is rejected on every other flow with that guard's exact message.
331
+ */
332
+ export function assertAuthorizationServerNotReserved(
333
+ as: PinnableAuthorizationServer,
334
+ profile: OAuthProviderProfile,
335
+ ): void {
336
+ const identities = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
337
+ for (const guard of RESERVED_AUTHORIZATION_SERVERS) {
338
+ if (profile.key === guard.allowedProfileKey) {
339
+ continue;
340
+ }
341
+ if (identities.some((origin) => guard.issuerOrigins.includes(origin))) {
342
+ throw new HTTPException(422, { message: guard.message });
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Catalog-row profile: the declarative subset a curated row may carry. It can
349
+ * only narrow the default flow; the fields that grant authority (deployment
350
+ * client settings, reserved-server membership) are built-in-only by
351
+ * construction because the schema cannot express them.
352
+ */
353
+ /**
354
+ * Authorize-URL parameters owned by the OAuth client itself. A profile's
355
+ * `extraAuthorizeParams` may never name one: overriding `scope` or `resource`
356
+ * would widen the grant past the recorded contract, and the rest carry the
357
+ * PKCE/state security machinery. Enforced in this schema, in the curation
358
+ * parser, and defensively again in `buildAuthorizationUrl`.
359
+ */
360
+ export const RESERVED_AUTHORIZE_PARAMS: ReadonlySet<string> = new Set([
361
+ "client_id",
362
+ "code_challenge",
363
+ "code_challenge_method",
364
+ "redirect_uri",
365
+ "resource",
366
+ "response_type",
367
+ "scope",
368
+ "state",
369
+ ]);
370
+
371
+ export const catalogOAuthProfileSchema = z
372
+ .object({
373
+ clientSource: z.enum(["deployment_managed", "cimd", "dcr"]).optional(),
374
+ exactMcpUrl: z.string().url().optional(),
375
+ pinnedIssuerOrigins: z.array(z.string().url()).min(1).optional(),
376
+ pinnedEndpointOrigins: z.array(z.string().url()).min(1).optional(),
377
+ sendResourceParameter: z.boolean().optional(),
378
+ allowedOwnership: z
379
+ .array(z.enum(["personal", "workspace"]))
380
+ .min(1)
381
+ .optional(),
382
+ requestedScopes: z.array(z.string().min(1)).min(1).optional(),
383
+ extraAuthorizeParams: z
384
+ .record(z.string(), z.string())
385
+ .optional()
386
+ .refine(
387
+ (value) => !value || Object.keys(value).every((key) => !RESERVED_AUTHORIZE_PARAMS.has(key)),
388
+ { message: "extraAuthorizeParams may not name a reserved OAuth parameter" },
389
+ ),
390
+ })
391
+ .strict();
392
+
393
+ export type CatalogOAuthProfile = z.infer<typeof catalogOAuthProfileSchema>;
394
+
395
+ function originOf(value: string): string {
396
+ return new URL(value).origin;
397
+ }
398
+
399
+ /**
400
+ * Canonical catalog lookup key for an MCP URL, mirroring the importer's
401
+ * `canonicalMcpUrl` (`scripts/catalog-curation.ts`): no fragment, lowercase
402
+ * host, default ports stripped, trailing slashes collapsed. Without this, a
403
+ * trailing-slash or uppercase-host variant of a profiled URL would miss the
404
+ * row and silently fall back to the default profile.
405
+ */
406
+ export function catalogMcpUrlKey(value: string): string {
407
+ const url = new URL(value);
408
+ url.hash = "";
409
+ url.hostname = url.hostname.toLowerCase();
410
+ if (
411
+ (url.protocol === "https:" && url.port === "443") ||
412
+ (url.protocol === "http:" && url.port === "80")
413
+ ) {
414
+ url.port = "";
415
+ }
416
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
417
+ return url.toString();
418
+ }
419
+
420
+ /**
421
+ * Applies a validated catalog profile over the default profile for a row with
422
+ * no built-in. `deployment_managed` from catalog data resolves through the
423
+ * operator clients JSON (issuer-keyed) only; named settings-backed clients
424
+ * remain built-in-only.
425
+ *
426
+ * A present-but-invalid profile fails closed with a 422: the row's operator
427
+ * declared constraints, and silently degrading to the default profile would
428
+ * drop an ownership fence or origin pin on a JSON typo.
429
+ */
430
+ export function oauthProfileFromCatalog(mcpUrl: string, raw: unknown): OAuthProviderProfile {
431
+ const parsed = catalogOAuthProfileSchema.safeParse(raw);
432
+ if (!parsed.success) {
433
+ throw new HTTPException(422, {
434
+ message:
435
+ "this integration's catalog OAuth profile is invalid; re-run the catalog import or fix the curated overlay",
436
+ });
437
+ }
438
+ const data = parsed.data;
439
+ const issuerOrigins = data.pinnedIssuerOrigins?.map(originOf);
440
+ const endpointOrigins = data.pinnedEndpointOrigins?.map(originOf);
441
+ const pins =
442
+ issuerOrigins || endpointOrigins
443
+ ? {
444
+ issuerOrigins: issuerOrigins ?? endpointOrigins ?? [],
445
+ authorizationEndpointOrigins: endpointOrigins ?? issuerOrigins ?? [],
446
+ tokenEndpointOrigins: endpointOrigins ?? issuerOrigins ?? [],
447
+ message: "authorization metadata did not remain bound to the reviewed provider origins",
448
+ skipInLocalTest: false,
449
+ }
450
+ : undefined;
451
+ return {
452
+ key: `catalog:${mcpUrl}`,
453
+ match: { mcpUrls: [mcpUrl] },
454
+ allowedOwnership: data.allowedOwnership ?? DEFAULT_OAUTH_PROFILE.allowedOwnership,
455
+ ...(data.allowedOwnership && !data.allowedOwnership.includes("workspace")
456
+ ? {
457
+ ownershipMessage:
458
+ "this integration allows only personal connections; each workspace member must connect their own account",
459
+ }
460
+ : {}),
461
+ exactMcpBinding: Boolean(data.exactMcpUrl),
462
+ ...(data.exactMcpUrl
463
+ ? {
464
+ requireExactMcpUrl: {
465
+ url: data.exactMcpUrl,
466
+ message: `OAuth for this integration must use ${data.exactMcpUrl}`,
467
+ },
468
+ }
469
+ : {}),
470
+ connectionSelection: "first_active",
471
+ ...(pins ? { authorizationServer: pins } : {}),
472
+ ...(data.clientSource ? { clientSource: data.clientSource } : {}),
473
+ sendResourceParameter: data.sendResourceParameter ?? true,
474
+ ...(data.requestedScopes ? { requestedScopes: data.requestedScopes } : {}),
475
+ ...(data.extraAuthorizeParams ? { extraAuthorizeParams: data.extraAuthorizeParams } : {}),
476
+ };
477
+ }