@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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
2
|
+
import { closeConnectorScope } from "../connector-scope.js";
|
|
3
|
+
import type {
|
|
4
|
+
ConnectorContext,
|
|
5
|
+
ConnectorStatus,
|
|
6
|
+
ConnectaBranding,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { isSafeHttpUrl, resolveBranding } from "../ui.js";
|
|
9
|
+
import {
|
|
10
|
+
authorizeUiAdmin,
|
|
11
|
+
isSameOrigin,
|
|
12
|
+
loggableValue,
|
|
13
|
+
msg,
|
|
14
|
+
privateJson,
|
|
15
|
+
type RouteContext,
|
|
16
|
+
} from "./shared.js";
|
|
17
|
+
|
|
18
|
+
async function handleOAuthManagementRequest(
|
|
19
|
+
context: RouteContext,
|
|
20
|
+
connectorId: string,
|
|
21
|
+
): Promise<Response> {
|
|
22
|
+
const { request, baseUrl, opts, defer } = context;
|
|
23
|
+
if (!isSameOrigin(request, baseUrl)) {
|
|
24
|
+
return privateJson(
|
|
25
|
+
{ error: "same-origin request required" },
|
|
26
|
+
{ status: 403 },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const admin = await authorizeUiAdmin(
|
|
30
|
+
request,
|
|
31
|
+
baseUrl,
|
|
32
|
+
opts.auth,
|
|
33
|
+
opts.logger,
|
|
34
|
+
"OAuth management",
|
|
35
|
+
);
|
|
36
|
+
if (!admin.ok) return admin.response;
|
|
37
|
+
|
|
38
|
+
const connector = opts.registry.getConnector(connectorId);
|
|
39
|
+
if (!connector?.disconnectAuth || !connector.startAuth) {
|
|
40
|
+
return privateJson(
|
|
41
|
+
{ error: "unknown OAuth connector" },
|
|
42
|
+
{ status: 404 },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (request.method !== "DELETE" && request.method !== "POST") {
|
|
46
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const requestScope = {};
|
|
50
|
+
const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope);
|
|
51
|
+
try {
|
|
52
|
+
let result: ConnectorStatus | undefined;
|
|
53
|
+
let operationError: unknown;
|
|
54
|
+
try {
|
|
55
|
+
if (request.method === "DELETE") {
|
|
56
|
+
await connector.disconnectAuth(ctx);
|
|
57
|
+
} else {
|
|
58
|
+
result = await connector.startAuth(ctx, { force: true });
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
operationError = error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The old grant and its catalog verdict are invalid after either operation,
|
|
65
|
+
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
66
|
+
try {
|
|
67
|
+
await opts.registry.invalidateStored(connectorId);
|
|
68
|
+
await opts.registry.clearCredentialHealth(connectorId);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
operationError ??= error;
|
|
71
|
+
}
|
|
72
|
+
if (operationError) {
|
|
73
|
+
return privateJson({ error: msg(operationError) }, { status: 400 });
|
|
74
|
+
}
|
|
75
|
+
if (request.method === "DELETE") {
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
status: 204,
|
|
78
|
+
headers: {
|
|
79
|
+
"Cache-Control": "no-store",
|
|
80
|
+
"Referrer-Policy": "no-referrer",
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const authorizationUrl = isSafeHttpUrl(result!.authorizationUrl)
|
|
86
|
+
? result!.authorizationUrl
|
|
87
|
+
: undefined;
|
|
88
|
+
if (result!.state === "error") {
|
|
89
|
+
return privateJson(
|
|
90
|
+
{ error: result!.message || "OAuth authorization could not start" },
|
|
91
|
+
{ status: 502 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (result!.state === "auth_required" && !authorizationUrl) {
|
|
95
|
+
return privateJson(
|
|
96
|
+
{
|
|
97
|
+
error:
|
|
98
|
+
result!.message ||
|
|
99
|
+
"OAuth authorization requires consent but no safe URL is available",
|
|
100
|
+
},
|
|
101
|
+
{ status: 502 },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return privateJson({
|
|
105
|
+
state: result!.state,
|
|
106
|
+
...(result!.message ? { message: result!.message } : {}),
|
|
107
|
+
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
108
|
+
});
|
|
109
|
+
} finally {
|
|
110
|
+
await closeConnectorScope(connector, ctx, defer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function routeOAuthManagement(
|
|
115
|
+
context: RouteContext,
|
|
116
|
+
): Promise<Response | null> {
|
|
117
|
+
const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
|
|
118
|
+
if (!match) return null;
|
|
119
|
+
const connectorId = match[1];
|
|
120
|
+
if (!connectorId) return null;
|
|
121
|
+
if (context.request.method === "OPTIONS") {
|
|
122
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
123
|
+
}
|
|
124
|
+
return handleOAuthManagementRequest(context, connectorId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function escapeHtml(value: string): string {
|
|
128
|
+
return value
|
|
129
|
+
.replaceAll("&", "&")
|
|
130
|
+
.replaceAll("<", "<")
|
|
131
|
+
.replaceAll(">", ">")
|
|
132
|
+
.replaceAll('"', """);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `body` is escaped — callback params and error messages are attacker-influenced. */
|
|
136
|
+
function html(
|
|
137
|
+
body: string,
|
|
138
|
+
status = 200,
|
|
139
|
+
branding?: ConnectaBranding,
|
|
140
|
+
): Response {
|
|
141
|
+
const brand = resolveBranding(branding);
|
|
142
|
+
const title = brand.pageTitle;
|
|
143
|
+
const owner = brand.ownerName
|
|
144
|
+
? brand.ownerUrl
|
|
145
|
+
? `<a class="brand" href="${escapeHtml(brand.ownerUrl)}">${escapeHtml(brand.ownerName)}</a>`
|
|
146
|
+
: `<span class="brand">${escapeHtml(brand.ownerName)}</span>`
|
|
147
|
+
: brand.productUrl
|
|
148
|
+
? `<a class="brand" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
|
|
149
|
+
: `<span class="brand">${escapeHtml(brand.productName)}</span>`;
|
|
150
|
+
const product = brand.ownerName
|
|
151
|
+
? brand.productUrl
|
|
152
|
+
? `<a class="product" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
|
|
153
|
+
: `<span class="product">${escapeHtml(brand.productName)}</span>`
|
|
154
|
+
: "";
|
|
155
|
+
return new Response(
|
|
156
|
+
`<!doctype html>
|
|
157
|
+
<html lang="en">
|
|
158
|
+
<head>
|
|
159
|
+
<meta charset="utf-8">
|
|
160
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
161
|
+
<meta name="theme-color" content="${escapeHtml(brand.themeColor)}">
|
|
162
|
+
<link rel="icon" href="${escapeHtml(brand.faviconHref)}" type="image/svg+xml">
|
|
163
|
+
<link rel="shortcut icon" href="/favicon.ico">
|
|
164
|
+
<title>${escapeHtml(title)}</title>
|
|
165
|
+
<style>
|
|
166
|
+
* { border-radius: 0; box-sizing: border-box; }
|
|
167
|
+
html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
|
|
168
|
+
Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
169
|
+
body { margin: 0; min-height: 100vh; }
|
|
170
|
+
::selection { color: #fff; background: #000; }
|
|
171
|
+
.shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
|
|
172
|
+
.grid { display: grid; gap: 1rem 1.5rem;
|
|
173
|
+
grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
174
|
+
.brand { font-weight: 500; grid-column: 1; text-decoration: none; }
|
|
175
|
+
.product { grid-column: 2 / -1; }
|
|
176
|
+
main { margin-top: 5rem; }
|
|
177
|
+
h1, p { font: inherit; margin: 0; }
|
|
178
|
+
h1 { grid-column: 1; }
|
|
179
|
+
.copy { grid-column: 2 / -1; max-width: 34em; }
|
|
180
|
+
.copy > * + * { margin-top: 1.5rem; }
|
|
181
|
+
a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
|
|
182
|
+
text-underline-offset: .22em; }
|
|
183
|
+
a:hover { text-decoration-color: transparent; }
|
|
184
|
+
a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
|
|
185
|
+
@media (max-width: 36.99rem) {
|
|
186
|
+
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
187
|
+
.product { grid-column: 2; }
|
|
188
|
+
main { margin-top: 3rem; }
|
|
189
|
+
h1, .copy { grid-column: 1 / -1; }
|
|
190
|
+
}
|
|
191
|
+
</style>
|
|
192
|
+
</head>
|
|
193
|
+
<body>
|
|
194
|
+
<header class="shell grid">
|
|
195
|
+
${owner}
|
|
196
|
+
${product}
|
|
197
|
+
</header>
|
|
198
|
+
<main class="shell grid">
|
|
199
|
+
<h1>Connection status</h1>
|
|
200
|
+
<div class="copy">
|
|
201
|
+
<p>${escapeHtml(body)}</p>
|
|
202
|
+
<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
|
|
203
|
+
</div>
|
|
204
|
+
</main>
|
|
205
|
+
</body>
|
|
206
|
+
</html>`,
|
|
207
|
+
{ status, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
213
|
+
* paths that would otherwise pay nothing.
|
|
214
|
+
*
|
|
215
|
+
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
216
|
+
* `KvOAuthProvider.verifyState` reads `oauth:state` and its generation before
|
|
217
|
+
* it can reject a mismatched value, so a configured id costs two storage round
|
|
218
|
+
* trips on the ordinary path while an id naming nothing used to touch no I/O.
|
|
219
|
+
* That gap is an oracle: sample the two and a wordlist recovers the connector
|
|
220
|
+
* list the flat 400 was meant to withhold. So zero-I/O refusals read the same
|
|
221
|
+
* keys in the same `conn:<id>:` namespace, where an unconfigured id gets misses.
|
|
222
|
+
*
|
|
223
|
+
* This is deliberately *not* a constant-time claim, and documentation/connectors.md says
|
|
224
|
+
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
225
|
+
* shipping its own `verifyState` may do more or less work. What it
|
|
226
|
+
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
227
|
+
* which is the only part of the signal that makes enumeration cheap.
|
|
228
|
+
*
|
|
229
|
+
* A throwing read is swallowed: the refusal is the answer either way, and
|
|
230
|
+
* turning it into a 500 would hand back exactly the distinguishable response
|
|
231
|
+
* this whole path exists to deny.
|
|
232
|
+
*/
|
|
233
|
+
async function equalizeRefusalCost(
|
|
234
|
+
context: ConnectorContext,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
try {
|
|
237
|
+
const generation = await context.storage.get("oauth:generation");
|
|
238
|
+
await context.storage.get(
|
|
239
|
+
oauthValueStorageKey("oauth:state", generation),
|
|
240
|
+
);
|
|
241
|
+
} catch {
|
|
242
|
+
// Deliberately ignored — see above.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function routeOAuthCallback(
|
|
247
|
+
context: RouteContext,
|
|
248
|
+
): Promise<Response | null> {
|
|
249
|
+
const { path, url, baseUrl, opts } = context;
|
|
250
|
+
if (!path.startsWith("/oauth/callback/")) return null;
|
|
251
|
+
const error = url.searchParams.get("error");
|
|
252
|
+
if (error) return html(`Authorization denied: ${error}`, 400, opts.branding);
|
|
253
|
+
const code = url.searchParams.get("code");
|
|
254
|
+
if (!code) return html("Missing authorization code.", 400, opts.branding);
|
|
255
|
+
const id = path.slice("/oauth/callback/".length);
|
|
256
|
+
const connector = opts.registry.getConnector(id);
|
|
257
|
+
// Safe to build before we know the id names anything: `contextFor` is a pure
|
|
258
|
+
// constructor — a namespaced storage view over `conn:<id>:` and, only for a
|
|
259
|
+
// connector that declares one, a lazy credential accessor. It neither throws
|
|
260
|
+
// nor touches storage for an unknown id, which is what lets the refusals
|
|
261
|
+
// below borrow it to equalize their cost.
|
|
262
|
+
const connectorContext = opts.registry.contextFor(id, baseUrl);
|
|
263
|
+
const refused = () =>
|
|
264
|
+
html(
|
|
265
|
+
"Authorization could not be completed. Re-run authorization from " +
|
|
266
|
+
"connecta and try again.",
|
|
267
|
+
400,
|
|
268
|
+
opts.branding,
|
|
269
|
+
);
|
|
270
|
+
if (!connector || !connector.finishAuth) {
|
|
271
|
+
await equalizeRefusalCost(connectorContext);
|
|
272
|
+
return refused();
|
|
273
|
+
}
|
|
274
|
+
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
275
|
+
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
276
|
+
if (!connector.verifyState) {
|
|
277
|
+
await equalizeRefusalCost(connectorContext);
|
|
278
|
+
opts.logger.warn(
|
|
279
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
280
|
+
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
281
|
+
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
282
|
+
"No authorization code was exchanged. Implement verifyState before " +
|
|
283
|
+
"trying again.",
|
|
284
|
+
);
|
|
285
|
+
return refused();
|
|
286
|
+
}
|
|
287
|
+
const state = url.searchParams.get("state");
|
|
288
|
+
let stateMatches: boolean;
|
|
289
|
+
try {
|
|
290
|
+
stateMatches = await connector.verifyState(state, connectorContext);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
opts.logger.warn(
|
|
293
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
294
|
+
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
295
|
+
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
296
|
+
"Re-run authorization from connecta and check the verifier if it " +
|
|
297
|
+
"fails again.",
|
|
298
|
+
);
|
|
299
|
+
return refused();
|
|
300
|
+
}
|
|
301
|
+
if (!stateMatches) {
|
|
302
|
+
opts.logger.warn(
|
|
303
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
304
|
+
`${loggableValue(id)} with 400: ` +
|
|
305
|
+
(state === null
|
|
306
|
+
? "the state parameter was missing"
|
|
307
|
+
: "the state did not match the pending authorization flow") +
|
|
308
|
+
". No authorization code was exchanged. Re-run authorization from " +
|
|
309
|
+
"connecta and try again.",
|
|
310
|
+
);
|
|
311
|
+
return refused();
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
await connector.finishAuth(code, connectorContext);
|
|
315
|
+
await opts.registry.invalidateStored(id);
|
|
316
|
+
// Recovery, without a restart: the grant this connector was reported dead
|
|
317
|
+
// for has just been replaced, so drop the verdict rather than let a stale
|
|
318
|
+
// `auth_required` survive until the next scheduled check.
|
|
319
|
+
await opts.registry.clearCredentialHealth(id);
|
|
320
|
+
return html(
|
|
321
|
+
`Connected "${id}". You can close this window.`,
|
|
322
|
+
200,
|
|
323
|
+
opts.branding,
|
|
324
|
+
);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return html(`Authorization failed: ${msg(err)}`, 500, opts.branding);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity.js";
|
|
3
|
+
import type { CredentialVault } from "../credentials.js";
|
|
4
|
+
import type { DeferredWork } from "../connector-scope.js";
|
|
5
|
+
import type { AdmissionController } from "../executor-admission.js";
|
|
6
|
+
import type { Registry } from "../registry.js";
|
|
7
|
+
import { resolveIdentityBinding, type Toolkit } from "../toolkits.js";
|
|
8
|
+
import type {
|
|
9
|
+
ConnectaBranding,
|
|
10
|
+
Executor,
|
|
11
|
+
InboundAuth,
|
|
12
|
+
Logger,
|
|
13
|
+
ToolkitBinding,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
import { operatorPageForPath } from "../ui.js";
|
|
16
|
+
|
|
17
|
+
export interface ServerOptions {
|
|
18
|
+
registry: Registry;
|
|
19
|
+
auth: InboundAuth[];
|
|
20
|
+
publicUrl?: string;
|
|
21
|
+
// The SDK's Implementation shape: name/version plus optional title,
|
|
22
|
+
// websiteUrl, and icons (MCP icons spec) that clients may render.
|
|
23
|
+
serverInfo: ConstructorParameters<typeof McpServer>[0];
|
|
24
|
+
logger: Logger;
|
|
25
|
+
activity?: ActivityStore;
|
|
26
|
+
activityReadGate?: ActivityReadGate;
|
|
27
|
+
activityDeploymentId?: string;
|
|
28
|
+
deploymentInfo?: Record<string, unknown>;
|
|
29
|
+
/** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
|
|
30
|
+
defaultToolTimeoutMs?: number;
|
|
31
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
32
|
+
probeTimeoutMs?: number;
|
|
33
|
+
/** Maximum simultaneous connector discovery operations. Default 4. */
|
|
34
|
+
discoveryConcurrency?: number;
|
|
35
|
+
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
36
|
+
executor?: Executor;
|
|
37
|
+
/** Global FIFO boundary for all non-preflight `/mcp` requests. */
|
|
38
|
+
requestAdmission: AdmissionController;
|
|
39
|
+
/** Encrypted connector-credential storage backing the Credentials page. */
|
|
40
|
+
credentialVault?: CredentialVault;
|
|
41
|
+
/** Optional browser UI and OAuth result-page labels. */
|
|
42
|
+
branding?: ConnectaBranding;
|
|
43
|
+
/**
|
|
44
|
+
* Validated named scopes, selected per connection with `?toolkit=<name>` on
|
|
45
|
+
* `/mcp`. Omit (or leave empty) and every connection sees the full registry.
|
|
46
|
+
*/
|
|
47
|
+
toolkits?: ReadonlyMap<string, Toolkit>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RuntimeExecutionContext {
|
|
51
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RouteContext {
|
|
55
|
+
request: Request;
|
|
56
|
+
url: URL;
|
|
57
|
+
path: string;
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
opts: ServerOptions;
|
|
60
|
+
defer: DeferredWork | undefined;
|
|
61
|
+
runtimeContext: RuntimeExecutionContext | undefined;
|
|
62
|
+
sweepCredentials(): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function msg(err: unknown): string {
|
|
66
|
+
return err instanceof Error ? err.message : String(err);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function privateJson(
|
|
70
|
+
body: unknown,
|
|
71
|
+
init: ResponseInit = {},
|
|
72
|
+
): Response {
|
|
73
|
+
const headers = new Headers(init.headers);
|
|
74
|
+
headers.set("Content-Type", "application/json");
|
|
75
|
+
headers.set("Cache-Control", "no-store");
|
|
76
|
+
headers.set("Referrer-Policy", "no-referrer");
|
|
77
|
+
return new Response(JSON.stringify(body), { ...init, headers });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const MAX_LOGGED_VALUE_LENGTH = 64;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
|
|
84
|
+
* identity id, or OAuth callback connector id) for the operator log. Goes
|
|
85
|
+
* through JSON.stringify so a caller-controlled newline or control character
|
|
86
|
+
* cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
|
|
87
|
+
* JSON.stringify leaves raw even though a log reader treats them as line
|
|
88
|
+
* terminators. Truncated to a small shared cap (also the toolkit response's echo
|
|
89
|
+
* limit), so an oversized value cannot flood the log either.
|
|
90
|
+
*/
|
|
91
|
+
export function loggableValue(requested: string): string {
|
|
92
|
+
const bounded = requested.slice(0, MAX_LOGGED_VALUE_LENGTH);
|
|
93
|
+
const escaped = JSON.stringify(bounded).replace(
|
|
94
|
+
/[\u2028\u2029]/g,
|
|
95
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
|
|
96
|
+
);
|
|
97
|
+
return escaped + (bounded.length < requested.length ? " (truncated)" : "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
101
|
+
export const MAX_ECHOED_TOOLKIT_NAME = MAX_LOGGED_VALUE_LENGTH;
|
|
102
|
+
|
|
103
|
+
const ACTIVITY_ACTOR_NAMESPACE_RE = /^[\x21-\x7e]{1,256}$/;
|
|
104
|
+
|
|
105
|
+
export function activityActorNamespace(
|
|
106
|
+
provider: InboundAuth,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
return typeof provider.activityActorNamespace === "string" &&
|
|
109
|
+
ACTIVITY_ACTOR_NAMESPACE_RE.test(provider.activityActorNamespace)
|
|
110
|
+
? provider.activityActorNamespace
|
|
111
|
+
: undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Refusal for an identity whose toolkit binding cannot be trusted — a malformed
|
|
116
|
+
* declaration, or a malformed per-identity binding out of `authorize`. The
|
|
117
|
+
* caller is authenticated, so this is a 403, and it is deliberately opaque: the
|
|
118
|
+
* cause is an operator bug, and the operator reads it in the log, not the client.
|
|
119
|
+
*/
|
|
120
|
+
function unusableBinding(): Response {
|
|
121
|
+
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function authorize(
|
|
125
|
+
request: Request,
|
|
126
|
+
baseUrl: string,
|
|
127
|
+
auth: InboundAuth[],
|
|
128
|
+
logger: Logger,
|
|
129
|
+
): Promise<
|
|
130
|
+
| {
|
|
131
|
+
ok: true;
|
|
132
|
+
actor: ActivityActor;
|
|
133
|
+
/** True only when the admitting provider can also authorize UI mutation. */
|
|
134
|
+
uiAdminEligible?: boolean;
|
|
135
|
+
/** The admitting identity's toolkit binding (documentation/toolkits.md). */
|
|
136
|
+
toolkitBinding?: ToolkitBinding;
|
|
137
|
+
}
|
|
138
|
+
| { ok: false; response: Response }
|
|
139
|
+
> {
|
|
140
|
+
if (auth.length === 0) {
|
|
141
|
+
return { ok: true, actor: { kind: "anonymous" } };
|
|
142
|
+
}
|
|
143
|
+
let lastResponse: Response | null = null;
|
|
144
|
+
for (const provider of auth) {
|
|
145
|
+
const result = await provider.authorize(request, baseUrl);
|
|
146
|
+
if (result.ok) {
|
|
147
|
+
const subjectId = result.subjectId ?? result.userId;
|
|
148
|
+
// Re-validate both halves and cap the per-identity one by the provider's
|
|
149
|
+
// declaration (see resolveIdentityBinding). A binding that does not
|
|
150
|
+
// type-check at runtime refuses the request rather than evaporating:
|
|
151
|
+
// dropping it would hand the caller the full registry, which is the one
|
|
152
|
+
// outcome a binding exists to prevent.
|
|
153
|
+
const binding = resolveIdentityBinding(
|
|
154
|
+
provider.toolkitBinding,
|
|
155
|
+
result.toolkitBinding,
|
|
156
|
+
);
|
|
157
|
+
if (!binding.ok) {
|
|
158
|
+
logger.warn(
|
|
159
|
+
`[connecta] refused a request admitted by inbound auth provider ` +
|
|
160
|
+
`"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
|
|
161
|
+
"this provider cannot admit anyone, because connecta cannot tell " +
|
|
162
|
+
"which toolkits the identity may use.",
|
|
163
|
+
);
|
|
164
|
+
return { ok: false, response: unusableBinding() };
|
|
165
|
+
}
|
|
166
|
+
const actorNamespace = activityActorNamespace(provider);
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
actor: {
|
|
170
|
+
kind: provider.kind,
|
|
171
|
+
...(subjectId ? { id: subjectId } : {}),
|
|
172
|
+
...(subjectId && actorNamespace
|
|
173
|
+
? { namespace: actorNamespace }
|
|
174
|
+
: {}),
|
|
175
|
+
},
|
|
176
|
+
...(result.userId && provider.uiAuth?.kind === "clerk"
|
|
177
|
+
? { uiAdminEligible: true }
|
|
178
|
+
: {}),
|
|
179
|
+
...(binding.binding ? { toolkitBinding: binding.binding } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
lastResponse = result.response;
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
response:
|
|
187
|
+
lastResponse ??
|
|
188
|
+
new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
189
|
+
status: 401,
|
|
190
|
+
headers: {
|
|
191
|
+
"Content-Type": "application/json",
|
|
192
|
+
"WWW-Authenticate": "Bearer",
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when this identity is confined to one or more toolkits — bound, without
|
|
200
|
+
* `unscoped`. Such a credential belongs to a team's agent, not to the operator
|
|
201
|
+
* running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
|
|
202
|
+
* `/ui/activity`, the credential API) refuse it: their payloads describe every
|
|
203
|
+
* connector in the org, which is exactly what the binding exists to withhold.
|
|
204
|
+
*/
|
|
205
|
+
export function isToolkitRestricted(
|
|
206
|
+
binding: ToolkitBinding | undefined,
|
|
207
|
+
): boolean {
|
|
208
|
+
return Boolean(binding && !binding.unscoped);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The refusal the deployment-wide operator surfaces give a bound identity. */
|
|
212
|
+
export function restrictedOperatorSurface(): Response {
|
|
213
|
+
return privateJson(
|
|
214
|
+
{
|
|
215
|
+
error:
|
|
216
|
+
"this credential is bound to a toolkit and may not read " +
|
|
217
|
+
"deployment-wide operator data",
|
|
218
|
+
},
|
|
219
|
+
{ status: 403 },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function authorizeUiAdmin(
|
|
224
|
+
request: Request,
|
|
225
|
+
baseUrl: string,
|
|
226
|
+
auth: InboundAuth[],
|
|
227
|
+
logger: Logger,
|
|
228
|
+
purpose = "credential management",
|
|
229
|
+
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
|
|
230
|
+
// Operator mutation is intentionally narrower than /mcp and /ui/data: only
|
|
231
|
+
// an interactive Clerk provider may admit it. A static bearer token is useful
|
|
232
|
+
// for headless tool calls but must not become a deployment-admin key.
|
|
233
|
+
//
|
|
234
|
+
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
235
|
+
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
236
|
+
// `gate` and `toolkits` (documentation/toolkits.md). Stopping at the first would make
|
|
237
|
+
// admission depend on config order: the team-bound provider listed first
|
|
238
|
+
// would refuse the operator outright, and a refusal here — a failed gate, a
|
|
239
|
+
// missing user, a toolkit-bound identity — is exactly the case where a later
|
|
240
|
+
// provider is the one meant to admit. The last refusal is returned if none do.
|
|
241
|
+
const providers = auth.filter(
|
|
242
|
+
(candidate) => candidate.uiAuth?.kind === "clerk",
|
|
243
|
+
);
|
|
244
|
+
if (providers.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
response: privateJson(
|
|
248
|
+
{ error: `${purpose} requires Clerk authentication` },
|
|
249
|
+
{ status: 403 },
|
|
250
|
+
),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
let lastResponse: Response | null = null;
|
|
254
|
+
for (const provider of providers) {
|
|
255
|
+
const result = await provider.authorize(request, baseUrl);
|
|
256
|
+
if (!result.ok) {
|
|
257
|
+
lastResponse = result.response;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!result.userId) {
|
|
261
|
+
lastResponse = privateJson(
|
|
262
|
+
{ error: "authenticated user required" },
|
|
263
|
+
{ status: 403 },
|
|
264
|
+
);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const binding = resolveIdentityBinding(
|
|
268
|
+
provider.toolkitBinding,
|
|
269
|
+
result.toolkitBinding,
|
|
270
|
+
);
|
|
271
|
+
if (!binding.ok) {
|
|
272
|
+
logger.warn(
|
|
273
|
+
`[connecta] refused an operator-mutation request admitted by inbound auth ` +
|
|
274
|
+
`provider "${provider.kind}" with 403: ${binding.reason}.`,
|
|
275
|
+
);
|
|
276
|
+
lastResponse = unusableBinding();
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// A toolkit-bound identity is a team's credential, not a vault admin key:
|
|
280
|
+
// credentials are deployment-wide, so writing one reaches every toolkit.
|
|
281
|
+
if (isToolkitRestricted(binding.binding)) {
|
|
282
|
+
lastResponse = restrictedOperatorSurface();
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
return { ok: true, userId: result.userId };
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
response:
|
|
290
|
+
lastResponse ??
|
|
291
|
+
privateJson({ error: "forbidden" }, { status: 403 }),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function isSameOrigin(request: Request, baseUrl: string): boolean {
|
|
296
|
+
const origin = request.headers.get("origin");
|
|
297
|
+
if (!origin) return false;
|
|
298
|
+
try {
|
|
299
|
+
return new URL(origin).origin === new URL(baseUrl).origin;
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function withSecurityHeaders(
|
|
306
|
+
response: Response,
|
|
307
|
+
requestUrl: URL,
|
|
308
|
+
path: string,
|
|
309
|
+
): Response {
|
|
310
|
+
const headers = new Headers(response.headers);
|
|
311
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
312
|
+
headers.set("Referrer-Policy", "no-referrer");
|
|
313
|
+
if (requestUrl.protocol === "https:") {
|
|
314
|
+
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
315
|
+
}
|
|
316
|
+
if (operatorPageForPath(path) || path === "/ui") {
|
|
317
|
+
// Operator HTML responses ship their own nonce-based script CSP (which
|
|
318
|
+
// already includes frame-ancestors 'none'); only fall back to the
|
|
319
|
+
// framing-only directive when no CSP is present (for example redirects).
|
|
320
|
+
if (!headers.has("Content-Security-Policy")) {
|
|
321
|
+
headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
322
|
+
}
|
|
323
|
+
headers.set("X-Frame-Options", "DENY");
|
|
324
|
+
}
|
|
325
|
+
return new Response(response.body, {
|
|
326
|
+
status: response.status,
|
|
327
|
+
statusText: response.statusText,
|
|
328
|
+
headers,
|
|
329
|
+
});
|
|
330
|
+
}
|