@zackbart/connecta 0.7.5 → 0.7.7
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 +108 -0
- package/README.md +1 -0
- package/dist/activity.d.ts +17 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +101 -0
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +57 -20
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +275 -67
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +165 -103
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +11 -0
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +18 -1
- package/dist/executor-admission.d.ts.map +1 -1
- package/dist/executor-admission.js +82 -3
- package/dist/executor-admission.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +1 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-protocol.js +7 -4
- package/dist/executors/quickjs-protocol.js.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +22 -9
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +44 -10
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +31 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -15
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +413 -35
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +8 -0
- package/dist/storage/file.js.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +7 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +118 -4
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/src/activity.ts +20 -0
- package/src/auth/clerk.ts +124 -0
- package/src/auth/downstream-oauth.ts +359 -68
- package/src/connectors/remote-mcp.ts +172 -104
- package/src/execute.ts +11 -0
- package/src/executor-admission.ts +90 -3
- package/src/executors/quickjs-protocol.ts +7 -4
- package/src/executors/quickjs-runtime.ts +31 -9
- package/src/executors/quickjs.ts +61 -12
- package/src/index.ts +90 -1
- package/src/registry.ts +79 -19
- package/src/server.ts +523 -45
- package/src/storage/file.ts +7 -0
- package/src/types.ts +50 -0
- package/src/ui.ts +124 -3
- package/src/version.ts +1 -1
package/dist/server.js
CHANGED
|
@@ -8,7 +8,10 @@ import { credentialTestRule, describeCredentialTestMismatch, storedCredentialSha
|
|
|
8
8
|
import { ScopedRegistry } from "./registry.js";
|
|
9
9
|
import { resolveIdentityBinding, TOOLKIT_NAME_RE, } from "./toolkits.js";
|
|
10
10
|
import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
11
|
-
import {
|
|
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";
|
|
12
15
|
const CORS_HEADERS = {
|
|
13
16
|
"Access-Control-Allow-Origin": "*",
|
|
14
17
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
@@ -158,11 +161,15 @@ async function authorize(request, baseUrl, auth, logger) {
|
|
|
158
161
|
"which toolkits the identity may use.");
|
|
159
162
|
return { ok: false, response: unusableBinding() };
|
|
160
163
|
}
|
|
164
|
+
const actorNamespace = activityActorNamespace(provider);
|
|
161
165
|
return {
|
|
162
166
|
ok: true,
|
|
163
167
|
actor: {
|
|
164
168
|
kind: provider.kind,
|
|
165
169
|
...(subjectId ? { id: subjectId } : {}),
|
|
170
|
+
...(subjectId && actorNamespace
|
|
171
|
+
? { namespace: actorNamespace }
|
|
172
|
+
: {}),
|
|
166
173
|
},
|
|
167
174
|
...(result.userId && provider.uiAuth?.kind === "clerk"
|
|
168
175
|
? { uiAdminEligible: true }
|
|
@@ -199,13 +206,102 @@ function withMcpCors(response) {
|
|
|
199
206
|
for (const [name, value] of Object.entries(CORS_HEADERS)) {
|
|
200
207
|
headers.set(name, value);
|
|
201
208
|
}
|
|
202
|
-
headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, mcp-session-id, mcp-protocol-version");
|
|
209
|
+
headers.set("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version");
|
|
203
210
|
return new Response(response.body, {
|
|
204
211
|
status: response.status,
|
|
205
212
|
statusText: response.statusText,
|
|
206
213
|
headers,
|
|
207
214
|
});
|
|
208
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
|
+
}
|
|
209
305
|
function withSecurityHeaders(response, requestUrl, path) {
|
|
210
306
|
const headers = new Headers(response.headers);
|
|
211
307
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
@@ -228,10 +324,10 @@ function withSecurityHeaders(response, requestUrl, path) {
|
|
|
228
324
|
headers,
|
|
229
325
|
});
|
|
230
326
|
}
|
|
231
|
-
async function authorizeUiAdmin(request, baseUrl, auth, logger) {
|
|
232
|
-
//
|
|
327
|
+
async function authorizeUiAdmin(request, baseUrl, auth, logger, purpose = "credential management") {
|
|
328
|
+
// Operator mutation is intentionally narrower than /mcp and /ui/data: only
|
|
233
329
|
// an interactive Clerk provider may admit it. A static bearer token is useful
|
|
234
|
-
// for headless tool calls but must not become a
|
|
330
|
+
// for headless tool calls but must not become a deployment-admin key.
|
|
235
331
|
//
|
|
236
332
|
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
237
333
|
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
@@ -244,7 +340,7 @@ async function authorizeUiAdmin(request, baseUrl, auth, logger) {
|
|
|
244
340
|
if (providers.length === 0) {
|
|
245
341
|
return {
|
|
246
342
|
ok: false,
|
|
247
|
-
response: privateJson({ error:
|
|
343
|
+
response: privateJson({ error: `${purpose} requires Clerk authentication` }, { status: 403 }),
|
|
248
344
|
};
|
|
249
345
|
}
|
|
250
346
|
let lastResponse = null;
|
|
@@ -260,7 +356,7 @@ async function authorizeUiAdmin(request, baseUrl, auth, logger) {
|
|
|
260
356
|
}
|
|
261
357
|
const binding = resolveIdentityBinding(provider.toolkitBinding, result.toolkitBinding);
|
|
262
358
|
if (!binding.ok) {
|
|
263
|
-
logger.warn(`[connecta] refused
|
|
359
|
+
logger.warn(`[connecta] refused an operator-mutation request admitted by inbound auth ` +
|
|
264
360
|
`provider "${provider.kind}" with 403: ${binding.reason}.`);
|
|
265
361
|
lastResponse = unusableBinding();
|
|
266
362
|
continue;
|
|
@@ -431,7 +527,7 @@ async function handleCredentialRequest(request, connectorId, action, opts, baseU
|
|
|
431
527
|
const metadata = input.input.kind === "single"
|
|
432
528
|
? await opts.credentialVault.set(connectorId, input.input.value, admin.userId)
|
|
433
529
|
: await opts.credentialVault.setAll(connectorId, input.input.values, admin.userId);
|
|
434
|
-
opts.registry.
|
|
530
|
+
await opts.registry.invalidateStored(connectorId);
|
|
435
531
|
// The credential the last verdict judged is gone; judging its replacement
|
|
436
532
|
// is the next check's job, not this one's.
|
|
437
533
|
await opts.registry.clearCredentialHealth(connectorId);
|
|
@@ -443,7 +539,7 @@ async function handleCredentialRequest(request, connectorId, action, opts, baseU
|
|
|
443
539
|
}
|
|
444
540
|
if (request.method === "DELETE") {
|
|
445
541
|
await opts.credentialVault.delete(connectorId);
|
|
446
|
-
opts.registry.
|
|
542
|
+
await opts.registry.invalidateStored(connectorId);
|
|
447
543
|
await opts.registry.clearCredentialHealth(connectorId);
|
|
448
544
|
return new Response(null, {
|
|
449
545
|
status: 204,
|
|
@@ -455,6 +551,79 @@ async function handleCredentialRequest(request, connectorId, action, opts, baseU
|
|
|
455
551
|
}
|
|
456
552
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
457
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
|
+
}
|
|
458
627
|
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
459
628
|
const MAX_ECHOED_TOOLKIT_NAME = 64;
|
|
460
629
|
/**
|
|
@@ -504,6 +673,138 @@ function toolkitForbidden() {
|
|
|
504
673
|
function identityLabel(actor) {
|
|
505
674
|
return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
|
|
506
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
|
+
}
|
|
507
808
|
/**
|
|
508
809
|
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
509
810
|
* enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
|
|
@@ -673,17 +974,16 @@ async function serveMcp(request, opts, baseUrl, actor, scope, runtimeContext) {
|
|
|
673
974
|
* paths that would otherwise pay nothing.
|
|
674
975
|
*
|
|
675
976
|
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
676
|
-
* `KvOAuthProvider.verifyState` reads `oauth:state`
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* `conn:<id>:` namespace, which for an unconfigured id is simply a miss.
|
|
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.
|
|
683
983
|
*
|
|
684
984
|
* This is deliberately *not* a constant-time claim, and docs/connectors.md says
|
|
685
985
|
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
686
|
-
* shipping its own `verifyState` may do more or less work
|
|
986
|
+
* shipping its own `verifyState` may do more or less work. What it
|
|
687
987
|
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
688
988
|
* which is the only part of the signal that makes enumeration cheap.
|
|
689
989
|
*
|
|
@@ -693,7 +993,8 @@ async function serveMcp(request, opts, baseUrl, actor, scope, runtimeContext) {
|
|
|
693
993
|
*/
|
|
694
994
|
async function equalizeRefusalCost(context) {
|
|
695
995
|
try {
|
|
696
|
-
await context.storage.get("oauth:
|
|
996
|
+
const generation = await context.storage.get("oauth:generation");
|
|
997
|
+
await context.storage.get(oauthValueStorageKey("oauth:state", generation));
|
|
697
998
|
}
|
|
698
999
|
catch {
|
|
699
1000
|
// Deliberately ignored — see above.
|
|
@@ -770,6 +1071,23 @@ async function handleOAuthCallback(url, registry, baseUrl, logger, branding) {
|
|
|
770
1071
|
/** Build the Web-standard fetch handler that serves connecta. */
|
|
771
1072
|
export function createFetchHandler(opts) {
|
|
772
1073
|
const { registry, auth, publicUrl, serverInfo } = opts;
|
|
1074
|
+
let lastAdmissionWarningAt = 0;
|
|
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
|
+
};
|
|
773
1091
|
return async function fetch(request, runtimeContext) {
|
|
774
1092
|
const url = new URL(request.url);
|
|
775
1093
|
const baseUrl = publicUrl ?? url.origin;
|
|
@@ -843,6 +1161,13 @@ export function createFetchHandler(opts) {
|
|
|
843
1161
|
}
|
|
844
1162
|
return handleCredentialRequest(request, credentialMatch[1], credentialMatch[2], opts, baseUrl);
|
|
845
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
|
+
}
|
|
846
1171
|
if (request.method === "OPTIONS") {
|
|
847
1172
|
for (const a of auth) {
|
|
848
1173
|
if (a.handleMetadata) {
|
|
@@ -864,10 +1189,28 @@ export function createFetchHandler(opts) {
|
|
|
864
1189
|
return new Response("Not Found", { status: 404 });
|
|
865
1190
|
}
|
|
866
1191
|
if (path === "/health") {
|
|
1192
|
+
const codeAdmission = opts.executor && isAdmittingExecutor(opts.executor)
|
|
1193
|
+
? opts.executor.admissionSnapshot?.()
|
|
1194
|
+
: undefined;
|
|
867
1195
|
return Response.json({
|
|
868
1196
|
status: "ok",
|
|
869
1197
|
connectors: registry.listConnectors().length,
|
|
870
1198
|
server: opts.serverInfo,
|
|
1199
|
+
admission: {
|
|
1200
|
+
policy: "global-fifo",
|
|
1201
|
+
requests: opts.requestAdmission.snapshot(),
|
|
1202
|
+
code: opts.executor
|
|
1203
|
+
? (codeAdmission ?? { managedByExecutor: true })
|
|
1204
|
+
: null,
|
|
1205
|
+
reservedRoutes: [
|
|
1206
|
+
"/health",
|
|
1207
|
+
"/",
|
|
1208
|
+
"/credentials",
|
|
1209
|
+
"/activity",
|
|
1210
|
+
"/ui",
|
|
1211
|
+
"/ui/*",
|
|
1212
|
+
],
|
|
1213
|
+
},
|
|
871
1214
|
...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
|
|
872
1215
|
});
|
|
873
1216
|
}
|
|
@@ -951,7 +1294,7 @@ export function createFetchHandler(opts) {
|
|
|
951
1294
|
const data = await buildUiData(registry, baseUrl, serverInfo,
|
|
952
1295
|
// The static headless bearer may read connector health, but only a
|
|
953
1296
|
// Clerk-authenticated operator receives credential metadata.
|
|
954
|
-
eligibleClerkOperator ? opts.credentialVault : undefined, Boolean(opts.activity?.list), credentialManagement, opts.toolkits, defer);
|
|
1297
|
+
eligibleClerkOperator ? opts.credentialVault : undefined, Boolean(opts.activity?.list), credentialManagement, opts.toolkits, defer, eligibleClerkOperator);
|
|
955
1298
|
return privateJson(data);
|
|
956
1299
|
}
|
|
957
1300
|
if (path === "/ui/activity") {
|
|
@@ -980,7 +1323,8 @@ export function createFetchHandler(opts) {
|
|
|
980
1323
|
? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
|
|
981
1324
|
: 50;
|
|
982
1325
|
try {
|
|
983
|
-
|
|
1326
|
+
const page = await opts.activity.list({ cursor, limit });
|
|
1327
|
+
return privateJson(await enrichActivityActorLabels(page, auth));
|
|
984
1328
|
}
|
|
985
1329
|
catch (error) {
|
|
986
1330
|
if (error instanceof InvalidActivityCursorError) {
|
|
@@ -991,21 +1335,55 @@ export function createFetchHandler(opts) {
|
|
|
991
1335
|
}
|
|
992
1336
|
}
|
|
993
1337
|
if (path === "/mcp") {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
+
}
|
|
1009
1387
|
}
|
|
1010
1388
|
// Connector-owned public routes, dispatched last: a connector can add a
|
|
1011
1389
|
// route but never shadow one of connecta's own. A throw here is the
|