@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +1 -0
  3. package/dist/activity.d.ts +17 -0
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js.map +1 -1
  6. package/dist/auth/clerk.d.ts.map +1 -1
  7. package/dist/auth/clerk.js +101 -0
  8. package/dist/auth/clerk.js.map +1 -1
  9. package/dist/auth/downstream-oauth.d.ts +57 -20
  10. package/dist/auth/downstream-oauth.d.ts.map +1 -1
  11. package/dist/auth/downstream-oauth.js +275 -67
  12. package/dist/auth/downstream-oauth.js.map +1 -1
  13. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  14. package/dist/connectors/remote-mcp.js +165 -103
  15. package/dist/connectors/remote-mcp.js.map +1 -1
  16. package/dist/execute.d.ts.map +1 -1
  17. package/dist/execute.js +11 -0
  18. package/dist/execute.js.map +1 -1
  19. package/dist/executor-admission.d.ts +18 -1
  20. package/dist/executor-admission.d.ts.map +1 -1
  21. package/dist/executor-admission.js +82 -3
  22. package/dist/executor-admission.js.map +1 -1
  23. package/dist/executors/quickjs-protocol.d.ts +1 -0
  24. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  25. package/dist/executors/quickjs-protocol.js +7 -4
  26. package/dist/executors/quickjs-protocol.js.map +1 -1
  27. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  28. package/dist/executors/quickjs-runtime.js +22 -9
  29. package/dist/executors/quickjs-runtime.js.map +1 -1
  30. package/dist/executors/quickjs.d.ts.map +1 -1
  31. package/dist/executors/quickjs.js +44 -10
  32. package/dist/executors/quickjs.js.map +1 -1
  33. package/dist/index.d.ts +31 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +38 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/registry.d.ts +12 -0
  38. package/dist/registry.d.ts.map +1 -1
  39. package/dist/registry.js +70 -15
  40. package/dist/registry.js.map +1 -1
  41. package/dist/server.d.ts +3 -0
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +413 -35
  44. package/dist/server.js.map +1 -1
  45. package/dist/storage/file.d.ts.map +1 -1
  46. package/dist/storage/file.js +8 -0
  47. package/dist/storage/file.js.map +1 -1
  48. package/dist/types.d.ts +47 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/ui.d.ts +7 -1
  51. package/dist/ui.d.ts.map +1 -1
  52. package/dist/ui.js +118 -4
  53. package/dist/ui.js.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +2 -1
  57. package/src/activity.ts +20 -0
  58. package/src/auth/clerk.ts +124 -0
  59. package/src/auth/downstream-oauth.ts +359 -68
  60. package/src/connectors/remote-mcp.ts +172 -104
  61. package/src/execute.ts +11 -0
  62. package/src/executor-admission.ts +90 -3
  63. package/src/executors/quickjs-protocol.ts +7 -4
  64. package/src/executors/quickjs-runtime.ts +31 -9
  65. package/src/executors/quickjs.ts +61 -12
  66. package/src/index.ts +90 -1
  67. package/src/registry.ts +79 -19
  68. package/src/server.ts +523 -45
  69. package/src/storage/file.ts +7 -0
  70. package/src/types.ts +50 -0
  71. package/src/ui.ts +124 -3
  72. 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
- // Credential mutation is intentionally narrower than /mcp and /ui/data: only
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 vault-admin key.
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: "credential management requires Clerk authentication" },
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 a credential-API request admitted by inbound auth ` +
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.invalidate(connectorId);
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.invalidate(connectorId);
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` before it can fail, so a
926
- * configured id costs one storage round trip on the Workers deployment shape
927
- * that is a real KV read, tens of milliseconds cold while an id that names
928
- * nothing used to return having touched no I/O at all. That gap is an oracle:
929
- * sample the two and a wordlist recovers the connector list the flat 400 was
930
- * meant to withhold. So the zero-I/O refusals read the same key in the same
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 than one read. What it
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:state");
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
- return privateJson(await opts.activity.list({ cursor, limit }));
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
- // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
1316
- // must not be able to probe which toolkit names exist.
1317
- const authz = await authorize(request, baseUrl, auth, opts.logger);
1318
- if (!authz.ok) return withMcpCors(authz.response);
1319
- const selected = resolveToolkitScope(
1320
- url,
1321
- registry,
1322
- opts.toolkits,
1323
- opts.logger,
1324
- {
1325
- actor: authz.actor,
1326
- ...(authz.toolkitBinding
1327
- ? { binding: authz.toolkitBinding }
1328
- : {}),
1329
- },
1330
- );
1331
- if (!selected.ok) return withMcpCors(selected.response);
1332
- sweepCredentials();
1333
- return withMcpCors(
1334
- await serveMcp(
1335
- request,
1336
- opts,
1337
- baseUrl,
1338
- authz.actor,
1339
- selected.scope,
1340
- runtimeContext,
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