@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/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 { buildUiData, CONNECTA_FAVICON_SVG, credentialManagementCapability, operatorPageForPath, resolveBranding, renderUiHtml, } from "./ui.js";
11
+ import { ExecutorAdmissionError, isAdmittingExecutor, } from "./executor-admission.js";
12
+ import { buildUiData, CONNECTA_FAVICON_SVG, credentialManagementCapability, isSafeHttpUrl, operatorPageForPath, resolveBranding, renderUiHtml, } from "./ui.js";
13
+ import { oauthValueStorageKey } from "./auth/downstream-oauth.js";
14
+ import { closeConnectorScope, } from "./connector-scope.js";
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
- // Credential mutation is intentionally narrower than /mcp and /ui/data: only
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 vault-admin key.
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: "credential management requires Clerk authentication" }, { status: 403 }),
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 a credential-API request admitted by inbound auth ` +
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.invalidate(connectorId);
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.invalidate(connectorId);
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` before it can fail, so a
677
- * configured id costs one storage round trip on the Workers deployment shape
678
- * that is a real KV read, tens of milliseconds cold while an id that names
679
- * nothing used to return having touched no I/O at all. That gap is an oracle:
680
- * sample the two and a wordlist recovers the connector list the flat 400 was
681
- * meant to withhold. So the zero-I/O refusals read the same key in the same
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 than one read. What it
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:state");
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
- return privateJson(await opts.activity.list({ cursor, limit }));
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
- // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
995
- // must not be able to probe which toolkit names exist.
996
- const authz = await authorize(request, baseUrl, auth, opts.logger);
997
- if (!authz.ok)
998
- return withMcpCors(authz.response);
999
- const selected = resolveToolkitScope(url, registry, opts.toolkits, opts.logger, {
1000
- actor: authz.actor,
1001
- ...(authz.toolkitBinding
1002
- ? { binding: authz.toolkitBinding }
1003
- : {}),
1004
- });
1005
- if (!selected.ok)
1006
- return withMcpCors(selected.response);
1007
- sweepCredentials();
1008
- return withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, selected.scope, runtimeContext));
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