@zackbart/connecta 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +358 -0
  2. package/README.md +53 -12
  3. package/dist/auth/bearer.d.ts +10 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +21 -0
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +26 -1
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +161 -4
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  12. package/dist/connectors/remote-mcp.js +8 -0
  13. package/dist/connectors/remote-mcp.js.map +1 -1
  14. package/dist/credential-health.d.ts +212 -0
  15. package/dist/credential-health.d.ts.map +1 -0
  16. package/dist/credential-health.js +535 -0
  17. package/dist/credential-health.js.map +1 -0
  18. package/dist/execute.d.ts.map +1 -1
  19. package/dist/execute.js +16 -4
  20. package/dist/execute.js.map +1 -1
  21. package/dist/index.d.ts +46 -5
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +96 -13
  24. package/dist/index.js.map +1 -1
  25. package/dist/meta-tools.d.ts +56 -5
  26. package/dist/meta-tools.d.ts.map +1 -1
  27. package/dist/meta-tools.js +249 -92
  28. package/dist/meta-tools.js.map +1 -1
  29. package/dist/registry.d.ts +62 -0
  30. package/dist/registry.d.ts.map +1 -1
  31. package/dist/registry.js +85 -1
  32. package/dist/registry.js.map +1 -1
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +292 -37
  35. package/dist/server.js.map +1 -1
  36. package/dist/skills.d.ts +1 -1
  37. package/dist/skills.d.ts.map +1 -1
  38. package/dist/skills.js +1 -1
  39. package/dist/timeout.d.ts +16 -0
  40. package/dist/timeout.d.ts.map +1 -0
  41. package/dist/timeout.js +38 -0
  42. package/dist/timeout.js.map +1 -0
  43. package/dist/toolkits.d.ts +95 -1
  44. package/dist/toolkits.d.ts.map +1 -1
  45. package/dist/toolkits.js +190 -5
  46. package/dist/toolkits.js.map +1 -1
  47. package/dist/types.d.ts +70 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/ui.d.ts +35 -0
  50. package/dist/ui.d.ts.map +1 -1
  51. package/dist/ui.js +87 -3
  52. package/dist/ui.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +1 -1
  56. package/src/auth/bearer.ts +35 -1
  57. package/src/auth/clerk.ts +202 -5
  58. package/src/connectors/remote-mcp.ts +9 -0
  59. package/src/credential-health.ts +736 -0
  60. package/src/execute.ts +28 -4
  61. package/src/index.ts +176 -20
  62. package/src/meta-tools.ts +286 -109
  63. package/src/registry.ts +125 -1
  64. package/src/server.ts +349 -34
  65. package/src/skills.ts +1 -1
  66. package/src/timeout.ts +49 -0
  67. package/src/toolkits.ts +241 -6
  68. package/src/types.ts +76 -1
  69. package/src/ui.ts +98 -3
  70. package/src/version.ts +1 -1
