@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.
- package/CHANGELOG.md +94 -0
- package/README.md +67 -144
- package/dist/activity.d.ts +1 -1
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +1 -1
- package/dist/auth/bearer.js +4 -4
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +1 -1
- package/dist/auth/clerk.js +1 -1
- package/dist/call-admission.d.ts +81 -0
- package/dist/call-admission.d.ts.map +1 -0
- package/dist/call-admission.js +345 -0
- package/dist/call-admission.js.map +1 -0
- package/dist/catalog-fingerprint.d.ts +20 -0
- package/dist/catalog-fingerprint.d.ts.map +1 -0
- package/dist/catalog-fingerprint.js +34 -0
- package/dist/catalog-fingerprint.js.map +1 -0
- package/dist/catalog-limits.d.ts +10 -0
- package/dist/catalog-limits.d.ts.map +1 -0
- package/dist/catalog-limits.js +10 -0
- package/dist/catalog-limits.js.map +1 -0
- package/dist/catalog-service.d.ts +138 -0
- package/dist/catalog-service.d.ts.map +1 -0
- package/dist/catalog-service.js +407 -0
- package/dist/catalog-service.js.map +1 -0
- package/dist/catalog.js +2 -2
- package/dist/catalog.js.map +1 -1
- package/dist/concurrency.d.ts +4 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +31 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/connectors/api.d.ts +3 -1
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +22 -11
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +3 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +44 -18
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +0 -22
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +14 -10
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +3 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +9 -8
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +144 -291
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.d.ts +0 -3
- package/dist/executor-result.d.ts.map +1 -1
- package/dist/executor-result.js +2 -2
- package/dist/executor-result.js.map +1 -1
- package/dist/executors/quickjs-child.js +3 -3
- package/dist/executors/quickjs-child.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -1
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +7 -2
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +4 -1
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/favicon.js +2 -2
- package/dist/favicon.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -17
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +96 -0
- package/dist/invocation.d.ts.map +1 -0
- package/dist/invocation.js +369 -0
- package/dist/invocation.js.map +1 -0
- package/dist/meta-tools.d.ts +11 -86
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +145 -485
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +3 -0
- package/dist/operator-ui/generated.d.ts.map +1 -0
- package/dist/operator-ui/generated.js +5 -0
- package/dist/operator-ui/generated.js.map +1 -0
- package/dist/operator-ui/model.d.ts +94 -0
- package/dist/operator-ui/model.d.ts.map +1 -0
- package/dist/operator-ui/model.js +28 -0
- package/dist/operator-ui/model.js.map +1 -0
- package/dist/registry.d.ts +47 -6
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +356 -35
- package/dist/registry.js.map +1 -1
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +173 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/credentials.d.ts +3 -0
- package/dist/routes/credentials.d.ts.map +1 -0
- package/dist/routes/credentials.js +181 -0
- package/dist/routes/credentials.js.map +1 -0
- package/dist/routes/mcp.d.ts +8 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +383 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/oauth.d.ts +4 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +268 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/shared.d.ts +97 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +199 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +122 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/server.d.ts +9 -40
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +67 -1292
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +0 -15
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +6 -5
- package/dist/skills.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +3 -1
- package/dist/storage/file.js.map +1 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +3 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/toolkits.d.ts +0 -22
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +2 -2
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +64 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +3 -93
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +26 -1389
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +15 -8
- package/src/activity.ts +5 -1
- package/src/auth/bearer.ts +4 -4
- package/src/auth/clerk.ts +1 -1
- package/src/call-admission.ts +522 -0
- package/src/catalog-fingerprint.ts +52 -0
- package/src/catalog-limits.ts +11 -0
- package/src/catalog-service.ts +601 -0
- package/src/catalog.ts +2 -2
- package/src/concurrency.ts +39 -0
- package/src/connectors/api.ts +25 -11
- package/src/connectors/remote-mcp.ts +55 -21
- package/src/credential-health.ts +13 -10
- package/src/credentials.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/execute.ts +194 -377
- package/src/executor-result.ts +2 -2
- package/src/executors/quickjs-child.ts +8 -4
- package/src/executors/quickjs-protocol.ts +1 -1
- package/src/executors/quickjs-runtime.ts +5 -1
- package/src/executors/quickjs.ts +11 -8
- package/src/favicon.ts +2 -2
- package/src/index.ts +54 -17
- package/src/invocation.ts +527 -0
- package/src/meta-tools.ts +369 -797
- package/src/operator-ui/browser.css +413 -0
- package/src/operator-ui/browser.ts +1121 -0
- package/src/operator-ui/generated.ts +4 -0
- package/src/operator-ui/model.ts +127 -0
- package/src/registry.ts +489 -44
- package/src/routes/activity.ts +220 -0
- package/src/routes/credentials.ts +266 -0
- package/src/routes/mcp.ts +485 -0
- package/src/routes/oauth.ts +328 -0
- package/src/routes/shared.ts +330 -0
- package/src/routes/ui.ts +153 -0
- package/src/server.ts +70 -1702
- package/src/skills.ts +6 -5
- package/src/storage/file.ts +3 -1
- package/src/storage/memory.ts +3 -1
- package/src/toolkits.ts +2 -2
- package/src/types.ts +70 -2
- package/src/ui.ts +52 -1489
- package/src/version.ts +1 -1
package/dist/server.js
CHANGED
|
@@ -1,1093 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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("&", "&")
|
|
61
|
-
.replaceAll("<", "<")
|
|
62
|
-
.replaceAll(">", ">")
|
|
63
|
-
.replaceAll('"', """);
|
|
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
|
-
...(runtimeContext
|
|
954
|
-
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
955
|
-
: {}),
|
|
956
|
-
});
|
|
957
|
-
if (opts.executor) {
|
|
958
|
-
registerExecuteTool(server, registry, {
|
|
959
|
-
baseUrl,
|
|
960
|
-
executor: opts.executor,
|
|
961
|
-
logger: opts.logger,
|
|
962
|
-
activity,
|
|
963
|
-
requestSignal: request.signal,
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
967
|
-
enableJsonResponse: true,
|
|
968
|
-
});
|
|
969
|
-
await server.connect(transport);
|
|
970
|
-
return transport.handleRequest(request);
|
|
971
|
-
}
|
|
972
|
-
/**
|
|
973
|
-
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
974
|
-
* paths that would otherwise pay nothing.
|
|
975
|
-
*
|
|
976
|
-
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
977
|
-
* `KvOAuthProvider.verifyState` reads `oauth:state` and its generation before
|
|
978
|
-
* it can reject a mismatched value, so a configured id costs two storage round
|
|
979
|
-
* trips on the ordinary path while an id naming nothing used to touch no I/O.
|
|
980
|
-
* That gap is an oracle: sample the two and a wordlist recovers the connector
|
|
981
|
-
* list the flat 400 was meant to withhold. So zero-I/O refusals read the same
|
|
982
|
-
* keys in the same `conn:<id>:` namespace, where an unconfigured id gets misses.
|
|
983
|
-
*
|
|
984
|
-
* This is deliberately *not* a constant-time claim, and docs/connectors.md says
|
|
985
|
-
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
986
|
-
* shipping its own `verifyState` may do more or less work. What it
|
|
987
|
-
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
988
|
-
* which is the only part of the signal that makes enumeration cheap.
|
|
989
|
-
*
|
|
990
|
-
* A throwing read is swallowed: the refusal is the answer either way, and
|
|
991
|
-
* turning it into a 500 would hand back exactly the distinguishable response
|
|
992
|
-
* 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.
|
|
993
14
|
*/
|
|
994
|
-
async function equalizeRefusalCost(context) {
|
|
995
|
-
try {
|
|
996
|
-
const generation = await context.storage.get("oauth:generation");
|
|
997
|
-
await context.storage.get(oauthValueStorageKey("oauth:state", generation));
|
|
998
|
-
}
|
|
999
|
-
catch {
|
|
1000
|
-
// Deliberately ignored — see above.
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
async function handleOAuthCallback(url, registry, baseUrl, logger, branding) {
|
|
1004
|
-
const error = url.searchParams.get("error");
|
|
1005
|
-
if (error)
|
|
1006
|
-
return html(`Authorization denied: ${error}`, 400, branding);
|
|
1007
|
-
const code = url.searchParams.get("code");
|
|
1008
|
-
if (!code)
|
|
1009
|
-
return html("Missing authorization code.", 400, branding);
|
|
1010
|
-
const id = url.pathname.slice("/oauth/callback/".length);
|
|
1011
|
-
const connector = registry.getConnector(id);
|
|
1012
|
-
// Safe to build before we know the id names anything: `contextFor` is a pure
|
|
1013
|
-
// constructor — a namespaced storage view over `conn:<id>:` and, only for a
|
|
1014
|
-
// connector that declares one, a lazy credential accessor. It neither throws
|
|
1015
|
-
// nor touches storage for an unknown id, which is what lets the refusals
|
|
1016
|
-
// below borrow it to equalize their cost.
|
|
1017
|
-
const context = registry.contextFor(id, baseUrl);
|
|
1018
|
-
const refused = () => html("Authorization could not be completed. Re-run authorization from " +
|
|
1019
|
-
"connecta and try again.", 400, branding);
|
|
1020
|
-
if (!connector || !connector.finishAuth) {
|
|
1021
|
-
await equalizeRefusalCost(context);
|
|
1022
|
-
return refused();
|
|
1023
|
-
}
|
|
1024
|
-
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
1025
|
-
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
1026
|
-
if (!connector.verifyState) {
|
|
1027
|
-
await equalizeRefusalCost(context);
|
|
1028
|
-
logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
1029
|
-
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
1030
|
-
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
1031
|
-
"No authorization code was exchanged. Implement verifyState before " +
|
|
1032
|
-
"trying again.");
|
|
1033
|
-
return refused();
|
|
1034
|
-
}
|
|
1035
|
-
const state = url.searchParams.get("state");
|
|
1036
|
-
let stateMatches;
|
|
1037
|
-
try {
|
|
1038
|
-
stateMatches = await connector.verifyState(state, context);
|
|
1039
|
-
}
|
|
1040
|
-
catch (err) {
|
|
1041
|
-
logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
1042
|
-
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
1043
|
-
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
1044
|
-
"Re-run authorization from connecta and check the verifier if it " +
|
|
1045
|
-
"fails again.");
|
|
1046
|
-
return refused();
|
|
1047
|
-
}
|
|
1048
|
-
if (!stateMatches) {
|
|
1049
|
-
logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
1050
|
-
`${loggableValue(id)} with 400: ` +
|
|
1051
|
-
(state === null
|
|
1052
|
-
? "the state parameter was missing"
|
|
1053
|
-
: "the state did not match the pending authorization flow") +
|
|
1054
|
-
". No authorization code was exchanged. Re-run authorization from " +
|
|
1055
|
-
"connecta and try again.");
|
|
1056
|
-
return refused();
|
|
1057
|
-
}
|
|
1058
|
-
try {
|
|
1059
|
-
await connector.finishAuth(code, context);
|
|
1060
|
-
await registry.invalidateStored(id);
|
|
1061
|
-
// Recovery, without a restart: the grant this connector was reported dead
|
|
1062
|
-
// for has just been replaced, so drop the verdict rather than let a stale
|
|
1063
|
-
// `auth_required` survive until the next scheduled check.
|
|
1064
|
-
await registry.clearCredentialHealth(id);
|
|
1065
|
-
return html(`Connected "${id}". You can close this window.`, 200, branding);
|
|
1066
|
-
}
|
|
1067
|
-
catch (err) {
|
|
1068
|
-
return html(`Authorization failed: ${msg(err)}`, 500, branding);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
/** Build the Web-standard fetch handler that serves connecta. */
|
|
1072
15
|
export function createFetchHandler(opts) {
|
|
1073
|
-
const { registry, auth, publicUrl
|
|
1074
|
-
|
|
1075
|
-
let suppressedAdmissionWarnings = 0;
|
|
1076
|
-
const warnAdmissionRejected = (error) => {
|
|
1077
|
-
const now = Date.now();
|
|
1078
|
-
if (now - lastAdmissionWarningAt < 1_000) {
|
|
1079
|
-
suppressedAdmissionWarnings++;
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
opts.logger.warn("[connecta] MCP request admission rejected", {
|
|
1083
|
-
retryAfterMs: error.retryAfterMs,
|
|
1084
|
-
active: opts.requestAdmission.activeCount,
|
|
1085
|
-
queued: opts.requestAdmission.queuedCount,
|
|
1086
|
-
suppressedSinceLastWarning: suppressedAdmissionWarnings,
|
|
1087
|
-
});
|
|
1088
|
-
lastAdmissionWarningAt = now;
|
|
1089
|
-
suppressedAdmissionWarnings = 0;
|
|
1090
|
-
};
|
|
16
|
+
const { registry, auth, publicUrl } = opts;
|
|
17
|
+
const routeMcp = createMcpRoute(opts);
|
|
1091
18
|
return async function fetch(request, runtimeContext) {
|
|
1092
19
|
const url = new URL(request.url);
|
|
1093
20
|
const baseUrl = publicUrl ?? url.origin;
|
|
@@ -1134,16 +61,15 @@ export function createFetchHandler(opts) {
|
|
|
1134
61
|
path !== "/health" &&
|
|
1135
62
|
new URL(publicUrl).protocol === "https:" &&
|
|
1136
63
|
url.protocol === "http:") {
|
|
1137
|
-
// Canonicalize the legacy bookmark while upgrading it so an old /ui URL
|
|
1138
|
-
// reaches the new Connections entry point in one permanent redirect.
|
|
1139
|
-
const targetPath = path === "/ui" ? "/" : url.pathname;
|
|
1140
64
|
// Assign the path and query onto the configured URL instead of resolving
|
|
1141
65
|
// attacker-controlled text against it. A pathname beginning with `//`
|
|
1142
66
|
// (including a backslash form normalized by URL parsing) is an authority
|
|
1143
67
|
// when passed to `new URL(value, base)` and would otherwise replace the
|
|
1144
|
-
// 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.
|
|
1145
71
|
const target = new URL(publicUrl);
|
|
1146
|
-
target.pathname =
|
|
72
|
+
target.pathname = path === "/ui" ? "/" : url.pathname;
|
|
1147
73
|
target.search = url.search;
|
|
1148
74
|
target.hash = "";
|
|
1149
75
|
return withSecurityHeaders(new Response(null, {
|
|
@@ -1151,39 +77,43 @@ export function createFetchHandler(opts) {
|
|
|
1151
77
|
headers: { Location: target.toString() },
|
|
1152
78
|
}), url, path);
|
|
1153
79
|
}
|
|
80
|
+
const context = {
|
|
81
|
+
request,
|
|
82
|
+
url,
|
|
83
|
+
path,
|
|
84
|
+
baseUrl,
|
|
85
|
+
opts,
|
|
86
|
+
defer,
|
|
87
|
+
runtimeContext,
|
|
88
|
+
sweepCredentials,
|
|
89
|
+
};
|
|
1154
90
|
const route = async () => {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
return handleCredentialRequest(request, credentialMatch[1], credentialMatch[2], opts, baseUrl);
|
|
1163
|
-
}
|
|
1164
|
-
const oauthManagementMatch = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(path);
|
|
1165
|
-
if (oauthManagementMatch) {
|
|
1166
|
-
if (request.method === "OPTIONS") {
|
|
1167
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1168
|
-
}
|
|
1169
|
-
return handleOAuthManagementRequest(request, oauthManagementMatch[1], opts, baseUrl, defer);
|
|
1170
|
-
}
|
|
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;
|
|
1171
98
|
if (request.method === "OPTIONS") {
|
|
1172
|
-
for (const
|
|
1173
|
-
if (
|
|
1174
|
-
const
|
|
1175
|
-
if (
|
|
1176
|
-
return
|
|
99
|
+
for (const provider of auth) {
|
|
100
|
+
if (provider.handleMetadata) {
|
|
101
|
+
const response = await provider.handleMetadata(request, baseUrl);
|
|
102
|
+
if (response)
|
|
103
|
+
return response;
|
|
1177
104
|
}
|
|
1178
105
|
}
|
|
1179
|
-
return new Response(null, {
|
|
106
|
+
return new Response(null, {
|
|
107
|
+
status: 204,
|
|
108
|
+
headers: MCP_CORS_HEADERS,
|
|
109
|
+
});
|
|
1180
110
|
}
|
|
1181
111
|
if (path.startsWith("/.well-known/")) {
|
|
1182
|
-
for (const
|
|
1183
|
-
if (
|
|
1184
|
-
const
|
|
1185
|
-
if (
|
|
1186
|
-
return
|
|
112
|
+
for (const provider of auth) {
|
|
113
|
+
if (provider.handleMetadata) {
|
|
114
|
+
const response = await provider.handleMetadata(request, baseUrl);
|
|
115
|
+
if (response)
|
|
116
|
+
return response;
|
|
1187
117
|
}
|
|
1188
118
|
}
|
|
1189
119
|
return new Response("Not Found", { status: 404 });
|
|
@@ -1202,6 +132,10 @@ export function createFetchHandler(opts) {
|
|
|
1202
132
|
code: opts.executor
|
|
1203
133
|
? (codeAdmission ?? { managedByExecutor: true })
|
|
1204
134
|
: null,
|
|
135
|
+
downstreamCalls: {
|
|
136
|
+
policy: "connector-partitioned-per-runtime",
|
|
137
|
+
connectors: registry.callAdmissionSnapshot(),
|
|
138
|
+
},
|
|
1205
139
|
reservedRoutes: [
|
|
1206
140
|
"/health",
|
|
1207
141
|
"/",
|
|
@@ -1214,177 +148,18 @@ export function createFetchHandler(opts) {
|
|
|
1214
148
|
...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
|
|
1215
149
|
});
|
|
1216
150
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
if (request.method === "GET" && path === "/favicon.ico") {
|
|
1230
|
-
return new Response(opts.branding?.favicon?.ico ?? CONNECTA_FAVICON_ICO, {
|
|
1231
|
-
headers: {
|
|
1232
|
-
"Content-Type": "image/x-icon",
|
|
1233
|
-
"Cache-Control": "public, max-age=86400",
|
|
1234
|
-
...INERT_ICON_HEADERS,
|
|
1235
|
-
},
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
if (path === "/ui") {
|
|
1239
|
-
if (request.method !== "GET") {
|
|
1240
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1241
|
-
}
|
|
1242
|
-
const target = new URL(`/${url.search}`, baseUrl);
|
|
1243
|
-
return new Response(null, {
|
|
1244
|
-
status: 308,
|
|
1245
|
-
headers: { Location: target.toString() },
|
|
1246
|
-
});
|
|
1247
|
-
}
|
|
1248
|
-
const operatorPage = operatorPageForPath(path);
|
|
1249
|
-
if (operatorPage) {
|
|
1250
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1251
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1252
|
-
}
|
|
1253
|
-
// Open shell — carries no operator data; everything comes from the
|
|
1254
|
-
// authenticated /ui/* APIs after the browser establishes a session.
|
|
1255
|
-
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
1256
|
-
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
1257
|
-
// Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
|
|
1258
|
-
// lets scripts the nonced Clerk loader injects at runtime execute; the
|
|
1259
|
-
// https:/'unsafe-inline' fallbacks are ignored by CSP3 browsers that
|
|
1260
|
-
// honour the nonce and only cover legacy ones. No default-src, so Clerk's
|
|
1261
|
-
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
1262
|
-
// — only script execution, the XSS sink, is gated.
|
|
1263
|
-
const nonce = uiScriptNonce();
|
|
1264
|
-
return new Response(request.method === "HEAD"
|
|
1265
|
-
? null
|
|
1266
|
-
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage), {
|
|
1267
|
-
status: 200,
|
|
1268
|
-
headers: {
|
|
1269
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
1270
|
-
"Content-Security-Policy": `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
1271
|
-
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
1272
|
-
"X-Content-Type-Options": "nosniff",
|
|
1273
|
-
},
|
|
1274
|
-
});
|
|
1275
|
-
}
|
|
1276
|
-
if (path === "/ui/data") {
|
|
1277
|
-
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1278
|
-
if (!authz.ok)
|
|
1279
|
-
return authz.response;
|
|
1280
|
-
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1281
|
-
return restrictedOperatorSurface();
|
|
1282
|
-
}
|
|
1283
|
-
// After the restriction check, not before: an identity that may not
|
|
1284
|
-
// read this surface should not get to trigger background work from it.
|
|
1285
|
-
sweepCredentials();
|
|
1286
|
-
const eligibleClerkOperator = authz.uiAdminEligible === true;
|
|
1287
|
-
const credentialManagement = credentialManagementCapability({
|
|
1288
|
-
eligibleClerkOperator,
|
|
1289
|
-
hasCredentialSlots: registry
|
|
1290
|
-
.listConnectors()
|
|
1291
|
-
.some((connector) => Boolean(connector.credential)),
|
|
1292
|
-
hasCredentialVault: Boolean(opts.credentialVault),
|
|
1293
|
-
});
|
|
1294
|
-
const data = await buildUiData(registry, baseUrl, serverInfo,
|
|
1295
|
-
// The static headless bearer may read connector health, but only a
|
|
1296
|
-
// Clerk-authenticated operator receives credential metadata.
|
|
1297
|
-
eligibleClerkOperator ? opts.credentialVault : undefined, Boolean(opts.activity?.list), credentialManagement, opts.toolkits, defer, eligibleClerkOperator);
|
|
1298
|
-
return privateJson(data);
|
|
1299
|
-
}
|
|
1300
|
-
if (path === "/ui/activity") {
|
|
1301
|
-
if (request.method !== "GET") {
|
|
1302
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1303
|
-
}
|
|
1304
|
-
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1305
|
-
if (!authz.ok)
|
|
1306
|
-
return authz.response;
|
|
1307
|
-
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1308
|
-
return restrictedOperatorSurface();
|
|
1309
|
-
}
|
|
1310
|
-
if (opts.activityReadGate &&
|
|
1311
|
-
!(await opts.activityReadGate(authz.actor))) {
|
|
1312
|
-
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
1313
|
-
}
|
|
1314
|
-
if (!opts.activity?.list) {
|
|
1315
|
-
return privateJson({ error: "activity history is not configured" }, { status: 404 });
|
|
1316
|
-
}
|
|
1317
|
-
const cursor = url.searchParams.get("cursor") ?? undefined;
|
|
1318
|
-
if (cursor && cursor.length > 500) {
|
|
1319
|
-
return privateJson({ error: "invalid cursor" }, { status: 400 });
|
|
1320
|
-
}
|
|
1321
|
-
const requestedLimit = Number(url.searchParams.get("limit") ?? "50");
|
|
1322
|
-
const limit = Number.isFinite(requestedLimit)
|
|
1323
|
-
? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
|
|
1324
|
-
: 50;
|
|
1325
|
-
try {
|
|
1326
|
-
const page = await opts.activity.list({ cursor, limit });
|
|
1327
|
-
return privateJson(await enrichActivityActorLabels(page, auth));
|
|
1328
|
-
}
|
|
1329
|
-
catch (error) {
|
|
1330
|
-
if (error instanceof InvalidActivityCursorError) {
|
|
1331
|
-
return privateJson({ error: error.message }, { status: 400 });
|
|
1332
|
-
}
|
|
1333
|
-
opts.logger.error("[connecta] activity read failed", error);
|
|
1334
|
-
return privateJson({ error: "activity history is temporarily unavailable" }, { status: 503 });
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
if (path === "/mcp") {
|
|
1338
|
-
let admission;
|
|
1339
|
-
try {
|
|
1340
|
-
admission = await opts.requestAdmission.acquire({
|
|
1341
|
-
signal: request.signal,
|
|
1342
|
-
});
|
|
1343
|
-
if (admission.waitMs > 0) {
|
|
1344
|
-
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
1345
|
-
waitMs: admission.waitMs,
|
|
1346
|
-
active: opts.requestAdmission.activeCount,
|
|
1347
|
-
queued: opts.requestAdmission.queuedCount,
|
|
1348
|
-
});
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
catch (error) {
|
|
1352
|
-
if (error instanceof ExecutorAdmissionError &&
|
|
1353
|
-
error.code === "executor_cancelled") {
|
|
1354
|
-
throw request.signal.reason ?? error;
|
|
1355
|
-
}
|
|
1356
|
-
if (error instanceof ExecutorAdmissionError) {
|
|
1357
|
-
if (error.code === "executor_overloaded") {
|
|
1358
|
-
warnAdmissionRejected(error);
|
|
1359
|
-
}
|
|
1360
|
-
return withMcpCors(requestAdmissionFailure(error));
|
|
1361
|
-
}
|
|
1362
|
-
throw error;
|
|
1363
|
-
}
|
|
1364
|
-
try {
|
|
1365
|
-
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
1366
|
-
// must not be able to probe which toolkit names exist.
|
|
1367
|
-
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1368
|
-
if (!authz.ok) {
|
|
1369
|
-
return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
|
|
1370
|
-
}
|
|
1371
|
-
const selected = resolveToolkitScope(url, registry, opts.toolkits, opts.logger, {
|
|
1372
|
-
actor: authz.actor,
|
|
1373
|
-
...(authz.toolkitBinding
|
|
1374
|
-
? { binding: authz.toolkitBinding }
|
|
1375
|
-
: {}),
|
|
1376
|
-
});
|
|
1377
|
-
if (!selected.ok) {
|
|
1378
|
-
return releaseAdmissionWithResponse(withMcpCors(selected.response), admission, request.signal);
|
|
1379
|
-
}
|
|
1380
|
-
sweepCredentials();
|
|
1381
|
-
return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, selected.scope, runtimeContext)), admission, request.signal);
|
|
1382
|
-
}
|
|
1383
|
-
catch (error) {
|
|
1384
|
-
admission.release();
|
|
1385
|
-
throw error;
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
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;
|
|
1388
163
|
// Connector-owned public routes, dispatched last: a connector can add a
|
|
1389
164
|
// route but never shadow one of connecta's own. A throw here is the
|
|
1390
165
|
// connector's bug, not a missing route, so it surfaces as 500 rather
|
|
@@ -1393,9 +168,9 @@ export function createFetchHandler(opts) {
|
|
|
1393
168
|
if (!connector.handleRequest)
|
|
1394
169
|
continue;
|
|
1395
170
|
try {
|
|
1396
|
-
const
|
|
1397
|
-
if (
|
|
1398
|
-
return
|
|
171
|
+
const response = await connector.handleRequest(request, registry.contextFor(connector.id, baseUrl));
|
|
172
|
+
if (response)
|
|
173
|
+
return response;
|
|
1399
174
|
}
|
|
1400
175
|
catch (error) {
|
|
1401
176
|
opts.logger.error(`[connecta] connector "${connector.id}" handleRequest failed`, error);
|