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