package/src/server.ts CHANGED
@@ -12,7 +12,11 @@ import type {
12
12
  import { InvalidActivityCursorError } from "./activity.js";
13
13
  import type { CredentialVault } from "./credentials.js";
14
14
  import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
15
- import { TOOLKIT_NAME_RE, type Toolkit } from "./toolkits.js";
15
+ import {
16
+ resolveIdentityBinding,
17
+ TOOLKIT_NAME_RE,
18
+ type Toolkit,
19
+ } from "./toolkits.js";
16
20
  import type {
17
21
  ConnectorCredentialConfig,
18
22
  ConnectorCredentialValues,
@@ -20,6 +24,7 @@ import type {
20
24
  Executor,
21
25
  InboundAuth,
22
26
  Logger,
27
+ ToolkitBinding,
23
28
  } from "./types.js";
24
29
  import { CONNECTA_FAVICON_ICO } from "./favicon.js";
25
30
  import {
@@ -36,6 +41,35 @@ const CORS_HEADERS = {
36
41
  "Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
37
42
  };
38
43
 
44
+ /**
45
+ * Headers that make an operator-supplied favicon body inert on this origin.
46
+ * The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
47
+ * so a `<script>` inside a branding SVG would run on the deployment origin the
48
+ * moment anyone navigated straight to `/favicon.svg` — strictly more powerful
49
+ * than the `favicon.href` vector the branding gates close, because the payload
50
+ * is same-origin. Neutralizing the response rather than inspecting the body
51
+ * keeps every valid static SVG (the built-in mark included) byte-identical:
52
+ *
53
+ * - `sandbox` (no tokens ⇒ every restriction) drops the document into an opaque
54
+ * origin with scripting off, so even a script that ran would have nothing to
55
+ * reach.
56
+ * - `default-src 'none'` denies script, network, and framing outright.
57
+ * - `style-src 'unsafe-inline'` is the single allowance: the default mark styles
58
+ * itself inline to follow the OS colour scheme, and CSS cannot script.
59
+ * - `nosniff` keeps the declared type authoritative in both directions — an SVG
60
+ * can never be re-read as HTML, and `.ico` bytes can never be re-read as SVG.
61
+ *
62
+ * `.ico` bodies are deliberately in scope: they are inert bytes rather than
63
+ * active content, so they are still served verbatim, but they carry the same
64
+ * headers so the invariant is "every favicon route is neutralized" rather than
65
+ * "whichever route got attention".
66
+ */
67
+ const INERT_ICON_HEADERS = {
68
+ "Content-Security-Policy":
69
+ "default-src 'none'; style-src 'unsafe-inline'; sandbox",
70
+ "X-Content-Type-Options": "nosniff",
71
+ };
72
+
39
73
  export interface ServerOptions {
40
74
  registry: Registry;
41
75
  auth: InboundAuth[];
@@ -161,16 +195,29 @@ function html(
161
195
  );
162
196
  }
163
197
 
198
+ /**
199
+ * Refusal for an identity whose toolkit binding cannot be trusted — a malformed
200
+ * declaration, or a malformed per-identity binding out of `authorize`. The
201
+ * caller is authenticated, so this is a 403, and it is deliberately opaque: the
202
+ * cause is an operator bug, and the operator reads it in the log, not the client.
203
+ */
204
+ function unusableBinding(): Response {
205
+ return privateJson({ error: "forbidden" }, { status: 403 });
206
+ }
207
+
164
208
  async function authorize(
165
209
  request: Request,
166
210
  baseUrl: string,
167
211
  auth: InboundAuth[],
212
+ logger: Logger,
168
213
  ): Promise<
169
214
  | {
170
215
  ok: true;
171
216
  actor: ActivityActor;
172
217
  providerKind?: string;
173
218
  userId?: string;
219
+ /** The admitting identity's toolkit binding, if it has one (§16). */
220
+ toolkitBinding?: ToolkitBinding;
174
221
  }
175
222
  | { ok: false; response: Response }
176
223
  > {
@@ -182,6 +229,24 @@ async function authorize(
182
229
  const result = await provider.authorize(request, baseUrl);
183
230
  if (result.ok) {
184
231
  const subjectId = result.subjectId ?? result.userId;
232
+ // Re-validate both halves and cap the per-identity one by the provider's
233
+ // declaration (see resolveIdentityBinding). A binding that does not
234
+ // type-check at runtime refuses the request rather than evaporating:
235
+ // dropping it would hand the caller the full registry, which is the one
236
+ // outcome a binding exists to prevent.
237
+ const binding = resolveIdentityBinding(
238
+ provider.toolkitBinding,
239
+ result.toolkitBinding,
240
+ );
241
+ if (!binding.ok) {
242
+ logger.warn(
243
+ `[connecta] refused a request admitted by inbound auth provider ` +
244
+ `"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
245
+ "this provider cannot admit anyone, because connecta cannot tell " +
246
+ "which toolkits the identity may use.",
247
+ );
248
+ return { ok: false, response: unusableBinding() };
249
+ }
185
250
  return {
186
251
  ok: true,
187
252
  actor: {
@@ -190,6 +255,7 @@ async function authorize(
190
255
  },
191
256
  providerKind: provider.kind,
192
257
  ...(result.userId ? { userId: result.userId } : {}),
258
+ ...(binding.binding ? { toolkitBinding: binding.binding } : {}),
193
259
  };
194
260
  }
195
261
  lastResponse = result.response;
@@ -266,12 +332,23 @@ async function authorizeUiAdmin(
266
332
  request: Request,
267
333
  baseUrl: string,
268
334
  auth: InboundAuth[],
335
+ logger: Logger,
269
336
  ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
270
- // Credential mutation is intentionally narrower than /mcp and /ui/data:
271
- // only the interactive Clerk provider may admit it. A static bearer token is
272
- // useful for headless tool calls but must not become a vault-admin key.
273
- const provider = auth.find((candidate) => candidate.uiAuth?.kind === "clerk");
274
- if (!provider) {
337
+ // Credential mutation is intentionally narrower than /mcp and /ui/data: only
338
+ // an interactive Clerk provider may admit it. A static bearer token is useful
339
+ // for headless tool calls but must not become a vault-admin key.
340
+ //
341
+ // EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
342
+ // documented per-team pattern is several `clerkAuth(...)`s that differ only in
343
+ // `gate` and `toolkits` (§16). Stopping at the first would make admission
344
+ // depend on config order: the team-bound provider listed first would refuse
345
+ // the operator outright, and a refusal here — a failed gate, a missing user, a
346
+ // toolkit-bound identity — is exactly the case where a later provider is the
347
+ // one meant to admit. The last refusal is returned if none do.
348
+ const providers = auth.filter(
349
+ (candidate) => candidate.uiAuth?.kind === "clerk",
350
+ );
351
+ if (providers.length === 0) {
275
352
  return {
276
353
  ok: false,
277
354
  response: privateJson(
@@ -280,18 +357,46 @@ async function authorizeUiAdmin(
280
357
  ),
281
358
  };
282
359
  }
283
- const result = await provider.authorize(request, baseUrl);
284
- if (!result.ok) return result;
285
- if (!result.userId) {
286
- return {
287
- ok: false,
288
- response: privateJson(
360
+ let lastResponse: Response | null = null;
361
+ for (const provider of providers) {
362
+ const result = await provider.authorize(request, baseUrl);
363
+ if (!result.ok) {
364
+ lastResponse = result.response;
365
+ continue;
366
+ }
367
+ if (!result.userId) {
368
+ lastResponse = privateJson(
289
369
  { error: "authenticated user required" },
290
370
  { status: 403 },
291
- ),
292
- };
371
+ );
372
+ continue;
373
+ }
374
+ const binding = resolveIdentityBinding(
375
+ provider.toolkitBinding,
376
+ result.toolkitBinding,
377
+ );
378
+ if (!binding.ok) {
379
+ logger.warn(
380
+ `[connecta] refused a credential-API request admitted by inbound auth ` +
381
+ `provider "${provider.kind}" with 403: ${binding.reason}.`,
382
+ );
383
+ lastResponse = unusableBinding();
384
+ continue;
385
+ }
386
+ // A toolkit-bound identity is a team's credential, not a vault admin key:
387
+ // credentials are deployment-wide, so writing one reaches every toolkit.
388
+ if (isToolkitRestricted(binding.binding)) {
389
+ lastResponse = restrictedOperatorSurface();
390
+ continue;
391
+ }
392
+ return { ok: true, userId: result.userId };
293
393
  }
294
- return { ok: true, userId: result.userId };
394
+ return {
395
+ ok: false,
396
+ response:
397
+ lastResponse ??
398
+ privateJson({ error: "forbidden" }, { status: 403 }),
399
+ };
295
400
  }
296
401
 
297
402
  function isSameOrigin(request: Request, baseUrl: string): boolean {
@@ -424,7 +529,12 @@ async function handleCredentialRequest(
424
529
  { status: 403 },
425
530
  );
426
531
  }
427
- const admin = await authorizeUiAdmin(request, baseUrl, opts.auth);
532
+ const admin = await authorizeUiAdmin(
533
+ request,
534
+ baseUrl,
535
+ opts.auth,
536
+ opts.logger,
537
+ );
428
538
  if (!admin.ok) return admin.response;
429
539
 
430
540
  const connector = opts.registry.getConnector(connectorId);
@@ -464,6 +574,13 @@ async function handleCredentialRequest(
464
574
  }
465
575
  result = await connector.testCredential!(value, ctx);
466
576
  }
577
+ // The operator just ran the very check the liveness sweep runs; record it
578
+ // so the cached status surfaces agree with what /ui just showed them.
579
+ await opts.registry.recordCredentialHealth(connectorId, {
580
+ state: result.ok ? "ok" : "auth_required",
581
+ checkedAt: new Date().toISOString(),
582
+ ...(result.message ? { message: result.message } : {}),
583
+ });
467
584
  return privateJson(result);
468
585
  } catch (err) {
469
586
  return privateJson({ ok: false, message: msg(err) });
@@ -491,6 +608,9 @@ async function handleCredentialRequest(
491
608
  admin.userId,
492
609
  );
493
610
  opts.registry.invalidate(connectorId);
611
+ // The credential the last verdict judged is gone; judging its replacement
612
+ // is the next check's job, not this one's.
613
+ await opts.registry.clearCredentialHealth(connectorId);
494
614
  return privateJson({ credential: metadata });
495
615
  } catch (err) {
496
616
  return privateJson({ error: msg(err) }, { status: 400 });
@@ -500,6 +620,7 @@ async function handleCredentialRequest(
500
620
  if (request.method === "DELETE") {
501
621
  await opts.credentialVault.delete(connectorId);
502
622
  opts.registry.invalidate(connectorId);
623
+ await opts.registry.clearCredentialHealth(connectorId);
503
624
  return new Response(null, {
504
625
  status: 204,
505
626
  headers: {
@@ -523,35 +644,150 @@ interface McpScope {
523
644
  }
524
645
 
525
646
  /**
526
- * Resolve `?toolkit=<name>` into the registry view this connection may see.
647
+ * Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
648
+ * an identity id) for the operator log. Goes through JSON.stringify so a
649
+ * caller-controlled newline or control character cannot forge a log line, plus a
650
+ * hand-rolled escape for U+2028/U+2029, which JSON.stringify leaves raw even
651
+ * though a log reader treats them as line terminators. Truncated to the same
652
+ * length the response body echoes at, so an oversized value cannot flood the log
653
+ * either.
654
+ */
655
+ function loggableValue(requested: string): string {
656
+ const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
657
+ const escaped = JSON.stringify(bounded).replace(
658
+ /[\u2028\u2029]/g,
659
+ (ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
660
+ );
661
+ return escaped + (bounded.length < requested.length ? " (truncated)" : "");
662
+ }
663
+
664
+ /** The one refusal a bound identity ever sees. Constant on purpose — see below. */
665
+ const TOOLKIT_FORBIDDEN_BODY = JSON.stringify({
666
+ jsonrpc: "2.0",
667
+ id: null,
668
+ error: {
669
+ code: -32600,
670
+ message:
671
+ "Not permitted to use the requested toolkit. This credential is bound " +
672
+ "to a specific toolkit — check the ?toolkit= value in this deployment's " +
673
+ "MCP endpoint URL with the operator.",
674
+ },
675
+ });
676
+
677
+ /**
678
+ * 403 for every binding refusal, with a body that does not depend on WHY.
679
+ *
680
+ * A bound identity asking for a toolkit it may not open, for a toolkit that does
681
+ * not exist, or for no toolkit at all gets byte-identical responses, so a team
682
+ * credential cannot be used to enumerate the org's other teams — the boundary
683
+ * would leak the very structure it exists to hide. The operator log below is
684
+ * where the three cases are told apart.
685
+ */
686
+ function toolkitForbidden(): Response {
687
+ return new Response(TOOLKIT_FORBIDDEN_BODY, {
688
+ status: 403,
689
+ headers: {
690
+ "Content-Type": "application/json",
691
+ "Cache-Control": "no-store",
692
+ },
693
+ });
694
+ }
695
+
696
+ /** How a rejected connection is named in the operator log. */
697
+ function identityLabel(actor: ActivityActor): string {
698
+ return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
699
+ }
700
+
701
+ /**
702
+ * Resolve `?toolkit=<name>` into the registry view this connection may see,
703
+ * enforcing the caller's toolkit binding (§16) on the way.
704
+ *
705
+ * For an UNBOUND identity (no binding configured — the pre-#37 shape):
527
706
  *
528
707
  * - absent → the full registry, byte-identical to a deployment with no toolkits
529
- * - known → a `ScopedRegistry` over that toolkit (the one enforcement point)
708
+ * - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
530
709
  * - anything else, including `?toolkit=` with an empty value → an explicit
531
- * error. Never a silent fallback to the full registry.
710
+ * 404. Never a silent fallback to the full registry.
711
+ *
712
+ * For a BOUND identity, membership is checked FIRST and refusal is a flat 403:
713
+ * a toolkit outside the binding, an unknown name, and (without `unscoped`) an
714
+ * omitted `?toolkit=` are all refused before any `ScopedRegistry` is built, and
715
+ * all three produce the same response.
532
716
  *
533
- * The error deliberately does not enumerate the configured toolkits: the name
534
- * selects a scope, so a wrong guess gets a flat "unknown", not a directory.
717
+ * Neither error enumerates the configured toolkits: the name selects a scope, so
718
+ * a wrong guess gets a flat refusal, not a directory.
719
+ *
720
+ * Because of that — and because SDK clients treat a 404/403 on the transport
721
+ * endpoint as a transport failure and discard the body — every rejection is also
722
+ * logged operator-side (issue #47), which is the channel that actually reaches a
723
+ * human. The log line may name the configured or bound toolkits; the response
724
+ * still may not.
535
725
  */
536
726
  function resolveToolkitScope(
537
727
  url: URL,
538
728
  registry: Registry,
539
729
  toolkits: ReadonlyMap<string, Toolkit> | undefined,
730
+ logger: Logger,
731
+ identity: { actor: ActivityActor; binding?: ToolkitBinding },
540
732
  ):
541
733
  | { ok: true; scope: McpScope }
542
734
  | { ok: false; response: Response } {
543
735
  const requested = url.searchParams.get("toolkit");
736
+ const binding = identity.binding;
737
+ const scopeFor = (toolkit: Toolkit) => ({
738
+ ok: true as const,
739
+ scope: {
740
+ registry: new ScopedRegistry(registry, toolkit),
741
+ toolkitId: toolkit.name,
742
+ },
743
+ });
744
+
745
+ if (binding) {
746
+ const who = identityLabel(identity.actor);
747
+ const bound = `Bound toolkits: ${binding.toolkits.join(", ") || "(none)"}${
748
+ binding.unscoped ? ", plus unscoped access" : ""
749
+ }.`;
750
+ if (requested === null) {
751
+ if (binding.unscoped) return { ok: true, scope: { registry } };
752
+ logger.warn(
753
+ `[connecta] refused an unscoped /mcp connection from ${who} with 403: ` +
754
+ "its toolkit binding does not allow the full registry. " +
755
+ bound +
756
+ " The client sees a transport-level failure and never the reason, so " +
757
+ "give it an MCP endpoint URL with a ?toolkit= value it is bound to.",
758
+ );
759
+ return { ok: false, response: toolkitForbidden() };
760
+ }
761
+ const permitted = binding.toolkits.includes(requested);
762
+ const toolkit = permitted ? toolkits?.get(requested) : undefined;
763
+ if (toolkit) return scopeFor(toolkit);
764
+ logger.warn(
765
+ `[connecta] refused an /mcp connection from ${who} with 403: it asked ` +
766
+ `for toolkit ${loggableValue(requested)}, which ` +
767
+ (permitted
768
+ ? "its binding allows but this deployment does not configure"
769
+ : "its toolkit binding does not include") +
770
+ ". " +
771
+ bound +
772
+ " The client sees a transport-level failure and never the reason, so " +
773
+ "check the ?toolkit= value in its MCP endpoint URL.",
774
+ );
775
+ return { ok: false, response: toolkitForbidden() };
776
+ }
777
+
544
778
  if (requested === null) return { ok: true, scope: { registry } };
545
779
  const toolkit = toolkits?.get(requested);
546
- if (toolkit) {
547
- return {
548
- ok: true,
549
- scope: {
550
- registry: new ScopedRegistry(registry, toolkit),
551
- toolkitId: toolkit.name,
552
- },
553
- };
554
- }
780
+ if (toolkit) return scopeFor(toolkit);
781
+ const configured = toolkits && toolkits.size > 0 ? [...toolkits.keys()] : [];
782
+ logger.warn(
783
+ "[connecta] rejected an /mcp connection asking for unknown toolkit " +
784
+ `${loggableValue(requested)} with 404. ` +
785
+ (configured.length > 0
786
+ ? `Configured toolkits: ${configured.join(", ")}.`
787
+ : "This deployment configures no toolkits, so no ?toolkit= value is accepted.") +
788
+ " The client sees a transport-level failure and never the reason, so " +
789
+ "check the ?toolkit= value in its MCP endpoint URL.",
790
+ );
555
791
  const label =
556
792
  requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
557
793
  TOOLKIT_NAME_RE.test(requested)
@@ -581,6 +817,29 @@ function resolveToolkitScope(
581
817
  };
582
818
  }
583
819
 
820
+ /**
821
+ * True when this identity is confined to one or more toolkits — bound, without
822
+ * `unscoped`. Such a credential belongs to a team's agent, not to the operator
823
+ * running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
824
+ * `/ui/activity`, the credential API) refuse it: their payloads describe every
825
+ * connector in the org, which is exactly what the binding exists to withhold.
826
+ */
827
+ function isToolkitRestricted(binding: ToolkitBinding | undefined): boolean {
828
+ return Boolean(binding && !binding.unscoped);
829
+ }
830
+
831
+ /** The refusal the deployment-wide operator surfaces give a bound identity. */
832
+ function restrictedOperatorSurface(): Response {
833
+ return privateJson(
834
+ {
835
+ error:
836
+ "this credential is bound to a toolkit and may not read " +
837
+ "deployment-wide operator data",
838
+ },
839
+ { status: 403 },
840
+ );
841
+ }
842
+
584
843
  async function serveMcp(
585
844
  request: Request,
586
845
  opts: ServerOptions,
@@ -666,6 +925,10 @@ async function handleOAuthCallback(
666
925
  try {
667
926
  await connector.finishAuth(code, context);
668
927
  await registry.invalidateStored(id);
928
+ // Recovery, without a restart: the grant this connector was reported dead
929
+ // for has just been replaced, so drop the verdict rather than let a stale
930
+ // `auth_required` survive until the next scheduled check.
931
+ await registry.clearCredentialHealth(id);
669
932
  return html(
670
933
  `Connected "${id}". You can close this window.`,
671
934
  200,
@@ -691,6 +954,35 @@ export function createFetchHandler(
691
954
  const url = new URL(request.url);
692
955
  const baseUrl = publicUrl ?? url.origin;
693
956
  const path = url.pathname;
957
+
958
+ /**
959
+ * Piggyback a DUE credential liveness sweep on traffic that has already been
960
+ * authenticated (issue #24). Started beside the request and never awaited by
961
+ * it: it must not add latency or change a result, so it is handed to
962
+ * `ctx.waitUntil` where the runtime has one (Workers, and the Node adapter's
963
+ * shim) to settle after the response. The registry answers `undefined`
964
+ * unless a sweep is actually due, so the ordinary request pays nothing.
965
+ */
966
+ const sweepCredentials = (): void => {
967
+ // Belt and braces: a rejected sweep is already absorbed below, and this
968
+ // catches the synchronous half — arming the gate, or a connector list that
969
+ // throws while deciding whether anything is due. Nothing about a
970
+ // background health check may turn a served request into a 500.
971
+ try {
972
+ const sweep = registry.sweepCredentialHealthIfDue(baseUrl);
973
+ if (!sweep) return;
974
+ const settled = sweep.then(
975
+ () => {},
976
+ (err) => {
977
+ opts.logger.warn("[connecta] credential health sweep failed", err);
978
+ },
979
+ );
980
+ if (runtimeContext?.waitUntil) runtimeContext.waitUntil(settled);
981
+ else void settled;
982
+ } catch (err) {
983
+ opts.logger.warn("[connecta] credential health sweep failed", err);
984
+ }
985
+ };
694
986
  // Container and orchestrator probes reach /health over plain HTTP on
695
987
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
696
988
  // the public origin would make an internal liveness check depend on
@@ -770,6 +1062,7 @@ export function createFetchHandler(
770
1062
  headers: {
771
1063
  "Content-Type": "image/svg+xml",
772
1064
  "Cache-Control": "public, max-age=86400",
1065
+ ...INERT_ICON_HEADERS,
773
1066
  },
774
1067
  });
775
1068
  }
@@ -779,6 +1072,7 @@ export function createFetchHandler(
779
1072
  headers: {
780
1073
  "Content-Type": "image/x-icon",
781
1074
  "Cache-Control": "public, max-age=86400",
1075
+ ...INERT_ICON_HEADERS,
782
1076
  },
783
1077
  });
784
1078
  }
@@ -807,8 +1101,14 @@ export function createFetchHandler(
807
1101
  }
808
1102
 
809
1103
  if (path === "/ui/data") {
810
- const authz = await authorize(request, baseUrl, auth);
1104
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
811
1105
  if (!authz.ok) return authz.response;
1106
+ if (isToolkitRestricted(authz.toolkitBinding)) {
1107
+ return restrictedOperatorSurface();
1108
+ }
1109
+ // After the restriction check, not before: an identity that may not
1110
+ // read this surface should not get to trigger background work from it.
1111
+ sweepCredentials();
812
1112
  const data = await buildUiData(
813
1113
  registry,
814
1114
  baseUrl,
@@ -827,8 +1127,11 @@ export function createFetchHandler(
827
1127
  if (request.method !== "GET") {
828
1128
  return privateJson({ error: "method not allowed" }, { status: 405 });
829
1129
  }
830
- const authz = await authorize(request, baseUrl, auth);
1130
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
831
1131
  if (!authz.ok) return authz.response;
1132
+ if (isToolkitRestricted(authz.toolkitBinding)) {
1133
+ return restrictedOperatorSurface();
1134
+ }
832
1135
  if (
833
1136
  opts.activityReadGate &&
834
1137
  !(await opts.activityReadGate(authz.actor))
@@ -866,10 +1169,22 @@ export function createFetchHandler(
866
1169
  if (path === "/mcp") {
867
1170
  // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
868
1171
  // must not be able to probe which toolkit names exist.
869
- const authz = await authorize(request, baseUrl, auth);
1172
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
870
1173
  if (!authz.ok) return withMcpCors(authz.response);
871
- const selected = resolveToolkitScope(url, registry, opts.toolkits);
1174
+ const selected = resolveToolkitScope(
1175
+ url,
1176
+ registry,
1177
+ opts.toolkits,
1178
+ opts.logger,
1179
+ {
1180
+ actor: authz.actor,
1181
+ ...(authz.toolkitBinding
1182
+ ? { binding: authz.toolkitBinding }
1183
+ : {}),
1184
+ },
1185
+ );
872
1186
  if (!selected.ok) return withMcpCors(selected.response);
1187
+ sweepCredentials();
873
1188
  return withMcpCors(
874
1189
  await serveMcp(
875
1190
  request,
package/src/skills.ts CHANGED
@@ -16,7 +16,7 @@ export const USAGE_SKILL = `# Connecta usage
16
16
  - Truncated result: retry with \`fields\` when possible; otherwise page it with \`get_result\`.
17
17
  - \`auth_required\`: use \`authorize_connector\`, have the operator complete consent, then confirm with \`list_connectors\`.
18
18
 
19
- Use \`list_connectors({ probe: false })\` for a fast inventory. Use \`probe: true\` only when diagnosing live health or authorization.
19
+ Use \`list_connectors({ probe: false })\` for a fast inventory. Use \`probe: true\` only when diagnosing live health or authorization. The fast inventory already reports a connector whose stored credential failed a proactive check as \`auth_required\` (with \`credentialCheck\` and the URL to open), so trust it and authorize up front rather than probing to confirm.
20
20
 
21
21
  ## Code mode
22
22
 
package/src/timeout.ts ADDED
@@ -0,0 +1,49 @@
1
+ // The deadline vocabulary shared by every non-call downstream probe: the
2
+ // discovery meta-tools' catalog fan-out (src/meta-tools.ts) and the credential
3
+ // liveness checks (src/credential-health.ts). One definition so a "probe" means
4
+ // the same thing, and is bounded the same way, wherever one is issued.
5
+
6
+ /**
7
+ * Generous default bound for a single downstream probe/catalog call. High enough
8
+ * to trip only on a pathological hang, not a realistically slow probe.
9
+ */
10
+ export const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
11
+
12
+ /** A finite, positive integer number of milliseconds, or undefined. */
13
+ export function normalizeTimeoutMs(
14
+ value: number | undefined,
15
+ ): number | undefined {
16
+ if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
17
+ return undefined;
18
+ }
19
+ return Math.max(1, Math.trunc(value));
20
+ }
21
+
22
+ /**
23
+ * Reject `promise` after `ms` if it has not settled, so one hung downstream
24
+ * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
25
+ * the registry probe methods take no AbortSignal, so the underlying fetch is
26
+ * NOT cancelled and keeps running in the background. Real cancellation
27
+ * (AbortSignal plumbed through the registry) is a deferred follow-up.
28
+ */
29
+ export function withTimeout<T>(
30
+ promise: Promise<T>,
31
+ ms: number,
32
+ label: string,
33
+ ): Promise<T> {
34
+ return new Promise<T>((resolve, reject) => {
35
+ const timer = setTimeout(() => {
36
+ reject(new Error(`${label} timed out after ${ms}ms`));
37
+ }, ms);
38
+ promise.then(
39
+ (value) => {
40
+ clearTimeout(timer);
41
+ resolve(value);
42
+ },
43
+ (err) => {
44
+ clearTimeout(timer);
45
+ reject(err);
46
+ },
47
+ );
48
+ });
49
+ }