@zackbart/connecta 0.7.8 → 0.8.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 (187) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +67 -144
  3. package/dist/activity.d.ts +1 -1
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js.map +1 -1
  6. package/dist/auth/bearer.d.ts +1 -1
  7. package/dist/auth/bearer.js +4 -4
  8. package/dist/auth/bearer.js.map +1 -1
  9. package/dist/auth/clerk.d.ts +1 -1
  10. package/dist/auth/clerk.js +1 -1
  11. package/dist/call-admission.d.ts +81 -0
  12. package/dist/call-admission.d.ts.map +1 -0
  13. package/dist/call-admission.js +345 -0
  14. package/dist/call-admission.js.map +1 -0
  15. package/dist/catalog-fingerprint.d.ts +20 -0
  16. package/dist/catalog-fingerprint.d.ts.map +1 -0
  17. package/dist/catalog-fingerprint.js +34 -0
  18. package/dist/catalog-fingerprint.js.map +1 -0
  19. package/dist/catalog-limits.d.ts +10 -0
  20. package/dist/catalog-limits.d.ts.map +1 -0
  21. package/dist/catalog-limits.js +10 -0
  22. package/dist/catalog-limits.js.map +1 -0
  23. package/dist/catalog-service.d.ts +138 -0
  24. package/dist/catalog-service.d.ts.map +1 -0
  25. package/dist/catalog-service.js +407 -0
  26. package/dist/catalog-service.js.map +1 -0
  27. package/dist/catalog.js +2 -2
  28. package/dist/catalog.js.map +1 -1
  29. package/dist/concurrency.d.ts +4 -0
  30. package/dist/concurrency.d.ts.map +1 -0
  31. package/dist/concurrency.js +31 -0
  32. package/dist/concurrency.js.map +1 -0
  33. package/dist/connectors/api.d.ts +3 -1
  34. package/dist/connectors/api.d.ts.map +1 -1
  35. package/dist/connectors/api.js +22 -11
  36. package/dist/connectors/api.js.map +1 -1
  37. package/dist/connectors/remote-mcp.d.ts +3 -1
  38. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  39. package/dist/connectors/remote-mcp.js +44 -18
  40. package/dist/connectors/remote-mcp.js.map +1 -1
  41. package/dist/credential-health.d.ts +0 -22
  42. package/dist/credential-health.d.ts.map +1 -1
  43. package/dist/credential-health.js +14 -10
  44. package/dist/credential-health.js.map +1 -1
  45. package/dist/credentials.d.ts +3 -2
  46. package/dist/credentials.d.ts.map +1 -1
  47. package/dist/errors.d.ts +1 -1
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js.map +1 -1
  50. package/dist/execute.d.ts +9 -8
  51. package/dist/execute.d.ts.map +1 -1
  52. package/dist/execute.js +144 -291
  53. package/dist/execute.js.map +1 -1
  54. package/dist/executor-result.d.ts +0 -3
  55. package/dist/executor-result.d.ts.map +1 -1
  56. package/dist/executor-result.js +2 -2
  57. package/dist/executor-result.js.map +1 -1
  58. package/dist/executors/quickjs-child.js +3 -3
  59. package/dist/executors/quickjs-child.js.map +1 -1
  60. package/dist/executors/quickjs-protocol.d.ts +4 -1
  61. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  62. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  63. package/dist/executors/quickjs-runtime.js +7 -2
  64. package/dist/executors/quickjs-runtime.js.map +1 -1
  65. package/dist/executors/quickjs.d.ts.map +1 -1
  66. package/dist/executors/quickjs.js +4 -1
  67. package/dist/executors/quickjs.js.map +1 -1
  68. package/dist/favicon.js +2 -2
  69. package/dist/favicon.js.map +1 -1
  70. package/dist/index.d.ts +6 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +45 -17
  73. package/dist/index.js.map +1 -1
  74. package/dist/invocation.d.ts +96 -0
  75. package/dist/invocation.d.ts.map +1 -0
  76. package/dist/invocation.js +369 -0
  77. package/dist/invocation.js.map +1 -0
  78. package/dist/meta-tools.d.ts +11 -86
  79. package/dist/meta-tools.d.ts.map +1 -1
  80. package/dist/meta-tools.js +145 -485
  81. package/dist/meta-tools.js.map +1 -1
  82. package/dist/operator-ui/generated.d.ts +3 -0
  83. package/dist/operator-ui/generated.d.ts.map +1 -0
  84. package/dist/operator-ui/generated.js +5 -0
  85. package/dist/operator-ui/generated.js.map +1 -0
  86. package/dist/operator-ui/model.d.ts +94 -0
  87. package/dist/operator-ui/model.d.ts.map +1 -0
  88. package/dist/operator-ui/model.js +28 -0
  89. package/dist/operator-ui/model.js.map +1 -0
  90. package/dist/registry.d.ts +47 -6
  91. package/dist/registry.d.ts.map +1 -1
  92. package/dist/registry.js +356 -35
  93. package/dist/registry.js.map +1 -1
  94. package/dist/routes/activity.d.ts +3 -0
  95. package/dist/routes/activity.d.ts.map +1 -0
  96. package/dist/routes/activity.js +173 -0
  97. package/dist/routes/activity.js.map +1 -0
  98. package/dist/routes/credentials.d.ts +3 -0
  99. package/dist/routes/credentials.d.ts.map +1 -0
  100. package/dist/routes/credentials.js +181 -0
  101. package/dist/routes/credentials.js.map +1 -0
  102. package/dist/routes/mcp.d.ts +8 -0
  103. package/dist/routes/mcp.d.ts.map +1 -0
  104. package/dist/routes/mcp.js +383 -0
  105. package/dist/routes/mcp.js.map +1 -0
  106. package/dist/routes/oauth.d.ts +4 -0
  107. package/dist/routes/oauth.d.ts.map +1 -0
  108. package/dist/routes/oauth.js +268 -0
  109. package/dist/routes/oauth.js.map +1 -0
  110. package/dist/routes/shared.d.ts +97 -0
  111. package/dist/routes/shared.d.ts.map +1 -0
  112. package/dist/routes/shared.js +199 -0
  113. package/dist/routes/shared.js.map +1 -0
  114. package/dist/routes/ui.d.ts +3 -0
  115. package/dist/routes/ui.d.ts.map +1 -0
  116. package/dist/routes/ui.js +122 -0
  117. package/dist/routes/ui.js.map +1 -0
  118. package/dist/server.d.ts +9 -40
  119. package/dist/server.d.ts.map +1 -1
  120. package/dist/server.js +67 -1292
  121. package/dist/server.js.map +1 -1
  122. package/dist/skills.d.ts +0 -15
  123. package/dist/skills.d.ts.map +1 -1
  124. package/dist/skills.js +6 -5
  125. package/dist/skills.js.map +1 -1
  126. package/dist/storage/file.d.ts.map +1 -1
  127. package/dist/storage/file.js +3 -1
  128. package/dist/storage/file.js.map +1 -1
  129. package/dist/storage/memory.d.ts.map +1 -1
  130. package/dist/storage/memory.js +3 -1
  131. package/dist/storage/memory.js.map +1 -1
  132. package/dist/toolkits.d.ts +0 -22
  133. package/dist/toolkits.d.ts.map +1 -1
  134. package/dist/toolkits.js +2 -2
  135. package/dist/toolkits.js.map +1 -1
  136. package/dist/types.d.ts +64 -2
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/ui.d.ts +3 -93
  139. package/dist/ui.d.ts.map +1 -1
  140. package/dist/ui.js +26 -1389
  141. package/dist/ui.js.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/package.json +15 -8
  145. package/src/activity.ts +5 -1
  146. package/src/auth/bearer.ts +4 -4
  147. package/src/auth/clerk.ts +1 -1
  148. package/src/call-admission.ts +522 -0
  149. package/src/catalog-fingerprint.ts +52 -0
  150. package/src/catalog-limits.ts +11 -0
  151. package/src/catalog-service.ts +601 -0
  152. package/src/catalog.ts +2 -2
  153. package/src/concurrency.ts +39 -0
  154. package/src/connectors/api.ts +25 -11
  155. package/src/connectors/remote-mcp.ts +55 -21
  156. package/src/credential-health.ts +13 -10
  157. package/src/credentials.ts +2 -2
  158. package/src/errors.ts +1 -1
  159. package/src/execute.ts +194 -377
  160. package/src/executor-result.ts +2 -2
  161. package/src/executors/quickjs-child.ts +8 -4
  162. package/src/executors/quickjs-protocol.ts +1 -1
  163. package/src/executors/quickjs-runtime.ts +5 -1
  164. package/src/executors/quickjs.ts +11 -8
  165. package/src/favicon.ts +2 -2
  166. package/src/index.ts +54 -17
  167. package/src/invocation.ts +527 -0
  168. package/src/meta-tools.ts +369 -797
  169. package/src/operator-ui/browser.css +413 -0
  170. package/src/operator-ui/browser.ts +1121 -0
  171. package/src/operator-ui/generated.ts +4 -0
  172. package/src/operator-ui/model.ts +127 -0
  173. package/src/registry.ts +489 -44
  174. package/src/routes/activity.ts +220 -0
  175. package/src/routes/credentials.ts +266 -0
  176. package/src/routes/mcp.ts +485 -0
  177. package/src/routes/oauth.ts +328 -0
  178. package/src/routes/shared.ts +330 -0
  179. package/src/routes/ui.ts +153 -0
  180. package/src/server.ts +70 -1702
  181. package/src/skills.ts +6 -5
  182. package/src/storage/file.ts +3 -1
  183. package/src/storage/memory.ts +3 -1
  184. package/src/toolkits.ts +2 -2
  185. package/src/types.ts +70 -2
  186. package/src/ui.ts +52 -1489
  187. package/src/version.ts +1 -1
