@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/src/server.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { registerMetaTools } from "./meta-tools.js";
|
|
|
5
5
|
import { CONNECTA_INSTRUCTIONS } from "./skills.js";
|
|
6
6
|
import type {
|
|
7
7
|
ActivityActor,
|
|
8
|
+
ActivityPage,
|
|
8
9
|
ActivityReadGate,
|
|
10
|
+
ActivityReadPage,
|
|
9
11
|
ActivityRequestContext,
|
|
10
12
|
ActivityStore,
|
|
11
13
|
} from "./activity.js";
|
|
@@ -26,6 +28,7 @@ import type {
|
|
|
26
28
|
ConnectorContext,
|
|
27
29
|
ConnectorCredentialConfig,
|
|
28
30
|
ConnectorCredentialValues,
|
|
31
|
+
ConnectorStatus,
|
|
29
32
|
ConnectaBranding,
|
|
30
33
|
Executor,
|
|
31
34
|
InboundAuth,
|
|
@@ -33,14 +36,26 @@ import type {
|
|
|
33
36
|
ToolkitBinding,
|
|
34
37
|
} from "./types.js";
|
|
35
38
|
import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
39
|
+
import {
|
|
40
|
+
ExecutorAdmissionError,
|
|
41
|
+
isAdmittingExecutor,
|
|
42
|
+
type AdmissionController,
|
|
43
|
+
type AdmissionLease,
|
|
44
|
+
} from "./executor-admission.js";
|
|
36
45
|
import {
|
|
37
46
|
buildUiData,
|
|
38
47
|
CONNECTA_FAVICON_SVG,
|
|
39
48
|
credentialManagementCapability,
|
|
49
|
+
isSafeHttpUrl,
|
|
40
50
|
operatorPageForPath,
|
|
41
51
|
resolveBranding,
|
|
42
52
|
renderUiHtml,
|
|
43
53
|
} from "./ui.js";
|
|
54
|
+
import { oauthValueStorageKey } from "./auth/downstream-oauth.js";
|
|
55
|
+
import {
|
|
56
|
+
closeConnectorScope,
|
|
57
|
+
type DeferredWork,
|
|
58
|
+
} from "./connector-scope.js";
|
|
44
59
|
|
|
45
60
|
const CORS_HEADERS = {
|
|
46
61
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -96,6 +111,8 @@ export interface ServerOptions {
|
|
|
96
111
|
probeTimeoutMs?: number;
|
|
97
112
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
98
113
|
executor?: Executor;
|
|
114
|
+
/** Global FIFO boundary for all non-preflight `/mcp` requests. */
|
|
115
|
+
requestAdmission: AdmissionController;
|
|
99
116
|
/** Encrypted connector-credential storage backing the Credentials page. */
|
|
100
117
|
credentialVault?: CredentialVault;
|
|
101
118
|
/** Optional browser UI and OAuth result-page labels. */
|
|
@@ -255,11 +272,15 @@ async function authorize(
|
|
|
255
272
|
);
|
|
256
273
|
return { ok: false, response: unusableBinding() };
|
|
257
274
|
}
|
|
275
|
+
const actorNamespace = activityActorNamespace(provider);
|
|
258
276
|
return {
|
|
259
277
|
ok: true,
|
|
260
278
|
actor: {
|
|
261
279
|
kind: provider.kind,
|
|
262
280
|
...(subjectId ? { id: subjectId } : {}),
|
|
281
|
+
...(subjectId && actorNamespace
|
|
282
|
+
? { namespace: actorNamespace }
|
|
283
|
+
: {}),
|
|
263
284
|
},
|
|
264
285
|
...(result.userId && provider.uiAuth?.kind === "clerk"
|
|
265
286
|
? { uiAdminEligible: true }
|
|
@@ -301,7 +322,7 @@ function withMcpCors(response: Response): Response {
|
|
|
301
322
|
}
|
|
302
323
|
headers.set(
|
|
303
324
|
"Access-Control-Expose-Headers",
|
|
304
|
-
"WWW-Authenticate, mcp-session-id, mcp-protocol-version",
|
|
325
|
+
"WWW-Authenticate, Retry-After, mcp-session-id, mcp-protocol-version",
|
|
305
326
|
);
|
|
306
327
|
return new Response(response.body, {
|
|
307
328
|
status: response.status,
|
|
@@ -310,6 +331,102 @@ function withMcpCors(response: Response): Response {
|
|
|
310
331
|
});
|
|
311
332
|
}
|
|
312
333
|
|
|
334
|
+
function requestAdmissionFailure(error: ExecutorAdmissionError): Response {
|
|
335
|
+
const overloaded = error.code === "executor_overloaded";
|
|
336
|
+
const data = {
|
|
337
|
+
code: overloaded ? "server_overloaded" : "server_shutting_down",
|
|
338
|
+
retryable: overloaded,
|
|
339
|
+
...(overloaded && error.retryAfterMs !== undefined
|
|
340
|
+
? { retryAfterMs: error.retryAfterMs }
|
|
341
|
+
: {}),
|
|
342
|
+
};
|
|
343
|
+
const headers = new Headers({
|
|
344
|
+
"Content-Type": "application/json",
|
|
345
|
+
"Cache-Control": "no-store",
|
|
346
|
+
});
|
|
347
|
+
if (overloaded && error.retryAfterMs !== undefined) {
|
|
348
|
+
headers.set(
|
|
349
|
+
"Retry-After",
|
|
350
|
+
String(Math.max(1, Math.ceil(error.retryAfterMs / 1_000))),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return new Response(
|
|
354
|
+
JSON.stringify({
|
|
355
|
+
jsonrpc: "2.0",
|
|
356
|
+
id: null,
|
|
357
|
+
error: {
|
|
358
|
+
code: overloaded ? -32001 : -32002,
|
|
359
|
+
message: overloaded
|
|
360
|
+
? "Server capacity is exhausted. Retry later."
|
|
361
|
+
: "Server is shutting down.",
|
|
362
|
+
data,
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
{ status: 503, headers },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A request owns its permit through the response body, not merely until the
|
|
371
|
+
* handler returns. This is what makes slow clients and response-stream failure
|
|
372
|
+
* part of the same bounded lifecycle as success, error, and cancellation.
|
|
373
|
+
*/
|
|
374
|
+
function releaseAdmissionWithResponse(
|
|
375
|
+
response: Response,
|
|
376
|
+
lease: AdmissionLease,
|
|
377
|
+
signal: AbortSignal,
|
|
378
|
+
): Response {
|
|
379
|
+
let released = false;
|
|
380
|
+
let onAbort = () => {};
|
|
381
|
+
const release = () => {
|
|
382
|
+
if (released) return;
|
|
383
|
+
released = true;
|
|
384
|
+
signal.removeEventListener("abort", onAbort);
|
|
385
|
+
lease.release();
|
|
386
|
+
};
|
|
387
|
+
if (!response.body) {
|
|
388
|
+
release();
|
|
389
|
+
return response;
|
|
390
|
+
}
|
|
391
|
+
const reader = response.body.getReader();
|
|
392
|
+
onAbort = () => {
|
|
393
|
+
// `cancel()` belongs to an operator/auth/SDK-provided stream and may
|
|
394
|
+
// reject. Consume both outcomes: `.finally(release)` would release the
|
|
395
|
+
// permit but preserve the rejection as an unhandled promise.
|
|
396
|
+
void reader.cancel(signal.reason).then(release, release);
|
|
397
|
+
};
|
|
398
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
399
|
+
if (signal.aborted) onAbort();
|
|
400
|
+
const body = new ReadableStream<Uint8Array>({
|
|
401
|
+
async pull(controller) {
|
|
402
|
+
try {
|
|
403
|
+
const next = await reader.read();
|
|
404
|
+
if (next.done) {
|
|
405
|
+
release();
|
|
406
|
+
controller.close();
|
|
407
|
+
} else {
|
|
408
|
+
controller.enqueue(next.value);
|
|
409
|
+
}
|
|
410
|
+
} catch (error) {
|
|
411
|
+
release();
|
|
412
|
+
controller.error(error);
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
async cancel(reason) {
|
|
416
|
+
try {
|
|
417
|
+
await reader.cancel(reason);
|
|
418
|
+
} finally {
|
|
419
|
+
release();
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
return new Response(body, {
|
|
424
|
+
status: response.status,
|
|
425
|
+
statusText: response.statusText,
|
|
426
|
+
headers: response.headers,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
313
430
|
function withSecurityHeaders(
|
|
314
431
|
response: Response,
|
|
315
432
|
requestUrl: URL,
|
|
@@ -342,10 +459,11 @@ async function authorizeUiAdmin(
|
|
|
342
459
|
baseUrl: string,
|
|
343
460
|
auth: InboundAuth[],
|
|
344
461
|
logger: Logger,
|
|
462
|
+
purpose = "credential management",
|
|
345
463
|
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
|
|
346
|
-
//
|
|
464
|
+
// Operator mutation is intentionally narrower than /mcp and /ui/data: only
|
|
347
465
|
// an interactive Clerk provider may admit it. A static bearer token is useful
|
|
348
|
-
// for headless tool calls but must not become a
|
|
466
|
+
// for headless tool calls but must not become a deployment-admin key.
|
|
349
467
|
//
|
|
350
468
|
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
351
469
|
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
@@ -361,7 +479,7 @@ async function authorizeUiAdmin(
|
|
|
361
479
|
return {
|
|
362
480
|
ok: false,
|
|
363
481
|
response: privateJson(
|
|
364
|
-
{ error:
|
|
482
|
+
{ error: `${purpose} requires Clerk authentication` },
|
|
365
483
|
{ status: 403 },
|
|
366
484
|
),
|
|
367
485
|
};
|
|
@@ -386,7 +504,7 @@ async function authorizeUiAdmin(
|
|
|
386
504
|
);
|
|
387
505
|
if (!binding.ok) {
|
|
388
506
|
logger.warn(
|
|
389
|
-
`[connecta] refused
|
|
507
|
+
`[connecta] refused an operator-mutation request admitted by inbound auth ` +
|
|
390
508
|
`provider "${provider.kind}" with 403: ${binding.reason}.`,
|
|
391
509
|
);
|
|
392
510
|
lastResponse = unusableBinding();
|
|
@@ -628,7 +746,7 @@ async function handleCredentialRequest(
|
|
|
628
746
|
input.input.values,
|
|
629
747
|
admin.userId,
|
|
630
748
|
);
|
|
631
|
-
opts.registry.
|
|
749
|
+
await opts.registry.invalidateStored(connectorId);
|
|
632
750
|
// The credential the last verdict judged is gone; judging its replacement
|
|
633
751
|
// is the next check's job, not this one's.
|
|
634
752
|
await opts.registry.clearCredentialHealth(connectorId);
|
|
@@ -640,7 +758,7 @@ async function handleCredentialRequest(
|
|
|
640
758
|
|
|
641
759
|
if (request.method === "DELETE") {
|
|
642
760
|
await opts.credentialVault.delete(connectorId);
|
|
643
|
-
opts.registry.
|
|
761
|
+
await opts.registry.invalidateStored(connectorId);
|
|
644
762
|
await opts.registry.clearCredentialHealth(connectorId);
|
|
645
763
|
return new Response(null, {
|
|
646
764
|
status: 204,
|
|
@@ -654,6 +772,105 @@ async function handleCredentialRequest(
|
|
|
654
772
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
655
773
|
}
|
|
656
774
|
|
|
775
|
+
async function handleOAuthManagementRequest(
|
|
776
|
+
request: Request,
|
|
777
|
+
connectorId: string,
|
|
778
|
+
opts: ServerOptions,
|
|
779
|
+
baseUrl: string,
|
|
780
|
+
defer?: DeferredWork,
|
|
781
|
+
): Promise<Response> {
|
|
782
|
+
if (!isSameOrigin(request, baseUrl)) {
|
|
783
|
+
return privateJson(
|
|
784
|
+
{ error: "same-origin request required" },
|
|
785
|
+
{ status: 403 },
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
const admin = await authorizeUiAdmin(
|
|
789
|
+
request,
|
|
790
|
+
baseUrl,
|
|
791
|
+
opts.auth,
|
|
792
|
+
opts.logger,
|
|
793
|
+
"OAuth management",
|
|
794
|
+
);
|
|
795
|
+
if (!admin.ok) return admin.response;
|
|
796
|
+
|
|
797
|
+
const connector = opts.registry.getConnector(connectorId);
|
|
798
|
+
if (!connector?.disconnectAuth || !connector.startAuth) {
|
|
799
|
+
return privateJson(
|
|
800
|
+
{ error: "unknown OAuth connector" },
|
|
801
|
+
{ status: 404 },
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
if (request.method !== "DELETE" && request.method !== "POST") {
|
|
805
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const requestScope = {};
|
|
809
|
+
const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope);
|
|
810
|
+
try {
|
|
811
|
+
let result: ConnectorStatus | undefined;
|
|
812
|
+
let operationError: unknown;
|
|
813
|
+
try {
|
|
814
|
+
if (request.method === "DELETE") {
|
|
815
|
+
await connector.disconnectAuth(ctx);
|
|
816
|
+
} else {
|
|
817
|
+
result = await connector.startAuth(ctx, { force: true });
|
|
818
|
+
}
|
|
819
|
+
} catch (error) {
|
|
820
|
+
operationError = error;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The old grant and its catalog verdict are invalid after either operation,
|
|
824
|
+
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
825
|
+
try {
|
|
826
|
+
await opts.registry.invalidateStored(connectorId);
|
|
827
|
+
await opts.registry.clearCredentialHealth(connectorId);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
operationError ??= error;
|
|
830
|
+
}
|
|
831
|
+
if (operationError) {
|
|
832
|
+
return privateJson({ error: msg(operationError) }, { status: 400 });
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (request.method === "DELETE") {
|
|
836
|
+
return new Response(null, {
|
|
837
|
+
status: 204,
|
|
838
|
+
headers: {
|
|
839
|
+
"Cache-Control": "no-store",
|
|
840
|
+
"Referrer-Policy": "no-referrer",
|
|
841
|
+
},
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const authorizationUrl = isSafeHttpUrl(result!.authorizationUrl)
|
|
846
|
+
? result!.authorizationUrl
|
|
847
|
+
: undefined;
|
|
848
|
+
if (result!.state === "error") {
|
|
849
|
+
return privateJson(
|
|
850
|
+
{ error: result!.message || "OAuth authorization could not start" },
|
|
851
|
+
{ status: 502 },
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (result!.state === "auth_required" && !authorizationUrl) {
|
|
855
|
+
return privateJson(
|
|
856
|
+
{
|
|
857
|
+
error:
|
|
858
|
+
result!.message ||
|
|
859
|
+
"OAuth authorization requires consent but no safe URL is available",
|
|
860
|
+
},
|
|
861
|
+
{ status: 502 },
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return privateJson({
|
|
865
|
+
state: result!.state,
|
|
866
|
+
...(result!.message ? { message: result!.message } : {}),
|
|
867
|
+
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
868
|
+
});
|
|
869
|
+
} finally {
|
|
870
|
+
await closeConnectorScope(connector, ctx, defer);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
657
874
|
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
658
875
|
const MAX_ECHOED_TOOLKIT_NAME = 64;
|
|
659
876
|
|
|
@@ -719,6 +936,165 @@ function identityLabel(actor: ActivityActor): string {
|
|
|
719
936
|
return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
|
|
720
937
|
}
|
|
721
938
|
|
|
939
|
+
const ACTIVITY_ACTOR_NAMESPACE_RE = /^[\x21-\x7e]{1,256}$/;
|
|
940
|
+
const ACTIVITY_LABEL_CONCURRENCY = 8;
|
|
941
|
+
const ACTIVITY_LABEL_PAGE_BUDGET_MS = 1_500;
|
|
942
|
+
const ACTIVITY_LABEL_MAX_LENGTH = 160;
|
|
943
|
+
|
|
944
|
+
function activityActorNamespace(
|
|
945
|
+
provider: InboundAuth,
|
|
946
|
+
): string | undefined {
|
|
947
|
+
return typeof provider.activityActorNamespace === "string" &&
|
|
948
|
+
ACTIVITY_ACTOR_NAMESPACE_RE.test(provider.activityActorNamespace)
|
|
949
|
+
? provider.activityActorNamespace
|
|
950
|
+
: undefined;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function cleanActivityActorLabel(value: unknown): string | undefined {
|
|
954
|
+
if (typeof value !== "string") return undefined;
|
|
955
|
+
const compact = value.replace(/\s+/gu, " ").trim();
|
|
956
|
+
if (!compact) return undefined;
|
|
957
|
+
return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function boundedActivityActorLabel(
|
|
961
|
+
hook: NonNullable<InboundAuth["activityActorLabel"]>,
|
|
962
|
+
id: string,
|
|
963
|
+
budgetMs: number,
|
|
964
|
+
): Promise<string | undefined> {
|
|
965
|
+
let timer: number | undefined;
|
|
966
|
+
try {
|
|
967
|
+
return await Promise.race([
|
|
968
|
+
Promise.resolve(hook(id))
|
|
969
|
+
.then(cleanActivityActorLabel)
|
|
970
|
+
.catch(() => undefined),
|
|
971
|
+
new Promise<undefined>((resolve) => {
|
|
972
|
+
timer = setTimeout(resolve, budgetMs);
|
|
973
|
+
}),
|
|
974
|
+
]);
|
|
975
|
+
} catch {
|
|
976
|
+
return undefined;
|
|
977
|
+
} finally {
|
|
978
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Add display-only actor labels to one authorized activity page. Resolution is
|
|
984
|
+
* best-effort, bounded, and read-time only: stored events retain stable ids and
|
|
985
|
+
* a profile-provider outage falls back to those ids without failing the page.
|
|
986
|
+
*/
|
|
987
|
+
async function enrichActivityActorLabels(
|
|
988
|
+
page: ActivityPage,
|
|
989
|
+
auth: readonly InboundAuth[],
|
|
990
|
+
): Promise<ActivityReadPage> {
|
|
991
|
+
const identities = new Map<
|
|
992
|
+
string,
|
|
993
|
+
{ kind: string; id: string; namespace?: string }
|
|
994
|
+
>();
|
|
995
|
+
for (const event of page.events) {
|
|
996
|
+
if (!event.actor.id) continue;
|
|
997
|
+
identities.set(JSON.stringify([
|
|
998
|
+
event.actor.kind,
|
|
999
|
+
event.actor.namespace,
|
|
1000
|
+
event.actor.id,
|
|
1001
|
+
]), {
|
|
1002
|
+
kind: event.actor.kind,
|
|
1003
|
+
id: event.actor.id,
|
|
1004
|
+
...(event.actor.namespace
|
|
1005
|
+
? { namespace: event.actor.namespace }
|
|
1006
|
+
: {}),
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
const queue = [...identities.entries()];
|
|
1010
|
+
const labels = new Map<string, string>();
|
|
1011
|
+
let next = 0;
|
|
1012
|
+
const deadline = Date.now() + ACTIVITY_LABEL_PAGE_BUDGET_MS;
|
|
1013
|
+
const workers = Array.from(
|
|
1014
|
+
{ length: Math.min(ACTIVITY_LABEL_CONCURRENCY, queue.length) },
|
|
1015
|
+
async () => {
|
|
1016
|
+
while (next < queue.length) {
|
|
1017
|
+
const [key, identity] = queue[next++];
|
|
1018
|
+
const sameKindProviders = auth
|
|
1019
|
+
.map((provider, index) => ({ provider, index }))
|
|
1020
|
+
.filter(({ provider }) => provider.kind === identity.kind);
|
|
1021
|
+
const candidates = sameKindProviders.filter(({ provider }) =>
|
|
1022
|
+
Boolean(provider.activityActorLabel),
|
|
1023
|
+
);
|
|
1024
|
+
const eligible = identity.namespace
|
|
1025
|
+
? candidates.filter(
|
|
1026
|
+
({ provider }) =>
|
|
1027
|
+
activityActorNamespace(provider) === identity.namespace,
|
|
1028
|
+
)
|
|
1029
|
+
: (() => {
|
|
1030
|
+
const directoryKey = ({
|
|
1031
|
+
provider,
|
|
1032
|
+
index,
|
|
1033
|
+
}: (typeof sameKindProviders)[number]) => {
|
|
1034
|
+
const namespace = activityActorNamespace(provider);
|
|
1035
|
+
return namespace === undefined
|
|
1036
|
+
? `provider:${index}`
|
|
1037
|
+
: `namespace:${namespace}`;
|
|
1038
|
+
};
|
|
1039
|
+
// Every same-kind provider participates in the ambiguity check,
|
|
1040
|
+
// even if it cannot resolve labels. Otherwise a legacy ID owned
|
|
1041
|
+
// by a provider without a resolver could be disclosed to a
|
|
1042
|
+
// different provider that happens to have one.
|
|
1043
|
+
const directories = new Set(
|
|
1044
|
+
sameKindProviders.map(directoryKey),
|
|
1045
|
+
);
|
|
1046
|
+
if (directories.size !== 1) return [];
|
|
1047
|
+
const [directory] = directories;
|
|
1048
|
+
return candidates.filter(
|
|
1049
|
+
(candidate) => directoryKey(candidate) === directory,
|
|
1050
|
+
);
|
|
1051
|
+
})();
|
|
1052
|
+
// One namespace is one directory. Use its first configured resolver so
|
|
1053
|
+
// duplicate gate adapters over the same Clerk instance do not multiply
|
|
1054
|
+
// the provider-level concurrency cap.
|
|
1055
|
+
const provider = eligible[0]?.provider;
|
|
1056
|
+
if (!provider) continue;
|
|
1057
|
+
const remaining = deadline - Date.now();
|
|
1058
|
+
if (remaining <= 0) return;
|
|
1059
|
+
const label = await boundedActivityActorLabel(
|
|
1060
|
+
provider.activityActorLabel!.bind(provider),
|
|
1061
|
+
identity.id,
|
|
1062
|
+
remaining,
|
|
1063
|
+
);
|
|
1064
|
+
if (label) {
|
|
1065
|
+
labels.set(key, label);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
);
|
|
1070
|
+
await Promise.all(workers);
|
|
1071
|
+
return {
|
|
1072
|
+
...page,
|
|
1073
|
+
events: page.events.map((event) => {
|
|
1074
|
+
const resolved = event.actor.id
|
|
1075
|
+
? labels.get(JSON.stringify([
|
|
1076
|
+
event.actor.kind,
|
|
1077
|
+
event.actor.namespace,
|
|
1078
|
+
event.actor.id,
|
|
1079
|
+
]))
|
|
1080
|
+
: undefined;
|
|
1081
|
+
// Never trust or echo a `label` supplied by storage. The persisted event
|
|
1082
|
+
// schema has no label; only this authenticated read path may add one.
|
|
1083
|
+
const actor: ActivityActor = {
|
|
1084
|
+
kind: event.actor.kind,
|
|
1085
|
+
...(event.actor.id ? { id: event.actor.id } : {}),
|
|
1086
|
+
...(event.actor.namespace
|
|
1087
|
+
? { namespace: event.actor.namespace }
|
|
1088
|
+
: {}),
|
|
1089
|
+
};
|
|
1090
|
+
return {
|
|
1091
|
+
...event,
|
|
1092
|
+
actor: resolved ? { ...actor, label: resolved } : actor,
|
|
1093
|
+
};
|
|
1094
|
+
}),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
722
1098
|
/**
|
|
723
1099
|
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
724
1100
|
* enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
|
|
@@ -922,17 +1298,16 @@ async function serveMcp(
|
|
|
922
1298
|
* paths that would otherwise pay nothing.
|
|
923
1299
|
*
|
|
924
1300
|
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
925
|
-
* `KvOAuthProvider.verifyState` reads `oauth:state`
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
* `conn:<id>:` namespace, which for an unconfigured id is simply a miss.
|
|
1301
|
+
* `KvOAuthProvider.verifyState` reads `oauth:state` and its generation before
|
|
1302
|
+
* it can reject a mismatched value, so a configured id costs two storage round
|
|
1303
|
+
* trips on the ordinary path while an id naming nothing used to touch no I/O.
|
|
1304
|
+
* That gap is an oracle: sample the two and a wordlist recovers the connector
|
|
1305
|
+
* list the flat 400 was meant to withhold. So zero-I/O refusals read the same
|
|
1306
|
+
* keys in the same `conn:<id>:` namespace, where an unconfigured id gets misses.
|
|
932
1307
|
*
|
|
933
1308
|
* This is deliberately *not* a constant-time claim, and docs/connectors.md says
|
|
934
1309
|
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
935
|
-
* shipping its own `verifyState` may do more or less work
|
|
1310
|
+
* shipping its own `verifyState` may do more or less work. What it
|
|
936
1311
|
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
937
1312
|
* which is the only part of the signal that makes enumeration cheap.
|
|
938
1313
|
*
|
|
@@ -942,7 +1317,10 @@ async function serveMcp(
|
|
|
942
1317
|
*/
|
|
943
1318
|
async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
|
|
944
1319
|
try {
|
|
945
|
-
await context.storage.get("oauth:
|
|
1320
|
+
const generation = await context.storage.get("oauth:generation");
|
|
1321
|
+
await context.storage.get(
|
|
1322
|
+
oauthValueStorageKey("oauth:state", generation),
|
|
1323
|
+
);
|
|
946
1324
|
} catch {
|
|
947
1325
|
// Deliberately ignored — see above.
|
|
948
1326
|
}
|
|
@@ -1042,6 +1420,23 @@ export function createFetchHandler(
|
|
|
1042
1420
|
runtimeContext?: RuntimeExecutionContext,
|
|
1043
1421
|
) => Promise<Response> {
|
|
1044
1422
|
const { registry, auth, publicUrl, serverInfo } = opts;
|
|
1423
|
+
let lastAdmissionWarningAt = 0;
|
|
1424
|
+
let suppressedAdmissionWarnings = 0;
|
|
1425
|
+
const warnAdmissionRejected = (error: ExecutorAdmissionError): void => {
|
|
1426
|
+
const now = Date.now();
|
|
1427
|
+
if (now - lastAdmissionWarningAt < 1_000) {
|
|
1428
|
+
suppressedAdmissionWarnings++;
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
opts.logger.warn("[connecta] MCP request admission rejected", {
|
|
1432
|
+
retryAfterMs: error.retryAfterMs,
|
|
1433
|
+
active: opts.requestAdmission.activeCount,
|
|
1434
|
+
queued: opts.requestAdmission.queuedCount,
|
|
1435
|
+
suppressedSinceLastWarning: suppressedAdmissionWarnings,
|
|
1436
|
+
});
|
|
1437
|
+
lastAdmissionWarningAt = now;
|
|
1438
|
+
suppressedAdmissionWarnings = 0;
|
|
1439
|
+
};
|
|
1045
1440
|
return async function fetch(
|
|
1046
1441
|
request: Request,
|
|
1047
1442
|
runtimeContext?: RuntimeExecutionContext,
|
|
@@ -1132,6 +1527,20 @@ export function createFetchHandler(
|
|
|
1132
1527
|
baseUrl,
|
|
1133
1528
|
);
|
|
1134
1529
|
}
|
|
1530
|
+
const oauthManagementMatch =
|
|
1531
|
+
/^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(path);
|
|
1532
|
+
if (oauthManagementMatch) {
|
|
1533
|
+
if (request.method === "OPTIONS") {
|
|
1534
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1535
|
+
}
|
|
1536
|
+
return handleOAuthManagementRequest(
|
|
1537
|
+
request,
|
|
1538
|
+
oauthManagementMatch[1],
|
|
1539
|
+
opts,
|
|
1540
|
+
baseUrl,
|
|
1541
|
+
defer,
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1135
1544
|
|
|
1136
1545
|
if (request.method === "OPTIONS") {
|
|
1137
1546
|
for (const a of auth) {
|
|
@@ -1154,10 +1563,29 @@ export function createFetchHandler(
|
|
|
1154
1563
|
}
|
|
1155
1564
|
|
|
1156
1565
|
if (path === "/health") {
|
|
1566
|
+
const codeAdmission =
|
|
1567
|
+
opts.executor && isAdmittingExecutor(opts.executor)
|
|
1568
|
+
? opts.executor.admissionSnapshot?.()
|
|
1569
|
+
: undefined;
|
|
1157
1570
|
return Response.json({
|
|
1158
1571
|
status: "ok",
|
|
1159
1572
|
connectors: registry.listConnectors().length,
|
|
1160
1573
|
server: opts.serverInfo,
|
|
1574
|
+
admission: {
|
|
1575
|
+
policy: "global-fifo",
|
|
1576
|
+
requests: opts.requestAdmission.snapshot(),
|
|
1577
|
+
code: opts.executor
|
|
1578
|
+
? (codeAdmission ?? { managedByExecutor: true })
|
|
1579
|
+
: null,
|
|
1580
|
+
reservedRoutes: [
|
|
1581
|
+
"/health",
|
|
1582
|
+
"/",
|
|
1583
|
+
"/credentials",
|
|
1584
|
+
"/activity",
|
|
1585
|
+
"/ui",
|
|
1586
|
+
"/ui/*",
|
|
1587
|
+
],
|
|
1588
|
+
},
|
|
1161
1589
|
...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
|
|
1162
1590
|
});
|
|
1163
1591
|
}
|
|
@@ -1264,6 +1692,7 @@ export function createFetchHandler(
|
|
|
1264
1692
|
credentialManagement,
|
|
1265
1693
|
opts.toolkits,
|
|
1266
1694
|
defer,
|
|
1695
|
+
eligibleClerkOperator,
|
|
1267
1696
|
);
|
|
1268
1697
|
return privateJson(data);
|
|
1269
1698
|
}
|
|
@@ -1298,7 +1727,8 @@ export function createFetchHandler(
|
|
|
1298
1727
|
? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
|
|
1299
1728
|
: 50;
|
|
1300
1729
|
try {
|
|
1301
|
-
|
|
1730
|
+
const page = await opts.activity.list({ cursor, limit });
|
|
1731
|
+
return privateJson(await enrichActivityActorLabels(page, auth));
|
|
1302
1732
|
} catch (error) {
|
|
1303
1733
|
if (error instanceof InvalidActivityCursorError) {
|
|
1304
1734
|
return privateJson({ error: error.message }, { status: 400 });
|
|
@@ -1312,34 +1742,82 @@ export function createFetchHandler(
|
|
|
1312
1742
|
}
|
|
1313
1743
|
|
|
1314
1744
|
if (path === "/mcp") {
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1745
|
+
let admission: AdmissionLease;
|
|
1746
|
+
try {
|
|
1747
|
+
admission = await opts.requestAdmission.acquire({
|
|
1748
|
+
signal: request.signal,
|
|
1749
|
+
});
|
|
1750
|
+
if (admission.waitMs > 0) {
|
|
1751
|
+
opts.logger.debug("[connecta] MCP request admitted after queue wait", {
|
|
1752
|
+
waitMs: admission.waitMs,
|
|
1753
|
+
active: opts.requestAdmission.activeCount,
|
|
1754
|
+
queued: opts.requestAdmission.queuedCount,
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
if (
|
|
1759
|
+
error instanceof ExecutorAdmissionError &&
|
|
1760
|
+
error.code === "executor_cancelled"
|
|
1761
|
+
) {
|
|
1762
|
+
throw request.signal.reason ?? error;
|
|
1763
|
+
}
|
|
1764
|
+
if (error instanceof ExecutorAdmissionError) {
|
|
1765
|
+
if (error.code === "executor_overloaded") {
|
|
1766
|
+
warnAdmissionRejected(error);
|
|
1767
|
+
}
|
|
1768
|
+
return withMcpCors(requestAdmissionFailure(error));
|
|
1769
|
+
}
|
|
1770
|
+
throw error;
|
|
1771
|
+
}
|
|
1772
|
+
try {
|
|
1773
|
+
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
1774
|
+
// must not be able to probe which toolkit names exist.
|
|
1775
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1776
|
+
if (!authz.ok) {
|
|
1777
|
+
return releaseAdmissionWithResponse(
|
|
1778
|
+
withMcpCors(authz.response),
|
|
1779
|
+
admission,
|
|
1780
|
+
request.signal,
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
const selected = resolveToolkitScope(
|
|
1784
|
+
url,
|
|
1785
|
+
registry,
|
|
1786
|
+
opts.toolkits,
|
|
1787
|
+
opts.logger,
|
|
1788
|
+
{
|
|
1789
|
+
actor: authz.actor,
|
|
1790
|
+
...(authz.toolkitBinding
|
|
1791
|
+
? { binding: authz.toolkitBinding }
|
|
1792
|
+
: {}),
|
|
1793
|
+
},
|
|
1794
|
+
);
|
|
1795
|
+
if (!selected.ok) {
|
|
1796
|
+
return releaseAdmissionWithResponse(
|
|
1797
|
+
withMcpCors(selected.response),
|
|
1798
|
+
admission,
|
|
1799
|
+
request.signal,
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
sweepCredentials();
|
|
1803
|
+
return releaseAdmissionWithResponse(
|
|
1804
|
+
withMcpCors(
|
|
1805
|
+
await serveMcp(
|
|
1806
|
+
request,
|
|
1807
|
+
opts,
|
|
1808
|
+
baseUrl,
|
|
1809
|
+
authz.actor,
|
|
1810
|
+
selected.scope,
|
|
1811
|
+
runtimeContext,
|
|
1812
|
+
),
|
|
1813
|
+
),
|
|
1814
|
+
admission,
|
|
1815
|
+
request.signal,
|
|
1816
|
+
);
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
admission.release();
|
|
1819
|
+
throw error;
|
|
1820
|
+
}
|
|
1343
1821
|
}
|
|
1344
1822
|
|
|
1345
1823
|
// Connector-owned public routes, dispatched last: a connector can add a
|