package/src/server.ts CHANGED
@@ -1,1442 +1,37 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
- import { registerExecuteTool } from "./execute.js";
4
- import { registerMetaTools } from "./meta-tools.js";
5
- import { CONNECTA_INSTRUCTIONS } from "./skills.js";
6
- import type {
7
- ActivityActor,
8
- ActivityPage,
9
- ActivityReadGate,
10
- ActivityReadPage,
11
- ActivityRequestContext,
12
- ActivityStore,
13
- } from "./activity.js";
14
- import { InvalidActivityCursorError } from "./activity.js";
1
+ import { isAdmittingExecutor } from "./executor-admission.js";
2
+ import { routeActivity } from "./routes/activity.js";
3
+ import { routeCredentials } from "./routes/credentials.js";
4
+ import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
15
5
  import {
16
- credentialTestRule,
17
- describeCredentialTestMismatch,
18
- storedCredentialShape,
19
- } from "./credentials.js";
20
- import type { CredentialVault } from "./credentials.js";
21
- import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
6
+ routeOAuthCallback,
7
+ routeOAuthManagement,
8
+ } from "./routes/oauth.js";
22
9
  import {
23
- resolveIdentityBinding,
24
- TOOLKIT_NAME_RE,
25
- type Toolkit,
26
- } from "./toolkits.js";
27
- import type {
28
- ConnectorContext,
29
- ConnectorCredentialConfig,
30
- ConnectorCredentialValues,
31
- ConnectorStatus,
32
- ConnectaBranding,
33
- Executor,
34
- InboundAuth,
35
- Logger,
36
- ToolkitBinding,
37
- } from "./types.js";
38
- import { CONNECTA_FAVICON_ICO } from "./favicon.js";
39
- import {
40
- ExecutorAdmissionError,
41
- isAdmittingExecutor,
42
- type AdmissionController,
43
- type AdmissionLease,
44
- } from "./executor-admission.js";
45
- import {
46
- buildUiData,
47
- CONNECTA_FAVICON_SVG,
48
- credentialManagementCapability,
49
- isSafeHttpUrl,
50
- operatorPageForPath,
51
- resolveBranding,
52
- renderUiHtml,
53
- } from "./ui.js";
54
- import { oauthValueStorageKey } from "./auth/downstream-oauth.js";
55
- import {
56
- closeConnectorScope,
57
- type DeferredWork,
58
- } from "./connector-scope.js";
59
-
60
- const CORS_HEADERS = {
61
- "Access-Control-Allow-Origin": "*",
62
- "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
63
- "Access-Control-Allow-Headers":
64
- "Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
65
- };
66
-
67
- /**
68
- * Headers that make an operator-supplied favicon body inert on this origin.
69
- * The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
70
- * so a `<script>` inside a branding SVG would run on the deployment origin the
71
- * moment anyone navigated straight to `/favicon.svg` — strictly more powerful
72
- * than the `favicon.href` vector the branding gates close, because the payload
73
- * is same-origin. Neutralizing the response rather than inspecting the body
74
- * keeps every valid static SVG (the built-in mark included) byte-identical:
75
- *
76
- * - `sandbox` (no tokens ⇒ every restriction) drops the document into an opaque
77
- * origin with scripting off, so even a script that ran would have nothing to
78
- * reach.
79
- * - `default-src 'none'` denies script, network, and framing outright.
80
- * - `style-src 'unsafe-inline'` is the single allowance: the default mark styles
81
- * itself inline to follow the OS colour scheme, and CSS cannot script.
82
- * - `nosniff` keeps the declared type authoritative in both directions — an SVG
83
- * can never be re-read as HTML, and `.ico` bytes can never be re-read as SVG.
84
- *
85
- * `.ico` bodies are deliberately in scope: they are inert bytes rather than
86
- * active content, so they are still served verbatim, but they carry the same
87
- * headers so the invariant is "every favicon route is neutralized" rather than
88
- * "whichever route got attention".
89
- */
90
- const INERT_ICON_HEADERS = {
91
- "Content-Security-Policy":
92
- "default-src 'none'; style-src 'unsafe-inline'; sandbox",
93
- "X-Content-Type-Options": "nosniff",
94
- };
95
-
96
- export interface ServerOptions {
97
- registry: Registry;
98
- auth: InboundAuth[];
99
- publicUrl?: string;
100
- // The SDK's Implementation shape: name/version plus optional title,
101
- // websiteUrl, and icons (MCP icons spec) that clients may render.
102
- serverInfo: ConstructorParameters<typeof McpServer>[0];
103
- logger: Logger;
104
- activity?: ActivityStore;
105
- activityReadGate?: ActivityReadGate;
106
- activityDeploymentId?: string;
107
- deploymentInfo?: Record<string, unknown>;
108
- /** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
109
- defaultToolTimeoutMs?: number;
110
- /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
111
- probeTimeoutMs?: number;
112
- /** When set, the execute_code meta-tool is registered on top of the nine. */
113
- executor?: Executor;
114
- /** Global FIFO boundary for all non-preflight `/mcp` requests. */
115
- requestAdmission: AdmissionController;
116
- /** Encrypted connector-credential storage backing the Credentials page. */
117
- credentialVault?: CredentialVault;
118
- /** Optional browser UI and OAuth result-page labels. */
119
- branding?: ConnectaBranding;
120
- /**
121
- * Validated named scopes, selected per connection with `?toolkit=<name>` on
122
- * `/mcp`. Omit (or leave empty) and every connection sees the full registry.
123
- */
124
- toolkits?: ReadonlyMap<string, Toolkit>;
125
- }
126
-
127
- function msg(err: unknown): string {
128
- return err instanceof Error ? err.message : String(err);
129
- }
130
-
131
- /** Per-request base64 nonce for an operator shell's scripts (Node 20+ and Workers). */
132
- function uiScriptNonce(): string {
133
- const bytes = crypto.getRandomValues(new Uint8Array(16));
134
- let binary = "";
135
- for (const byte of bytes) binary += String.fromCharCode(byte);
136
- return btoa(binary);
137
- }
138
-
139
- function escapeHtml(s: string): string {
140
- return s
141
- .replaceAll("&", "&amp;")
142
- .replaceAll("<", "&lt;")
143
- .replaceAll(">", "&gt;")
144
- .replaceAll('"', "&quot;");
145
- }
146
-
147
- /** `body` is escaped — callback params and error messages are attacker-influenced. */
148
- function html(
149
- body: string,
150
- status = 200,
151
- branding?: ConnectaBranding,
152
- ): Response {
153
- const brand = resolveBranding(branding);
154
- const title = brand.pageTitle;
155
- const owner = brand.ownerName
156
- ? brand.ownerUrl
157
- ? `<a class="brand" href="${escapeHtml(brand.ownerUrl)}">${escapeHtml(brand.ownerName)}</a>`
158
- : `<span class="brand">${escapeHtml(brand.ownerName)}</span>`
159
- : brand.productUrl
160
- ? `<a class="brand" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
161
- : `<span class="brand">${escapeHtml(brand.productName)}</span>`;
162
- const product = brand.ownerName
163
- ? brand.productUrl
164
- ? `<a class="product" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
165
- : `<span class="product">${escapeHtml(brand.productName)}</span>`
166
- : "";
167
- return new Response(
168
- `<!doctype html>
169
- <html lang="en">
170
- <head>
171
- <meta charset="utf-8">
172
- <meta name="viewport" content="width=device-width,initial-scale=1">
173
- <meta name="theme-color" content="${escapeHtml(brand.themeColor)}">
174
- <link rel="icon" href="${escapeHtml(brand.faviconHref)}" type="image/svg+xml">
175
- <link rel="shortcut icon" href="/favicon.ico">
176
- <title>${escapeHtml(title)}</title>
177
- <style>
178
- * { border-radius: 0; box-sizing: border-box; }
179
- html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
180
- Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
181
- body { margin: 0; min-height: 100vh; }
182
- ::selection { color: #fff; background: #000; }
183
- .shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
184
- .grid { display: grid; gap: 1rem 1.5rem;
185
- grid-template-columns: repeat(3, minmax(0, 1fr)); }
186
- .brand { font-weight: 500; grid-column: 1; text-decoration: none; }
187
- .product { grid-column: 2 / -1; }
188
- main { margin-top: 5rem; }
189
- h1, p { font: inherit; margin: 0; }
190
- h1 { grid-column: 1; }
191
- .copy { grid-column: 2 / -1; max-width: 34em; }
192
- .copy > * + * { margin-top: 1.5rem; }
193
- a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
194
- text-underline-offset: .22em; }
195
- a:hover { text-decoration-color: transparent; }
196
- a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
197
- @media (max-width: 36.99rem) {
198
- .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
199
- .product { grid-column: 2; }
200
- main { margin-top: 3rem; }
201
- h1, .copy { grid-column: 1 / -1; }
202
- }
203
- </style>
204
- </head>
205
- <body>
206
- <header class="shell grid">
207
- ${owner}
208
- ${product}
209
- </header>
210
- <main class="shell grid">
211
- <h1>Connection status</h1>
212
- <div class="copy">
213
- <p>${escapeHtml(body)}</p>
214
- <p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
215
- </div>
216
- </main>
217
- </body>
218
- </html>`,
219
- { status, headers: { "Content-Type": "text/html; charset=utf-8" } },
220
- );
221
- }
222
-
223
- /**
224
- * Refusal for an identity whose toolkit binding cannot be trusted — a malformed
225
- * declaration, or a malformed per-identity binding out of `authorize`. The
226
- * caller is authenticated, so this is a 403, and it is deliberately opaque: the
227
- * cause is an operator bug, and the operator reads it in the log, not the client.
228
- */
229
- function unusableBinding(): Response {
230
- return privateJson({ error: "forbidden" }, { status: 403 });
231
- }
232
-
233
- async function authorize(
234
- request: Request,
235
- baseUrl: string,
236
- auth: InboundAuth[],
237
- logger: Logger,
238
- ): Promise<
239
- | {
240
- ok: true;
241
- actor: ActivityActor;
242
- /** True only when the admitting provider can also authorize UI mutation. */
243
- uiAdminEligible?: boolean;
244
- /** The admitting identity's toolkit binding (docs/toolkits.md). */
245
- toolkitBinding?: ToolkitBinding;
246
- }
247
- | { ok: false; response: Response }
248
- > {
249
- if (auth.length === 0) {
250
- return { ok: true, actor: { kind: "anonymous" } };
251
- }
252
- let lastResponse: Response | null = null;
253
- for (const provider of auth) {
254
- const result = await provider.authorize(request, baseUrl);
255
- if (result.ok) {
256
- const subjectId = result.subjectId ?? result.userId;
257
- // Re-validate both halves and cap the per-identity one by the provider's
258
- // declaration (see resolveIdentityBinding). A binding that does not
259
- // type-check at runtime refuses the request rather than evaporating:
260
- // dropping it would hand the caller the full registry, which is the one
261
- // outcome a binding exists to prevent.
262
- const binding = resolveIdentityBinding(
263
- provider.toolkitBinding,
264
- result.toolkitBinding,
265
- );
266
- if (!binding.ok) {
267
- logger.warn(
268
- `[connecta] refused a request admitted by inbound auth provider ` +
269
- `"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
270
- "this provider cannot admit anyone, because connecta cannot tell " +
271
- "which toolkits the identity may use.",
272
- );
273
- return { ok: false, response: unusableBinding() };
274
- }
275
- const actorNamespace = activityActorNamespace(provider);
276
- return {
277
- ok: true,
278
- actor: {
279
- kind: provider.kind,
280
- ...(subjectId ? { id: subjectId } : {}),
281
- ...(subjectId && actorNamespace
282
- ? { namespace: actorNamespace }
283
- : {}),
284
- },
285
- ...(result.userId && provider.uiAuth?.kind === "clerk"
286
- ? { uiAdminEligible: true }
287
- : {}),
288
- ...(binding.binding ? { toolkitBinding: binding.binding } : {}),
289
- };
290
- }
291
- lastResponse = result.response;
292
- }
293
- return {
294
- ok: false,
295
- response:
296
- lastResponse ??
297
- new Response(JSON.stringify({ error: "unauthorized" }), {
298
- status: 401,
299
- headers: {
300
- "Content-Type": "application/json",
301
- "WWW-Authenticate": "Bearer",
302
- },
303
- }),
304
- };
305
- }
306
-
307
- function privateJson(body: unknown, init: ResponseInit = {}): Response {
308
- const headers = new Headers(init.headers);
309
- headers.set("Content-Type", "application/json");
310
- headers.set("Cache-Control", "no-store");
311
- headers.set("Referrer-Policy", "no-referrer");
312
- return new Response(JSON.stringify(body), { ...init, headers });
313
- }
314
-
315
- // Browser-based MCP clients call /mcp cross-origin. Without CORS on every
316
- // response — errors included — the browser hides the 401, the client cannot
317
- // read WWW-Authenticate, and OAuth discovery silently never starts.
318
- function withMcpCors(response: Response): Response {
319
- const headers = new Headers(response.headers);
320
- for (const [name, value] of Object.entries(CORS_HEADERS)) {
321
- headers.set(name, value);
322
- }
323
- headers.set(
324
- "Access-Control-Expose-Headers",
325
- "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version",
326
- );
327
- return new Response(response.body, {
328
- status: response.status,
329
- statusText: response.statusText,
330
- headers,
331
- });
332
- }
333
-
334
- function requestAdmissionFailure(error: ExecutorAdmissionError): Response {
335
- const overloaded = error.code === "executor_overloaded";
336
- const data = {
337
- code: overloaded ? "server_overloaded" : "server_shutting_down",
338
- retryable: overloaded,
339
- ...(overloaded && error.retryAfterMs !== undefined
340
- ? { retryAfterMs: error.retryAfterMs }
341
- : {}),
342
- };
343
- const headers = new Headers({
344
- "Content-Type": "application/json",
345
- "Cache-Control": "no-store",
346
- });
347
- if (overloaded && error.retryAfterMs !== undefined) {
348
- headers.set(
349
- "Retry-After",
350
- String(Math.max(1, Math.ceil(error.retryAfterMs / 1_000))),
351
- );
352
- }
353
- return new Response(
354
- JSON.stringify({
355
- jsonrpc: "2.0",
356
- id: null,
357
- error: {
358
- code: overloaded ? -32001 : -32002,
359
- message: overloaded
360
- ? "Server capacity is exhausted. Retry later."
361
- : "Server is shutting down.",
362
- data,
363
- },
364
- }),
365
- { status: 503, headers },
366
- );
367
- }
368
-
369
- /**
370
- * A request owns its permit through the response body, not merely until the
371
- * handler returns. This is what makes slow clients and response-stream failure
372
- * part of the same bounded lifecycle as success, error, and cancellation.
373
- */
374
- function releaseAdmissionWithResponse(
375
- response: Response,
376
- lease: AdmissionLease,
377
- signal: AbortSignal,
378
- ): Response {
379
- let released = false;
380
- let onAbort = () => {};
381
- const release = () => {
382
- if (released) return;
383
- released = true;
384
- signal.removeEventListener("abort", onAbort);
385
- lease.release();
386
- };
387
- if (!response.body) {
388
- release();
389
- return response;
390
- }
391
- const reader = response.body.getReader();
392
- onAbort = () => {
393
- // `cancel()` belongs to an operator/auth/SDK-provided stream and may
394
- // reject. Consume both outcomes: `.finally(release)` would release the
395
- // permit but preserve the rejection as an unhandled promise.
396
- void reader.cancel(signal.reason).then(release, release);
397
- };
398
- signal.addEventListener("abort", onAbort, { once: true });
399
- if (signal.aborted) onAbort();
400
- const body = new ReadableStream<Uint8Array>({
401
- async pull(controller) {
402
- try {
403
- const next = await reader.read();
404
- if (next.done) {
405
- release();
406
- controller.close();
407
- } else {
408
- controller.enqueue(next.value);
409
- }
410
- } catch (error) {
411
- release();
412
- controller.error(error);
413
- }
414
- },
415
- async cancel(reason) {
416
- try {
417
- await reader.cancel(reason);
418
- } finally {
419
- release();
420
- }
421
- },
422
- });
423
- return new Response(body, {
424
- status: response.status,
425
- statusText: response.statusText,
426
- headers: response.headers,
427
- });
428
- }
429
-
430
- function withSecurityHeaders(
431
- response: Response,
432
- requestUrl: URL,
433
- path: string,
434
- ): Response {
435
- const headers = new Headers(response.headers);
436
- headers.set("X-Content-Type-Options", "nosniff");
437
- headers.set("Referrer-Policy", "no-referrer");
438
- if (requestUrl.protocol === "https:") {
439
- headers.set("Strict-Transport-Security", "max-age=31536000");
440
- }
441
- if (operatorPageForPath(path) || path === "/ui") {
442
- // Operator HTML responses ship their own nonce-based script CSP (which
443
- // already includes frame-ancestors 'none'); only fall back to the
444
- // framing-only directive when no CSP is present (for example redirects).
445
- if (!headers.has("Content-Security-Policy")) {
446
- headers.set("Content-Security-Policy", "frame-ancestors 'none'");
447
- }
448
- headers.set("X-Frame-Options", "DENY");
449
- }
450
- return new Response(response.body, {
451
- status: response.status,
452
- statusText: response.statusText,
453
- headers,
454
- });
455
- }
456
-
457
- async function authorizeUiAdmin(
458
- request: Request,
459
- baseUrl: string,
460
- auth: InboundAuth[],
461
- logger: Logger,
462
- purpose = "credential management",
463
- ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
464
- // Operator mutation is intentionally narrower than /mcp and /ui/data: only
465
- // an interactive Clerk provider may admit it. A static bearer token is useful
466
- // for headless tool calls but must not become a deployment-admin key.
467
- //
468
- // EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
469
- // documented per-team pattern is several `clerkAuth(...)`s that differ only in
470
- // `gate` and `toolkits` (docs/toolkits.md). Stopping at the first would make
471
- // admission depend on config order: the team-bound provider listed first
472
- // would refuse the operator outright, and a refusal here — a failed gate, a
473
- // missing user, a toolkit-bound identity — is exactly the case where a later
474
- // provider is the one meant to admit. The last refusal is returned if none do.
475
- const providers = auth.filter(
476
- (candidate) => candidate.uiAuth?.kind === "clerk",
477
- );
478
- if (providers.length === 0) {
479
- return {
480
- ok: false,
481
- response: privateJson(
482
- { error: `${purpose} requires Clerk authentication` },
483
- { status: 403 },
484
- ),
485
- };
486
- }
487
- let lastResponse: Response | null = null;
488
- for (const provider of providers) {
489
- const result = await provider.authorize(request, baseUrl);
490
- if (!result.ok) {
491
- lastResponse = result.response;
492
- continue;
493
- }
494
- if (!result.userId) {
495
- lastResponse = privateJson(
496
- { error: "authenticated user required" },
497
- { status: 403 },
498
- );
499
- continue;
500
- }
501
- const binding = resolveIdentityBinding(
502
- provider.toolkitBinding,
503
- result.toolkitBinding,
504
- );
505
- if (!binding.ok) {
506
- logger.warn(
507
- `[connecta] refused an operator-mutation request admitted by inbound auth ` +
508
- `provider "${provider.kind}" with 403: ${binding.reason}.`,
509
- );
510
- lastResponse = unusableBinding();
511
- continue;
512
- }
513
- // A toolkit-bound identity is a team's credential, not a vault admin key:
514
- // credentials are deployment-wide, so writing one reaches every toolkit.
515
- if (isToolkitRestricted(binding.binding)) {
516
- lastResponse = restrictedOperatorSurface();
517
- continue;
518
- }
519
- return { ok: true, userId: result.userId };
520
- }
521
- return {
522
- ok: false,
523
- response:
524
- lastResponse ??
525
- privateJson({ error: "forbidden" }, { status: 403 }),
526
- };
527
- }
528
-
529
- function isSameOrigin(request: Request, baseUrl: string): boolean {
530
- const origin = request.headers.get("origin");
531
- if (!origin) return false;
532
- try {
533
- return new URL(origin).origin === new URL(baseUrl).origin;
534
- } catch {
535
- return false;
536
- }
537
- }
538
-
539
- type CredentialInput =
540
- | { kind: "single"; value: string }
541
- | { kind: "multiple"; values: ConnectorCredentialValues };
542
-
543
- async function readCredentialInput(
544
- request: Request,
545
- config: ConnectorCredentialConfig,
546
- ): Promise<
547
- { ok: true; input: CredentialInput } | { ok: false; response: Response }
548
- > {
549
- if (
550
- !request.headers
551
- .get("content-type")
552
- ?.toLowerCase()
553
- .startsWith("application/json")
554
- ) {
555
- return {
556
- ok: false,
557
- response: privateJson(
558
- { error: "Content-Type must be application/json" },
559
- { status: 415 },
560
- ),
561
- };
562
- }
563
- const raw = await request.text();
564
- if (raw.length > 20_000) {
565
- return {
566
- ok: false,
567
- response: privateJson(
568
- { error: "request body is too large" },
569
- { status: 413 },
570
- ),
571
- };
572
- }
573
- try {
574
- const body = JSON.parse(raw) as {
575
- value?: unknown;
576
- values?: unknown;
577
- };
578
- if (!config.fields?.length) {
579
- if (typeof body.value !== "string" || !body.value.trim()) {
580
- return {
581
- ok: false,
582
- response: privateJson(
583
- { error: "value must be a non-empty string" },
584
- { status: 400 },
585
- ),
586
- };
587
- }
588
- return { ok: true, input: { kind: "single", value: body.value } };
589
- }
590
- if (
591
- !body.values ||
592
- typeof body.values !== "object" ||
593
- Array.isArray(body.values)
594
- ) {
595
- return {
596
- ok: false,
597
- response: privateJson(
598
- { error: "values must be an object" },
599
- { status: 400 },
600
- ),
601
- };
602
- }
603
- const rawValues = body.values as Record<string, unknown>;
604
- const expected = new Set(config.fields.map((field) => field.name));
605
- const unexpected = Object.keys(rawValues).find(
606
- (field) => !expected.has(field),
607
- );
608
- if (unexpected) {
609
- return {
610
- ok: false,
611
- response: privateJson(
612
- { error: `unexpected credential field "${unexpected}"` },
613
- { status: 400 },
614
- ),
615
- };
616
- }
617
- const values: ConnectorCredentialValues = {};
618
- for (const field of config.fields) {
619
- const value = rawValues[field.name];
620
- if (typeof value !== "string" || !value.trim()) {
621
- return {
622
- ok: false,
623
- response: privateJson(
624
- { error: `${field.name} must be a non-empty string` },
625
- { status: 400 },
626
- ),
627
- };
628
- }
629
- values[field.name] = value;
630
- }
631
- return { ok: true, input: { kind: "multiple", values } };
632
- } catch {
633
- return {
634
- ok: false,
635
- response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
636
- };
637
- }
638
- }
639
-
640
- async function handleCredentialRequest(
641
- request: Request,
642
- connectorId: string,
643
- action: string | undefined,
644
- opts: ServerOptions,
645
- baseUrl: string,
646
- ): Promise<Response> {
647
- if (!opts.credentialVault) {
648
- return privateJson(
649
- { error: "credential storage is not configured" },
650
- { status: 503 },
651
- );
652
- }
653
- if (!isSameOrigin(request, baseUrl)) {
654
- return privateJson(
655
- { error: "same-origin request required" },
656
- { status: 403 },
657
- );
658
- }
659
- const admin = await authorizeUiAdmin(
660
- request,
661
- baseUrl,
662
- opts.auth,
663
- opts.logger,
664
- );
665
- if (!admin.ok) return admin.response;
666
-
667
- const connector = opts.registry.getConnector(connectorId);
668
- if (!connector?.credential) {
669
- return privateJson({ error: "unknown credential slot" }, { status: 404 });
670
- }
671
-
672
- if (action === "test") {
673
- if (request.method !== "POST") {
674
- return privateJson({ error: "method not allowed" }, { status: 405 });
675
- }
676
- // The declared credential shape picks the hook — the same single rule the
677
- // Credentials page asks for its Test affordance, so a shown button reaches
678
- // that reads the shape the credential was stored in.
679
- const rule = credentialTestRule(connector);
680
- if (!rule.mode) {
681
- return privateJson(
682
- {
683
- error: rule.mismatch
684
- ? "this connector cannot test its credential: " +
685
- describeCredentialTestMismatch(rule.mismatch)
686
- : "this connector does not support credential testing",
687
- },
688
- { status: 400 },
689
- );
690
- }
691
- try {
692
- const values = await opts.credentialVault.getAll(connectorId);
693
- const shape = storedCredentialShape(connector.credential, values);
694
- if (shape.state === "missing") {
695
- return privateJson(
696
- {
697
- error:
698
- rule.mode === "multiple"
699
- ? "configure the credentials before testing them"
700
- : "configure the credential before testing it",
701
- },
702
- { status: 409 },
703
- );
704
- }
705
- if (shape.state === "mismatch") {
706
- return privateJson({ error: shape.message }, { status: 409 });
707
- }
708
- const storedValues = values!;
709
- const ctx = opts.registry.contextFor(connectorId, baseUrl);
710
- let result;
711
- if (rule.mode === "multiple") {
712
- result = await connector.testCredentials!(storedValues, ctx);
713
- } else {
714
- result = await connector.testCredential!(storedValues.value, ctx);
715
- }
716
- // The operator just ran the very check the liveness sweep runs; record it
717
- // so cached status surfaces agree with what the operator page showed.
718
- await opts.registry.recordCredentialHealth(connectorId, {
719
- state: result.ok ? "ok" : "auth_required",
720
- checkedAt: new Date().toISOString(),
721
- ...(result.message ? { message: result.message } : {}),
722
- });
723
- return privateJson(result);
724
- } catch (err) {
725
- return privateJson({ ok: false, message: msg(err) });
726
- }
727
- }
728
-
729
- if (action) {
730
- return privateJson({ error: "not found" }, { status: 404 });
731
- }
732
-
733
- if (request.method === "PUT") {
734
- const input = await readCredentialInput(request, connector.credential);
735
- if (!input.ok) return input.response;
736
- try {
737
- const metadata =
738
- input.input.kind === "single"
739
- ? await opts.credentialVault.set(
740
- connectorId,
741
- input.input.value,
742
- admin.userId,
743
- )
744
- : await opts.credentialVault.setAll(
745
- connectorId,
746
- input.input.values,
747
- admin.userId,
748
- );
749
- await opts.registry.invalidateStored(connectorId);
750
- // The credential the last verdict judged is gone; judging its replacement
751
- // is the next check's job, not this one's.
752
- await opts.registry.clearCredentialHealth(connectorId);
753
- return privateJson({ credential: metadata });
754
- } catch (err) {
755
- return privateJson({ error: msg(err) }, { status: 400 });
756
- }
757
- }
758
-
759
- if (request.method === "DELETE") {
760
- await opts.credentialVault.delete(connectorId);
761
- await opts.registry.invalidateStored(connectorId);
762
- await opts.registry.clearCredentialHealth(connectorId);
763
- return new Response(null, {
764
- status: 204,
765
- headers: {
766
- "Cache-Control": "no-store",
767
- "Referrer-Policy": "no-referrer",
768
- },
769
- });
770
- }
771
-
772
- return privateJson({ error: "method not allowed" }, { status: 405 });
773
- }
774
-
775
- async function handleOAuthManagementRequest(
776
- request: Request,
777
- connectorId: string,
778
- opts: ServerOptions,
779
- baseUrl: string,
780
- defer?: DeferredWork,
781
- ): Promise<Response> {
782
- if (!isSameOrigin(request, baseUrl)) {
783
- return privateJson(
784
- { error: "same-origin request required" },
785
- { status: 403 },
786
- );
787
- }
788
- const admin = await authorizeUiAdmin(
789
- request,
790
- baseUrl,
791
- opts.auth,
792
- opts.logger,
793
- "OAuth management",
794
- );
795
- if (!admin.ok) return admin.response;
796
-
797
- const connector = opts.registry.getConnector(connectorId);
798
- if (!connector?.disconnectAuth || !connector.startAuth) {
799
- return privateJson(
800
- { error: "unknown OAuth connector" },
801
- { status: 404 },
802
- );
803
- }
804
- if (request.method !== "DELETE" && request.method !== "POST") {
805
- return privateJson({ error: "method not allowed" }, { status: 405 });
806
- }
807
-
808
- const requestScope = {};
809
- const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope);
810
- try {
811
- let result: ConnectorStatus | undefined;
812
- let operationError: unknown;
813
- try {
814
- if (request.method === "DELETE") {
815
- await connector.disconnectAuth(ctx);
816
- } else {
817
- result = await connector.startAuth(ctx, { force: true });
818
- }
819
- } catch (error) {
820
- operationError = error;
821
- }
822
-
823
- // The old grant and its catalog verdict are invalid after either operation,
824
- // including a partially failed physical cleanup whose epoch fence succeeded.
825
- try {
826
- await opts.registry.invalidateStored(connectorId);
827
- await opts.registry.clearCredentialHealth(connectorId);
828
- } catch (error) {
829
- operationError ??= error;
830
- }
831
- if (operationError) {
832
- return privateJson({ error: msg(operationError) }, { status: 400 });
833
- }
834
-
835
- if (request.method === "DELETE") {
836
- return new Response(null, {
837
- status: 204,
838
- headers: {
839
- "Cache-Control": "no-store",
840
- "Referrer-Policy": "no-referrer",
841
- },
842
- });
843
- }
844
-
845
- const authorizationUrl = isSafeHttpUrl(result!.authorizationUrl)
846
- ? result!.authorizationUrl
847
- : undefined;
848
- if (result!.state === "error") {
849
- return privateJson(
850
- { error: result!.message || "OAuth authorization could not start" },
851
- { status: 502 },
852
- );
853
- }
854
- if (result!.state === "auth_required" && !authorizationUrl) {
855
- return privateJson(
856
- {
857
- error:
858
- result!.message ||
859
- "OAuth authorization requires consent but no safe URL is available",
860
- },
861
- { status: 502 },
862
- );
863
- }
864
- return privateJson({
865
- state: result!.state,
866
- ...(result!.message ? { message: result!.message } : {}),
867
- ...(authorizationUrl ? { authorizationUrl } : {}),
868
- });
869
- } finally {
870
- await closeConnectorScope(connector, ctx, defer);
871
- }
872
- }
873
-
874
- /** Length beyond which a rejected toolkit name is not echoed back. */
875
- const MAX_ECHOED_TOOLKIT_NAME = 64;
876
-
877
- /** What one MCP connection may see: the full registry, or one toolkit's view. */
878
- interface McpScope {
879
- registry: RegistryView;
880
- /** Set only under `?toolkit=`; recorded on activity events. */
881
- toolkitId?: string;
882
- }
883
-
884
- /**
885
- * Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
886
- * identity id, or OAuth callback connector id) for the operator log. Goes
887
- * through JSON.stringify so a caller-controlled newline or control character
888
- * cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
889
- * JSON.stringify leaves raw even though a log reader treats them as line
890
- * terminators. Truncated to a small shared cap (also the toolkit response's echo
891
- * limit), so an oversized value cannot flood the log either.
892
- */
893
- function loggableValue(requested: string): string {
894
- const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
895
- const escaped = JSON.stringify(bounded).replace(
896
- /[\u2028\u2029]/g,
897
- (ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
898
- );
899
- return escaped + (bounded.length < requested.length ? " (truncated)" : "");
900
- }
901
-
902
- /** The one refusal a bound identity ever sees. Constant on purpose — see below. */
903
- const TOOLKIT_FORBIDDEN_BODY = JSON.stringify({
904
- jsonrpc: "2.0",
905
- id: null,
906
- error: {
907
- code: -32600,
908
- message:
909
- "Not permitted to use the requested toolkit. This credential is bound " +
910
- "to a specific toolkit — check the ?toolkit= value in this deployment's " +
911
- "MCP endpoint URL with the operator.",
912
- },
913
- });
10
+ withSecurityHeaders,
11
+ type RouteContext,
12
+ type RuntimeExecutionContext,
13
+ type ServerOptions,
14
+ } from "./routes/shared.js";
15
+ import { routeUi } from "./routes/ui.js";
914
16
 
915
- /**
916
- * 403 for every binding refusal, with a body that does not depend on WHY.
917
- *
918
- * A bound identity asking for a toolkit it may not open, for a toolkit that does
919
- * not exist, or for no toolkit at all gets byte-identical responses, so a team
920
- * credential cannot be used to enumerate the org's other teams — the boundary
921
- * would leak the very structure it exists to hide. The operator log below is
922
- * where the three cases are told apart.
923
- */
924
- function toolkitForbidden(): Response {
925
- return new Response(TOOLKIT_FORBIDDEN_BODY, {
926
- status: 403,
927
- headers: {
928
- "Content-Type": "application/json",
929
- "Cache-Control": "no-store",
930
- },
931
- });
932
- }
933
-
934
- /** How a rejected connection is named in the operator log. */
935
- function identityLabel(actor: ActivityActor): string {
936
- return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
937
- }
938
-
939
- const ACTIVITY_ACTOR_NAMESPACE_RE = /^[\x21-\x7e]{1,256}$/;
940
- const ACTIVITY_LABEL_CONCURRENCY = 8;
941
- const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
942
- const ACTIVITY_LABEL_MAX_LENGTH = 160;
943
-
944
- function activityActorNamespace(
945
- provider: InboundAuth,
946
- ): string | undefined {
947
- return typeof provider.activityActorNamespace === "string" &&
948
- ACTIVITY_ACTOR_NAMESPACE_RE.test(provider.activityActorNamespace)
949
- ? provider.activityActorNamespace
950
- : undefined;
951
- }
952
-
953
- function cleanActivityActorLabel(value: unknown): string | undefined {
954
- if (typeof value !== "string") return undefined;
955
- const compact = value.replace(/\s+/gu, " ").trim();
956
- if (!compact) return undefined;
957
- return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
958
- }
959
-
960
- async function boundedActivityActorLabel(
961
- hook: NonNullable<InboundAuth["activityActorLabel"]>,
962
- id: string,
963
- budgetMs: number,
964
- ): Promise<string | undefined> {
965
- let timer: number | undefined;
966
- try {
967
- return await Promise.race([
968
- Promise.resolve(hook(id))
969
- .then(cleanActivityActorLabel)
970
- .catch(() => undefined),
971
- new Promise<undefined>((resolve) => {
972
- timer = setTimeout(resolve, budgetMs);
973
- }),
974
- ]);
975
- } catch {
976
- return undefined;
977
- } finally {
978
- if (timer !== undefined) clearTimeout(timer);
979
- }
980
- }
17
+ export type { ServerOptions } from "./routes/shared.js";
981
18
 
982
19
  /**
983
- * Add display-only actor labels to one authorized activity page. Resolution is
984
- * best-effort, bounded, and read-time only: stored events retain stable ids and
985
- * a profile-provider outage falls back to those ids without failing the page.
986
- */
987
- async function enrichActivityActorLabels(
988
- page: ActivityPage,
989
- auth: readonly InboundAuth[],
990
- ): Promise<ActivityReadPage> {
991
- const identities = new Map<
992
- string,
993
- { kind: string; id: string; namespace?: string }
994
- >();
995
- for (const event of page.events) {
996
- if (!event.actor.id) continue;
997
- identities.set(JSON.stringify([
998
- event.actor.kind,
999
- event.actor.namespace,
1000
- event.actor.id,
1001
- ]), {
1002
- kind: event.actor.kind,
1003
- id: event.actor.id,
1004
- ...(event.actor.namespace
1005
- ? { namespace: event.actor.namespace }
1006
- : {}),
1007
- });
1008
- }
1009
- const queue = [...identities.entries()];
1010
- const labels = new Map<string, string>();
1011
- let next = 0;
1012
- const deadline = Date.now() + ACTIVITY_LABEL_PAGE_BUDGET_MS;
1013
- const workers = Array.from(
1014
- { length: Math.min(ACTIVITY_LABEL_CONCURRENCY, queue.length) },
1015
- async () => {
1016
- while (next < queue.length) {
1017
- const [key, identity] = queue[next++];
1018
- const sameKindProviders = auth
1019
- .map((provider, index) => ({ provider, index }))
1020
- .filter(({ provider }) => provider.kind === identity.kind);
1021
- const candidates = sameKindProviders.filter(({ provider }) =>
1022
- Boolean(provider.activityActorLabel),
1023
- );
1024
- const eligible = identity.namespace
1025
- ? candidates.filter(
1026
- ({ provider }) =>
1027
- activityActorNamespace(provider) === identity.namespace,
1028
- )
1029
- : (() => {
1030
- const directoryKey = ({
1031
- provider,
1032
- index,
1033
- }: (typeof sameKindProviders)[number]) => {
1034
- const namespace = activityActorNamespace(provider);
1035
- return namespace === undefined
1036
- ? `provider:${index}`
1037
- : `namespace:${namespace}`;
1038
- };
1039
- // Every same-kind provider participates in the ambiguity check,
1040
- // even if it cannot resolve labels. Otherwise a legacy ID owned
1041
- // by a provider without a resolver could be disclosed to a
1042
- // different provider that happens to have one.
1043
- const directories = new Set(
1044
- sameKindProviders.map(directoryKey),
1045
- );
1046
- if (directories.size !== 1) return [];
1047
- const [directory] = directories;
1048
- return candidates.filter(
1049
- (candidate) => directoryKey(candidate) === directory,
1050
- );
1051
- })();
1052
- // One namespace is one directory. Use its first configured resolver so
1053
- // duplicate gate adapters over the same Clerk instance do not multiply
1054
- // the provider-level concurrency cap.
1055
- const provider = eligible[0]?.provider;
1056
- if (!provider) continue;
1057
- const remaining = deadline - Date.now();
1058
- if (remaining <= 0) return;
1059
- const label = await boundedActivityActorLabel(
1060
- provider.activityActorLabel!.bind(provider),
1061
- identity.id,
1062
- remaining,
1063
- );
1064
- if (label) {
1065
- labels.set(key, label);
1066
- }
1067
- }
1068
- },
1069
- );
1070
- await Promise.all(workers);
1071
- return {
1072
- ...page,
1073
- events: page.events.map((event) => {
1074
- const resolved = event.actor.id
1075
- ? labels.get(JSON.stringify([
1076
- event.actor.kind,
1077
- event.actor.namespace,
1078
- event.actor.id,
1079
- ]))
1080
- : undefined;
1081
- // Never trust or echo a `label` supplied by storage. The persisted event
1082
- // schema has no label; only this authenticated read path may add one.
1083
- const actor: ActivityActor = {
1084
- kind: event.actor.kind,
1085
- ...(event.actor.id ? { id: event.actor.id } : {}),
1086
- ...(event.actor.namespace
1087
- ? { namespace: event.actor.namespace }
1088
- : {}),
1089
- };
1090
- return {
1091
- ...event,
1092
- actor: resolved ? { ...actor, label: resolved } : actor,
1093
- };
1094
- }),
1095
- };
1096
- }
1097
-
1098
- /**
1099
- * Resolve `?toolkit=<name>` into the registry view this connection may see,
1100
- * enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
1101
- *
1102
- * For an UNBOUND identity (no binding configured — the pre-#37 shape):
1103
- *
1104
- * - absent → the full registry, byte-identical to a deployment with no toolkits
1105
- * - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
1106
- * - anything else, including `?toolkit=` with an empty value → an explicit
1107
- * 404. Never a silent fallback to the full registry.
1108
- *
1109
- * For a BOUND identity, membership is checked FIRST and refusal is a flat 403:
1110
- * a toolkit outside the binding, an unknown name, and (without `unscoped`) an
1111
- * omitted `?toolkit=` are all refused before any `ScopedRegistry` is built, and
1112
- * all three produce the same response.
20
+ * Build the Web-standard fetch handler.
1113
21
  *
1114
- * Neither error enumerates the configured toolkits: the name selects a scope, so
1115
- * a wrong guess gets a flat refusal, not a directory.
1116
- *
1117
- * Because of that — and because SDK clients treat a 404/403 on the transport
1118
- * endpoint as a transport failure and discard the body — every rejection is also
1119
- * logged operator-side (issue #47), which is the channel that actually reaches a
1120
- * human. The log line may name the configured or bound toolkits; the response
1121
- * still may not.
22
+ * Route ordering is the contract: private mutation routes precede wildcard
23
+ * OPTIONS, every built-in precedes connector-owned routes, and the security
24
+ * wrapper is applied to every response.
1122
25
  */
1123
- function resolveToolkitScope(
1124
- url: URL,
1125
- registry: Registry,
1126
- toolkits: ReadonlyMap<string, Toolkit> | undefined,
1127
- logger: Logger,
1128
- identity: { actor: ActivityActor; binding?: ToolkitBinding },
1129
- ):
1130
- | { ok: true; scope: McpScope }
1131
- | { ok: false; response: Response } {
1132
- const requested = url.searchParams.get("toolkit");
1133
- const binding = identity.binding;
1134
- const scopeFor = (toolkit: Toolkit) => ({
1135
- ok: true as const,
1136
- scope: {
1137
- registry: new ScopedRegistry(registry, toolkit),
1138
- toolkitId: toolkit.name,
1139
- },
1140
- });
1141
-
1142
- if (binding) {
1143
- const who = identityLabel(identity.actor);
1144
- const bound = `Bound toolkits: ${binding.toolkits.join(", ") || "(none)"}${
1145
- binding.unscoped ? ", plus unscoped access" : ""
1146
- }.`;
1147
- if (requested === null) {
1148
- if (binding.unscoped) return { ok: true, scope: { registry } };
1149
- logger.warn(
1150
- `[connecta] refused an unscoped /mcp connection from ${who} with 403: ` +
1151
- "its toolkit binding does not allow the full registry. " +
1152
- bound +
1153
- " The client sees a transport-level failure and never the reason, so " +
1154
- "give it an MCP endpoint URL with a ?toolkit= value it is bound to.",
1155
- );
1156
- return { ok: false, response: toolkitForbidden() };
1157
- }
1158
- const permitted = binding.toolkits.includes(requested);
1159
- const toolkit = permitted ? toolkits?.get(requested) : undefined;
1160
- if (toolkit) return scopeFor(toolkit);
1161
- logger.warn(
1162
- `[connecta] refused an /mcp connection from ${who} with 403: it asked ` +
1163
- `for toolkit ${loggableValue(requested)}, which ` +
1164
- (permitted
1165
- ? "its binding allows but this deployment does not configure"
1166
- : "its toolkit binding does not include") +
1167
- ". " +
1168
- bound +
1169
- " The client sees a transport-level failure and never the reason, so " +
1170
- "check the ?toolkit= value in its MCP endpoint URL.",
1171
- );
1172
- return { ok: false, response: toolkitForbidden() };
1173
- }
1174
-
1175
- if (requested === null) return { ok: true, scope: { registry } };
1176
- const toolkit = toolkits?.get(requested);
1177
- if (toolkit) return scopeFor(toolkit);
1178
- const configured = toolkits && toolkits.size > 0 ? [...toolkits.keys()] : [];
1179
- logger.warn(
1180
- "[connecta] rejected an /mcp connection asking for unknown toolkit " +
1181
- `${loggableValue(requested)} with 404. ` +
1182
- (configured.length > 0
1183
- ? `Configured toolkits: ${configured.join(", ")}.`
1184
- : "This deployment configures no toolkits, so no ?toolkit= value is accepted.") +
1185
- " The client sees a transport-level failure and never the reason, so " +
1186
- "check the ?toolkit= value in its MCP endpoint URL.",
1187
- );
1188
- const label =
1189
- requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
1190
- TOOLKIT_NAME_RE.test(requested)
1191
- ? `"${requested}"`
1192
- : "requested";
1193
- return {
1194
- ok: false,
1195
- response: new Response(
1196
- JSON.stringify({
1197
- jsonrpc: "2.0",
1198
- id: null,
1199
- error: {
1200
- code: -32600,
1201
- message:
1202
- `Unknown toolkit ${label}. Check the ?toolkit= value in this ` +
1203
- "deployment's MCP endpoint URL with the operator.",
1204
- },
1205
- }),
1206
- {
1207
- status: 404,
1208
- headers: {
1209
- "Content-Type": "application/json",
1210
- "Cache-Control": "no-store",
1211
- },
1212
- },
1213
- ),
1214
- };
1215
- }
1216
-
1217
- /**
1218
- * True when this identity is confined to one or more toolkits — bound, without
1219
- * `unscoped`. Such a credential belongs to a team's agent, not to the operator
1220
- * running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
1221
- * `/ui/activity`, the credential API) refuse it: their payloads describe every
1222
- * connector in the org, which is exactly what the binding exists to withhold.
1223
- */
1224
- function isToolkitRestricted(binding: ToolkitBinding | undefined): boolean {
1225
- return Boolean(binding && !binding.unscoped);
1226
- }
1227
-
1228
- /** The refusal the deployment-wide operator surfaces give a bound identity. */
1229
- function restrictedOperatorSurface(): Response {
1230
- return privateJson(
1231
- {
1232
- error:
1233
- "this credential is bound to a toolkit and may not read " +
1234
- "deployment-wide operator data",
1235
- },
1236
- { status: 403 },
1237
- );
1238
- }
1239
-
1240
- async function serveMcp(
1241
- request: Request,
1242
- opts: ServerOptions,
1243
- baseUrl: string,
1244
- actor: ActivityActor,
1245
- scope: McpScope,
1246
- runtimeContext?: RuntimeExecutionContext,
1247
- ): Promise<Response> {
1248
- // Fresh McpServer + transport per request (SDK ≥1.26 requirement), stateless.
1249
- const server = new McpServer(opts.serverInfo, {
1250
- instructions: CONNECTA_INSTRUCTIONS,
1251
- });
1252
- const activity: ActivityRequestContext | undefined = opts.activity
1253
- ? {
1254
- sink: opts.activity,
1255
- actor,
1256
- requestId: crypto.randomUUID(),
1257
- serverInfo: opts.serverInfo,
1258
- ...(opts.activityDeploymentId
1259
- ? { deploymentId: opts.activityDeploymentId }
1260
- : {}),
1261
- ...(scope.toolkitId ? { toolkitId: scope.toolkitId } : {}),
1262
- ...(runtimeContext?.waitUntil
1263
- ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
1264
- : {}),
1265
- logger: opts.logger,
1266
- }
1267
- : undefined;
1268
- // `scope.registry` is the connection's VIEW — the full registry, or one
1269
- // toolkit's ScopedRegistry. Nothing below may reach for `opts.registry`.
1270
- const registry = scope.registry;
1271
- registerMetaTools(server, registry, {
1272
- baseUrl,
1273
- activity,
1274
- defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
1275
- probeTimeoutMs: opts.probeTimeoutMs,
1276
- ...(runtimeContext
1277
- ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
1278
- : {}),
1279
- });
1280
- if (opts.executor) {
1281
- registerExecuteTool(server, registry, {
1282
- baseUrl,
1283
- executor: opts.executor,
1284
- logger: opts.logger,
1285
- activity,
1286
- requestSignal: request.signal,
1287
- });
1288
- }
1289
- const transport = new WebStandardStreamableHTTPServerTransport({
1290
- enableJsonResponse: true,
1291
- });
1292
- await server.connect(transport);
1293
- return transport.handleRequest(request);
1294
- }
1295
-
1296
- /**
1297
- * Pay the storage read a real downstream-OAuth refusal pays, on the refusal
1298
- * paths that would otherwise pay nothing.
1299
- *
1300
- * Identical bodies do not hide a connector id if the clock still sorts them.
1301
- * `KvOAuthProvider.verifyState` reads `oauth:state` and its generation before
1302
- * it can reject a mismatched value, so a configured id costs two storage round
1303
- * trips on the ordinary path while an id naming nothing used to touch no I/O.
1304
- * That gap is an oracle: sample the two and a wordlist recovers the connector
1305
- * list the flat 400 was meant to withhold. So zero-I/O refusals read the same
1306
- * keys in the same `conn:<id>:` namespace, where an unconfigured id gets misses.
1307
- *
1308
- * This is deliberately *not* a constant-time claim, and docs/connectors.md says
1309
- * so in prose: a hit and a miss are not identical in a KV store, and a connector
1310
- * shipping its own `verifyState` may do more or less work. What it
1311
- * removes is the order-of-magnitude "no I/O versus a round trip" difference,
1312
- * which is the only part of the signal that makes enumeration cheap.
1313
- *
1314
- * A throwing read is swallowed: the refusal is the answer either way, and
1315
- * turning it into a 500 would hand back exactly the distinguishable response
1316
- * this whole path exists to deny.
1317
- */
1318
- async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
1319
- try {
1320
- const generation = await context.storage.get("oauth:generation");
1321
- await context.storage.get(
1322
- oauthValueStorageKey("oauth:state", generation),
1323
- );
1324
- } catch {
1325
- // Deliberately ignored — see above.
1326
- }
1327
- }
1328
-
1329
- async function handleOAuthCallback(
1330
- url: URL,
1331
- registry: Registry,
1332
- baseUrl: string,
1333
- logger: Logger,
1334
- branding?: ConnectaBranding,
1335
- ): Promise<Response> {
1336
- const error = url.searchParams.get("error");
1337
- if (error) return html(`Authorization denied: ${error}`, 400, branding);
1338
- const code = url.searchParams.get("code");
1339
- if (!code) return html("Missing authorization code.", 400, branding);
1340
- const id = url.pathname.slice("/oauth/callback/".length);
1341
- const connector = registry.getConnector(id);
1342
- // Safe to build before we know the id names anything: `contextFor` is a pure
1343
- // constructor — a namespaced storage view over `conn:<id>:` and, only for a
1344
- // connector that declares one, a lazy credential accessor. It neither throws
1345
- // nor touches storage for an unknown id, which is what lets the refusals
1346
- // below borrow it to equalize their cost.
1347
- const context = registry.contextFor(id, baseUrl);
1348
- const refused = () =>
1349
- html(
1350
- "Authorization could not be completed. Re-run authorization from " +
1351
- "connecta and try again.",
1352
- 400,
1353
- branding,
1354
- );
1355
- if (!connector || !connector.finishAuth) {
1356
- await equalizeRefusalCost(context);
1357
- return refused();
1358
- }
1359
- // CSRF / login-fixation guard: this route is intentionally public, so verify
1360
- // the `state` matches the flow connecta started BEFORE exchanging the code.
1361
- if (!connector.verifyState) {
1362
- await equalizeRefusalCost(context);
1363
- logger.warn(
1364
- `[connecta] refused an OAuth callback for connector ` +
1365
- `${loggableValue(id)} with 400: it implements finishAuth but no ` +
1366
- "verifyState, so connecta cannot establish that it started this flow. " +
1367
- "No authorization code was exchanged. Implement verifyState before " +
1368
- "trying again.",
1369
- );
1370
- return refused();
1371
- }
1372
- const state = url.searchParams.get("state");
1373
- let stateMatches: boolean;
1374
- try {
1375
- stateMatches = await connector.verifyState(state, context);
1376
- } catch (err) {
1377
- logger.warn(
1378
- `[connecta] refused an OAuth callback for connector ` +
1379
- `${loggableValue(id)} with 400: verifyState threw ` +
1380
- `${loggableValue(msg(err))}. No authorization code was exchanged. ` +
1381
- "Re-run authorization from connecta and check the verifier if it " +
1382
- "fails again.",
1383
- );
1384
- return refused();
1385
- }
1386
- if (!stateMatches) {
1387
- logger.warn(
1388
- `[connecta] refused an OAuth callback for connector ` +
1389
- `${loggableValue(id)} with 400: ` +
1390
- (state === null
1391
- ? "the state parameter was missing"
1392
- : "the state did not match the pending authorization flow") +
1393
- ". No authorization code was exchanged. Re-run authorization from " +
1394
- "connecta and try again.",
1395
- );
1396
- return refused();
1397
- }
1398
- try {
1399
- await connector.finishAuth(code, context);
1400
- await registry.invalidateStored(id);
1401
- // Recovery, without a restart: the grant this connector was reported dead
1402
- // for has just been replaced, so drop the verdict rather than let a stale
1403
- // `auth_required` survive until the next scheduled check.
1404
- await registry.clearCredentialHealth(id);
1405
- return html(
1406
- `Connected "${id}". You can close this window.`,
1407
- 200,
1408
- branding,
1409
- );
1410
- } catch (err) {
1411
- return html(`Authorization failed: ${msg(err)}`, 500, branding);
1412
- }
1413
- }
1414
-
1415
- /** Build the Web-standard fetch handler that serves connecta. */
1416
26
  export function createFetchHandler(
1417
27
  opts: ServerOptions,
1418
28
  ): (
1419
29
  request: Request,
1420
30
  runtimeContext?: RuntimeExecutionContext,
1421
31
  ) => Promise<Response> {
1422
- const { registry, auth, publicUrl, serverInfo } = opts;
1423
- let lastAdmissionWarningAt = 0;
1424
- let suppressedAdmissionWarnings = 0;
1425
- const warnAdmissionRejected = (error: ExecutorAdmissionError): void => {
1426
- const now = Date.now();
1427
- if (now - lastAdmissionWarningAt < 1_000) {
1428
- suppressedAdmissionWarnings++;
1429
- return;
1430
- }
1431
- opts.logger.warn("[connecta] MCP request admission rejected", {
1432
- retryAfterMs: error.retryAfterMs,
1433
- active: opts.requestAdmission.activeCount,
1434
- queued: opts.requestAdmission.queuedCount,
1435
- suppressedSinceLastWarning: suppressedAdmissionWarnings,
1436
- });
1437
- lastAdmissionWarningAt = now;
1438
- suppressedAdmissionWarnings = 0;
1439
- };
32
+ const { registry, auth, publicUrl } = opts;
33
+ const routeMcp = createMcpRoute(opts);
34
+
1440
35
  return async function fetch(
1441
36
  request: Request,
1442
37
  runtimeContext?: RuntimeExecutionContext,
@@ -1476,6 +71,7 @@ export function createFetchHandler(
1476
71
  opts.logger.warn("[connecta] credential health sweep failed", err);
1477
72
  }
1478
73
  };
74
+
1479
75
  // Container and orchestrator probes reach /health over plain HTTP on
1480
76
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
1481
77
  // the public origin would make an internal liveness check depend on
@@ -1488,16 +84,15 @@ export function createFetchHandler(
1488
84
  new URL(publicUrl).protocol === "https:" &&
1489
85
  url.protocol === "http:"
1490
86
  ) {
1491
- // Canonicalize the legacy bookmark while upgrading it so an old /ui URL
1492
- // reaches the new Connections entry point in one permanent redirect.
1493
- const targetPath = path === "/ui" ? "/" : url.pathname;
1494
87
  // Assign the path and query onto the configured URL instead of resolving
1495
88
  // attacker-controlled text against it. A pathname beginning with `//`
1496
89
  // (including a backslash form normalized by URL parsing) is an authority
1497
90
  // when passed to `new URL(value, base)` and would otherwise replace the
1498
- // deployment host.
91
+ // deployment host. `/ui` is canonicalized while upgrading it so an old
92
+ // bookmark reaches the new Connections entry point in one permanent
93
+ // redirect.
1499
94
  const target = new URL(publicUrl);
1500
- target.pathname = targetPath;
95
+ target.pathname = path === "/ui" ? "/" : url.pathname;
1501
96
  target.search = url.search;
1502
97
  target.hash = "";
1503
98
  return withSecurityHeaders(
@@ -1510,53 +105,43 @@ export function createFetchHandler(
1510
105
  );
1511
106
  }
1512
107
 
108
+ const context: RouteContext = {
109
+ request,
110
+ url,
111
+ path,
112
+ baseUrl,
113
+ opts,
114
+ defer,
115
+ runtimeContext,
116
+ sweepCredentials,
117
+ };
118
+
1513
119
  const route = async (): Promise<Response> => {
1514
- const credentialMatch =
1515
- /^\/ui\/credentials\/([a-z0-9_-]+)(?:\/([a-z]+))?$/.exec(path);
1516
- if (credentialMatch) {
1517
- // Never opt these mutation routes into the server's wildcard CORS
1518
- // preflight behavior.
1519
- if (request.method === "OPTIONS") {
1520
- return privateJson({ error: "method not allowed" }, { status: 405 });
1521
- }
1522
- return handleCredentialRequest(
1523
- request,
1524
- credentialMatch[1],
1525
- credentialMatch[2],
1526
- opts,
1527
- baseUrl,
1528
- );
1529
- }
1530
- const oauthManagementMatch =
1531
- /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(path);
1532
- if (oauthManagementMatch) {
1533
- if (request.method === "OPTIONS") {
1534
- return privateJson({ error: "method not allowed" }, { status: 405 });
1535
- }
1536
- return handleOAuthManagementRequest(
1537
- request,
1538
- oauthManagementMatch[1],
1539
- opts,
1540
- baseUrl,
1541
- defer,
1542
- );
1543
- }
120
+ // Private mutations own OPTIONS so they never inherit wildcard CORS.
121
+ const credentials = await routeCredentials(context);
122
+ if (credentials) return credentials;
123
+
124
+ const oauthManagement = await routeOAuthManagement(context);
125
+ if (oauthManagement) return oauthManagement;
1544
126
 
1545
127
  if (request.method === "OPTIONS") {
1546
- for (const a of auth) {
1547
- if (a.handleMetadata) {
1548
- const r = await a.handleMetadata(request, baseUrl);
1549
- if (r) return r;
128
+ for (const provider of auth) {
129
+ if (provider.handleMetadata) {
130
+ const response = await provider.handleMetadata(request, baseUrl);
131
+ if (response) return response;
1550
132
  }
1551
133
  }
1552
- return new Response(null, { status: 204, headers: CORS_HEADERS });
134
+ return new Response(null, {
135
+ status: 204,
136
+ headers: MCP_CORS_HEADERS,
137
+ });
1553
138
  }
1554
139
 
1555
140
  if (path.startsWith("/.well-known/")) {
1556
- for (const a of auth) {
1557
- if (a.handleMetadata) {
1558
- const r = await a.handleMetadata(request, baseUrl);
1559
- if (r) return r;
141
+ for (const provider of auth) {
142
+ if (provider.handleMetadata) {
143
+ const response = await provider.handleMetadata(request, baseUrl);
144
+ if (response) return response;
1560
145
  }
1561
146
  }
1562
147
  return new Response("Not Found", { status: 404 });
@@ -1577,6 +162,10 @@ export function createFetchHandler(
1577
162
  code: opts.executor
1578
163
  ? (codeAdmission ?? { managedByExecutor: true })
1579
164
  : null,
165
+ downstreamCalls: {
166
+ policy: "connector-partitioned-per-runtime",
167
+ connectors: registry.callAdmissionSnapshot(),
168
+ },
1580
169
  reservedRoutes: [
1581
170
  "/health",
1582
171
  "/",
@@ -1590,235 +179,17 @@ export function createFetchHandler(
1590
179
  });
1591
180
  }
1592
181
 
1593
- if (path.startsWith("/oauth/callback/")) {
1594
- return handleOAuthCallback(
1595
- url,
1596
- registry,
1597
- baseUrl,
1598
- opts.logger,
1599
- opts.branding,
1600
- );
1601
- }
1602
-
1603
- if (request.method === "GET" && path === "/favicon.svg") {
1604
- return new Response(opts.branding?.favicon?.svg ?? CONNECTA_FAVICON_SVG, {
1605
- headers: {
1606
- "Content-Type": "image/svg+xml",
1607
- "Cache-Control": "public, max-age=86400",
1608
- ...INERT_ICON_HEADERS,
1609
- },
1610
- });
1611
- }
1612
-
1613
- if (request.method === "GET" && path === "/favicon.ico") {
1614
- return new Response(opts.branding?.favicon?.ico ?? CONNECTA_FAVICON_ICO, {
1615
- headers: {
1616
- "Content-Type": "image/x-icon",
1617
- "Cache-Control": "public, max-age=86400",
1618
- ...INERT_ICON_HEADERS,
1619
- },
1620
- });
1621
- }
1622
-
1623
- if (path === "/ui") {
1624
- if (request.method !== "GET") {
1625
- return privateJson({ error: "method not allowed" }, { status: 405 });
1626
- }
1627
- const target = new URL(`/${url.search}`, baseUrl);
1628
- return new Response(null, {
1629
- status: 308,
1630
- headers: { Location: target.toString() },
1631
- });
1632
- }
1633
-
1634
- const operatorPage = operatorPageForPath(path);
1635
- if (operatorPage) {
1636
- if (request.method !== "GET" && request.method !== "HEAD") {
1637
- return privateJson({ error: "method not allowed" }, { status: 405 });
1638
- }
1639
- // Open shell — carries no operator data; everything comes from the
1640
- // authenticated /ui/* APIs after the browser establishes a session.
1641
- const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
1642
- const mcpUrl = new URL("/mcp", baseUrl).toString();
1643
- // Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
1644
- // lets scripts the nonced Clerk loader injects at runtime execute; the
1645
- // https:/'unsafe-inline' fallbacks are ignored by CSP3 browsers that
1646
- // honour the nonce and only cover legacy ones. No default-src, so Clerk's
1647
- // style/font/network needs and the page's inline <style> stay unrestricted
1648
- // — only script execution, the XSS sink, is gated.
1649
- const nonce = uiScriptNonce();
1650
- return new Response(
1651
- request.method === "HEAD"
1652
- ? null
1653
- : renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
1654
- {
1655
- status: 200,
1656
- headers: {
1657
- "Content-Type": "text/html; charset=utf-8",
1658
- "Content-Security-Policy":
1659
- `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
1660
- "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
1661
- "X-Content-Type-Options": "nosniff",
1662
- },
1663
- },
1664
- );
1665
- }
182
+ const oauthCallback = await routeOAuthCallback(context);
183
+ if (oauthCallback) return oauthCallback;
1666
184
 
1667
- if (path === "/ui/data") {
1668
- const authz = await authorize(request, baseUrl, auth, opts.logger);
1669
- if (!authz.ok) return authz.response;
1670
- if (isToolkitRestricted(authz.toolkitBinding)) {
1671
- return restrictedOperatorSurface();
1672
- }
1673
- // After the restriction check, not before: an identity that may not
1674
- // read this surface should not get to trigger background work from it.
1675
- sweepCredentials();
1676
- const eligibleClerkOperator = authz.uiAdminEligible === true;
1677
- const credentialManagement = credentialManagementCapability({
1678
- eligibleClerkOperator,
1679
- hasCredentialSlots: registry
1680
- .listConnectors()
1681
- .some((connector) => Boolean(connector.credential)),
1682
- hasCredentialVault: Boolean(opts.credentialVault),
1683
- });
1684
- const data = await buildUiData(
1685
- registry,
1686
- baseUrl,
1687
- serverInfo,
1688
- // The static headless bearer may read connector health, but only a
1689
- // Clerk-authenticated operator receives credential metadata.
1690
- eligibleClerkOperator ? opts.credentialVault : undefined,
1691
- Boolean(opts.activity?.list),
1692
- credentialManagement,
1693
- opts.toolkits,
1694
- defer,
1695
- eligibleClerkOperator,
1696
- );
1697
- return privateJson(data);
1698
- }
185
+ const ui = await routeUi(context);
186
+ if (ui) return ui;
1699
187
 
1700
- if (path === "/ui/activity") {
1701
- if (request.method !== "GET") {
1702
- return privateJson({ error: "method not allowed" }, { status: 405 });
1703
- }
1704
- const authz = await authorize(request, baseUrl, auth, opts.logger);
1705
- if (!authz.ok) return authz.response;
1706
- if (isToolkitRestricted(authz.toolkitBinding)) {
1707
- return restrictedOperatorSurface();
1708
- }
1709
- if (
1710
- opts.activityReadGate &&
1711
- !(await opts.activityReadGate(authz.actor))
1712
- ) {
1713
- return privateJson({ error: "forbidden" }, { status: 403 });
1714
- }
1715
- if (!opts.activity?.list) {
1716
- return privateJson(
1717
- { error: "activity history is not configured" },
1718
- { status: 404 },
1719
- );
1720
- }
1721
- const cursor = url.searchParams.get("cursor") ?? undefined;
1722
- if (cursor && cursor.length > 500) {
1723
- return privateJson({ error: "invalid cursor" }, { status: 400 });
1724
- }
1725
- const requestedLimit = Number(url.searchParams.get("limit") ?? "50");
1726
- const limit = Number.isFinite(requestedLimit)
1727
- ? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
1728
- : 50;
1729
- try {
1730
- const page = await opts.activity.list({ cursor, limit });
1731
- return privateJson(await enrichActivityActorLabels(page, auth));
1732
- } catch (error) {
1733
- if (error instanceof InvalidActivityCursorError) {
1734
- return privateJson({ error: error.message }, { status: 400 });
1735
- }
1736
- opts.logger.error("[connecta] activity read failed", error);
1737
- return privateJson(
1738
- { error: "activity history is temporarily unavailable" },
1739
- { status: 503 },
1740
- );
1741
- }
1742
- }
188
+ const activity = await routeActivity(context);
189
+ if (activity) return activity;
1743
190
 
1744
- if (path === "/mcp") {
1745
- let admission: AdmissionLease;
1746
- try {
1747
- admission = await opts.requestAdmission.acquire({
1748
- signal: request.signal,
1749
- });
1750
- if (admission.waitMs > 0) {
1751
- opts.logger.debug("[connecta] MCP request admitted after queue wait", {
1752
- waitMs: admission.waitMs,
1753
- active: opts.requestAdmission.activeCount,
1754
- queued: opts.requestAdmission.queuedCount,
1755
- });
1756
- }
1757
- } catch (error) {
1758
- if (
1759
- error instanceof ExecutorAdmissionError &&
1760
- error.code === "executor_cancelled"
1761
- ) {
1762
- throw request.signal.reason ?? error;
1763
- }
1764
- if (error instanceof ExecutorAdmissionError) {
1765
- if (error.code === "executor_overloaded") {
1766
- warnAdmissionRejected(error);
1767
- }
1768
- return withMcpCors(requestAdmissionFailure(error));
1769
- }
1770
- throw error;
1771
- }
1772
- try {
1773
- // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
1774
- // must not be able to probe which toolkit names exist.
1775
- const authz = await authorize(request, baseUrl, auth, opts.logger);
1776
- if (!authz.ok) {
1777
- return releaseAdmissionWithResponse(
1778
- withMcpCors(authz.response),
1779
- admission,
1780
- request.signal,
1781
- );
1782
- }
1783
- const selected = resolveToolkitScope(
1784
- url,
1785
- registry,
1786
- opts.toolkits,
1787
- opts.logger,
1788
- {
1789
- actor: authz.actor,
1790
- ...(authz.toolkitBinding
1791
- ? { binding: authz.toolkitBinding }
1792
- : {}),
1793
- },
1794
- );
1795
- if (!selected.ok) {
1796
- return releaseAdmissionWithResponse(
1797
- withMcpCors(selected.response),
1798
- admission,
1799
- request.signal,
1800
- );
1801
- }
1802
- sweepCredentials();
1803
- return releaseAdmissionWithResponse(
1804
- withMcpCors(
1805
- await serveMcp(
1806
- request,
1807
- opts,
1808
- baseUrl,
1809
- authz.actor,
1810
- selected.scope,
1811
- runtimeContext,
1812
- ),
1813
- ),
1814
- admission,
1815
- request.signal,
1816
- );
1817
- } catch (error) {
1818
- admission.release();
1819
- throw error;
1820
- }
1821
- }
191
+ const mcp = await routeMcp(context);
192
+ if (mcp) return mcp;
1822
193
 
1823
194
  // Connector-owned public routes, dispatched last: a connector can add a
1824
195
  // route but never shadow one of connecta's own. A throw here is the
@@ -1827,11 +198,11 @@ export function createFetchHandler(
1827
198
  for (const connector of registry.listConnectors()) {
1828
199
  if (!connector.handleRequest) continue;
1829
200
  try {
1830
- const connectorResponse = await connector.handleRequest(
201
+ const response = await connector.handleRequest(
1831
202
  request,
1832
203
  registry.contextFor(connector.id, baseUrl),
1833
204
  );
1834
- if (connectorResponse) return connectorResponse;
205
+ if (response) return response;
1835
206
  } catch (error) {
1836
207
  opts.logger.error(
1837
208
  `[connecta] connector "${connector.id}" handleRequest failed`,
@@ -1843,10 +214,7 @@ export function createFetchHandler(
1843
214
 
1844
215
  return new Response("Not Found", { status: 404 });
1845
216
  };
217
+
1846
218
  return withSecurityHeaders(await route(), url, path);
1847
219
  };
1848
220
  }
1849
-
1850
- interface RuntimeExecutionContext {
1851
- waitUntil(promise: Promise<unknown>): void;
1852
- }