@zackbart/connecta 0.5.0 → 0.6.1

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 (76) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/README.md +159 -267
  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 +28 -3
  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 +220 -0
  15. package/dist/credential-health.d.ts.map +1 -0
  16. package/dist/credential-health.js +551 -0
  17. package/dist/credential-health.js.map +1 -0
  18. package/dist/credentials.d.ts +35 -1
  19. package/dist/credentials.d.ts.map +1 -1
  20. package/dist/credentials.js +42 -0
  21. package/dist/credentials.js.map +1 -1
  22. package/dist/execute.d.ts.map +1 -1
  23. package/dist/execute.js +16 -4
  24. package/dist/execute.js.map +1 -1
  25. package/dist/index.d.ts +46 -5
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +118 -15
  28. package/dist/index.js.map +1 -1
  29. package/dist/meta-tools.d.ts +56 -5
  30. package/dist/meta-tools.d.ts.map +1 -1
  31. package/dist/meta-tools.js +249 -92
  32. package/dist/meta-tools.js.map +1 -1
  33. package/dist/registry.d.ts +62 -0
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +85 -1
  36. package/dist/registry.js.map +1 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +305 -40
  39. package/dist/server.js.map +1 -1
  40. package/dist/skills.d.ts +1 -1
  41. package/dist/skills.d.ts.map +1 -1
  42. package/dist/skills.js +3 -3
  43. package/dist/skills.js.map +1 -1
  44. package/dist/timeout.d.ts +16 -0
  45. package/dist/timeout.d.ts.map +1 -0
  46. package/dist/timeout.js +38 -0
  47. package/dist/timeout.js.map +1 -0
  48. package/dist/toolkits.d.ts +95 -1
  49. package/dist/toolkits.d.ts.map +1 -1
  50. package/dist/toolkits.js +190 -5
  51. package/dist/toolkits.js.map +1 -1
  52. package/dist/types.d.ts +81 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/ui.d.ts +52 -0
  55. package/dist/ui.d.ts.map +1 -1
  56. package/dist/ui.js +144 -13
  57. package/dist/ui.js.map +1 -1
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
  61. package/src/auth/bearer.ts +35 -1
  62. package/src/auth/clerk.ts +204 -7
  63. package/src/connectors/remote-mcp.ts +9 -0
  64. package/src/credential-health.ts +753 -0
  65. package/src/credentials.ts +71 -1
  66. package/src/execute.ts +28 -4
  67. package/src/index.ts +204 -22
  68. package/src/meta-tools.ts +286 -109
  69. package/src/registry.ts +125 -1
  70. package/src/server.ts +366 -38
  71. package/src/skills.ts +3 -3
  72. package/src/timeout.ts +49 -0
  73. package/src/toolkits.ts +241 -6
  74. package/src/types.ts +87 -1
  75. package/src/ui.ts +156 -14
  76. package/src/version.ts +1 -1
package/src/server.ts CHANGED
@@ -10,9 +10,17 @@ import type {
10
10
  ActivityStore,
11
11
  } from "./activity.js";
12
12
  import { InvalidActivityCursorError } from "./activity.js";
13
+ import {
14
+ credentialTestRule,
15
+ describeCredentialTestMismatch,
16
+ } from "./credentials.js";
13
17
  import type { CredentialVault } from "./credentials.js";
14
18
  import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
15
- import { TOOLKIT_NAME_RE, type Toolkit } from "./toolkits.js";
19
+ import {
20
+ resolveIdentityBinding,
21
+ TOOLKIT_NAME_RE,
22
+ type Toolkit,
23
+ } from "./toolkits.js";
16
24
  import type {
17
25
  ConnectorCredentialConfig,
18
26
  ConnectorCredentialValues,
@@ -20,6 +28,7 @@ import type {
20
28
  Executor,
21
29
  InboundAuth,
22
30
  Logger,
31
+ ToolkitBinding,
23
32
  } from "./types.js";
24
33
  import { CONNECTA_FAVICON_ICO } from "./favicon.js";
25
34
  import {
@@ -36,6 +45,35 @@ const CORS_HEADERS = {
36
45
  "Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
37
46
  };
38
47
 
48
+ /**
49
+ * Headers that make an operator-supplied favicon body inert on this origin.
50
+ * The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
51
+ * so a `<script>` inside a branding SVG would run on the deployment origin the
52
+ * moment anyone navigated straight to `/favicon.svg` — strictly more powerful
53
+ * than the `favicon.href` vector the branding gates close, because the payload
54
+ * is same-origin. Neutralizing the response rather than inspecting the body
55
+ * keeps every valid static SVG (the built-in mark included) byte-identical:
56
+ *
57
+ * - `sandbox` (no tokens ⇒ every restriction) drops the document into an opaque
58
+ * origin with scripting off, so even a script that ran would have nothing to
59
+ * reach.
60
+ * - `default-src 'none'` denies script, network, and framing outright.
61
+ * - `style-src 'unsafe-inline'` is the single allowance: the default mark styles
62
+ * itself inline to follow the OS colour scheme, and CSS cannot script.
63
+ * - `nosniff` keeps the declared type authoritative in both directions — an SVG
64
+ * can never be re-read as HTML, and `.ico` bytes can never be re-read as SVG.
65
+ *
66
+ * `.ico` bodies are deliberately in scope: they are inert bytes rather than
67
+ * active content, so they are still served verbatim, but they carry the same
68
+ * headers so the invariant is "every favicon route is neutralized" rather than
69
+ * "whichever route got attention".
70
+ */
71
+ const INERT_ICON_HEADERS = {
72
+ "Content-Security-Policy":
73
+ "default-src 'none'; style-src 'unsafe-inline'; sandbox",
74
+ "X-Content-Type-Options": "nosniff",
75
+ };
76
+
39
77
  export interface ServerOptions {
40
78
  registry: Registry;
41
79
  auth: InboundAuth[];
@@ -161,16 +199,29 @@ function html(
161
199
  );
162
200
  }
163
201
 
202
+ /**
203
+ * Refusal for an identity whose toolkit binding cannot be trusted — a malformed
204
+ * declaration, or a malformed per-identity binding out of `authorize`. The
205
+ * caller is authenticated, so this is a 403, and it is deliberately opaque: the
206
+ * cause is an operator bug, and the operator reads it in the log, not the client.
207
+ */
208
+ function unusableBinding(): Response {
209
+ return privateJson({ error: "forbidden" }, { status: 403 });
210
+ }
211
+
164
212
  async function authorize(
165
213
  request: Request,
166
214
  baseUrl: string,
167
215
  auth: InboundAuth[],
216
+ logger: Logger,
168
217
  ): Promise<
169
218
  | {
170
219
  ok: true;
171
220
  actor: ActivityActor;
172
221
  providerKind?: string;
173
222
  userId?: string;
223
+ /** The admitting identity's toolkit binding, if it has one (§16). */
224
+ toolkitBinding?: ToolkitBinding;
174
225
  }
175
226
  | { ok: false; response: Response }
176
227
  > {
@@ -182,6 +233,24 @@ async function authorize(
182
233
  const result = await provider.authorize(request, baseUrl);
183
234
  if (result.ok) {
184
235
  const subjectId = result.subjectId ?? result.userId;
236
+ // Re-validate both halves and cap the per-identity one by the provider's
237
+ // declaration (see resolveIdentityBinding). A binding that does not
238
+ // type-check at runtime refuses the request rather than evaporating:
239
+ // dropping it would hand the caller the full registry, which is the one
240
+ // outcome a binding exists to prevent.
241
+ const binding = resolveIdentityBinding(
242
+ provider.toolkitBinding,
243
+ result.toolkitBinding,
244
+ );
245
+ if (!binding.ok) {
246
+ logger.warn(
247
+ `[connecta] refused a request admitted by inbound auth provider ` +
248
+ `"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
249
+ "this provider cannot admit anyone, because connecta cannot tell " +
250
+ "which toolkits the identity may use.",
251
+ );
252
+ return { ok: false, response: unusableBinding() };
253
+ }
185
254
  return {
186
255
  ok: true,
187
256
  actor: {
@@ -190,6 +259,7 @@ async function authorize(
190
259
  },
191
260
  providerKind: provider.kind,
192
261
  ...(result.userId ? { userId: result.userId } : {}),
262
+ ...(binding.binding ? { toolkitBinding: binding.binding } : {}),
193
263
  };
194
264
  }
195
265
  lastResponse = result.response;
@@ -266,12 +336,23 @@ async function authorizeUiAdmin(
266
336
  request: Request,
267
337
  baseUrl: string,
268
338
  auth: InboundAuth[],
339
+ logger: Logger,
269
340
  ): 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) {
341
+ // Credential mutation is intentionally narrower than /mcp and /ui/data: only
342
+ // an interactive Clerk provider may admit it. A static bearer token is useful
343
+ // for headless tool calls but must not become a vault-admin key.
344
+ //
345
+ // EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
346
+ // documented per-team pattern is several `clerkAuth(...)`s that differ only in
347
+ // `gate` and `toolkits` (§16). Stopping at the first would make admission
348
+ // depend on config order: the team-bound provider listed first would refuse
349
+ // the operator outright, and a refusal here — a failed gate, a missing user, a
350
+ // toolkit-bound identity — is exactly the case where a later provider is the
351
+ // one meant to admit. The last refusal is returned if none do.
352
+ const providers = auth.filter(
353
+ (candidate) => candidate.uiAuth?.kind === "clerk",
354
+ );
355
+ if (providers.length === 0) {
275
356
  return {
276
357
  ok: false,
277
358
  response: privateJson(
@@ -280,18 +361,46 @@ async function authorizeUiAdmin(
280
361
  ),
281
362
  };
282
363
  }
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(
364
+ let lastResponse: Response | null = null;
365
+ for (const provider of providers) {
366
+ const result = await provider.authorize(request, baseUrl);
367
+ if (!result.ok) {
368
+ lastResponse = result.response;
369
+ continue;
370
+ }
371
+ if (!result.userId) {
372
+ lastResponse = privateJson(
289
373
  { error: "authenticated user required" },
290
374
  { status: 403 },
291
- ),
292
- };
375
+ );
376
+ continue;
377
+ }
378
+ const binding = resolveIdentityBinding(
379
+ provider.toolkitBinding,
380
+ result.toolkitBinding,
381
+ );
382
+ if (!binding.ok) {
383
+ logger.warn(
384
+ `[connecta] refused a credential-API request admitted by inbound auth ` +
385
+ `provider "${provider.kind}" with 403: ${binding.reason}.`,
386
+ );
387
+ lastResponse = unusableBinding();
388
+ continue;
389
+ }
390
+ // A toolkit-bound identity is a team's credential, not a vault admin key:
391
+ // credentials are deployment-wide, so writing one reaches every toolkit.
392
+ if (isToolkitRestricted(binding.binding)) {
393
+ lastResponse = restrictedOperatorSurface();
394
+ continue;
395
+ }
396
+ return { ok: true, userId: result.userId };
293
397
  }
294
- return { ok: true, userId: result.userId };
398
+ return {
399
+ ok: false,
400
+ response:
401
+ lastResponse ??
402
+ privateJson({ error: "forbidden" }, { status: 403 }),
403
+ };
295
404
  }
296
405
 
297
406
  function isSameOrigin(request: Request, baseUrl: string): boolean {
@@ -424,7 +533,12 @@ async function handleCredentialRequest(
424
533
  { status: 403 },
425
534
  );
426
535
  }
427
- const admin = await authorizeUiAdmin(request, baseUrl, opts.auth);
536
+ const admin = await authorizeUiAdmin(
537
+ request,
538
+ baseUrl,
539
+ opts.auth,
540
+ opts.logger,
541
+ );
428
542
  if (!admin.ok) return admin.response;
429
543
 
430
544
  const connector = opts.registry.getConnector(connectorId);
@@ -436,16 +550,25 @@ async function handleCredentialRequest(
436
550
  if (request.method !== "POST") {
437
551
  return privateJson({ error: "method not allowed" }, { status: 405 });
438
552
  }
439
- if (!connector.testCredential && !connector.testCredentials) {
553
+ // The declared credential shape picks the hook — the same single rule /ui
554
+ // asks for its Test affordance, so a shown button always reaches a hook
555
+ // that reads the shape the credential was stored in.
556
+ const rule = credentialTestRule(connector);
557
+ if (!rule.mode) {
440
558
  return privateJson(
441
- { error: "this connector does not support credential testing" },
559
+ {
560
+ error: rule.mismatch
561
+ ? "this connector cannot test its credential: " +
562
+ describeCredentialTestMismatch(rule.mismatch)
563
+ : "this connector does not support credential testing",
564
+ },
442
565
  { status: 400 },
443
566
  );
444
567
  }
445
568
  try {
446
569
  const ctx = opts.registry.contextFor(connectorId, baseUrl);
447
570
  let result;
448
- if (connector.testCredentials) {
571
+ if (rule.mode === "multiple") {
449
572
  const values = await opts.credentialVault.getAll(connectorId);
450
573
  if (!values) {
451
574
  return privateJson(
@@ -453,7 +576,7 @@ async function handleCredentialRequest(
453
576
  { status: 409 },
454
577
  );
455
578
  }
456
- result = await connector.testCredentials(values, ctx);
579
+ result = await connector.testCredentials!(values, ctx);
457
580
  } else {
458
581
  const value = await opts.credentialVault.get(connectorId);
459
582
  if (!value) {
@@ -464,6 +587,13 @@ async function handleCredentialRequest(
464
587
  }
465
588
  result = await connector.testCredential!(value, ctx);
466
589
  }
590
+ // The operator just ran the very check the liveness sweep runs; record it
591
+ // so the cached status surfaces agree with what /ui just showed them.
592
+ await opts.registry.recordCredentialHealth(connectorId, {
593
+ state: result.ok ? "ok" : "auth_required",
594
+ checkedAt: new Date().toISOString(),
595
+ ...(result.message ? { message: result.message } : {}),
596
+ });
467
597
  return privateJson(result);
468
598
  } catch (err) {
469
599
  return privateJson({ ok: false, message: msg(err) });
@@ -491,6 +621,9 @@ async function handleCredentialRequest(
491
621
  admin.userId,
492
622
  );
493
623
  opts.registry.invalidate(connectorId);
624
+ // The credential the last verdict judged is gone; judging its replacement
625
+ // is the next check's job, not this one's.
626
+ await opts.registry.clearCredentialHealth(connectorId);
494
627
  return privateJson({ credential: metadata });
495
628
  } catch (err) {
496
629
  return privateJson({ error: msg(err) }, { status: 400 });
@@ -500,6 +633,7 @@ async function handleCredentialRequest(
500
633
  if (request.method === "DELETE") {
501
634
  await opts.credentialVault.delete(connectorId);
502
635
  opts.registry.invalidate(connectorId);
636
+ await opts.registry.clearCredentialHealth(connectorId);
503
637
  return new Response(null, {
504
638
  status: 204,
505
639
  headers: {
@@ -523,35 +657,150 @@ interface McpScope {
523
657
  }
524
658
 
525
659
  /**
526
- * Resolve `?toolkit=<name>` into the registry view this connection may see.
660
+ * Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
661
+ * an identity id) for the operator log. Goes through JSON.stringify so a
662
+ * caller-controlled newline or control character cannot forge a log line, plus a
663
+ * hand-rolled escape for U+2028/U+2029, which JSON.stringify leaves raw even
664
+ * though a log reader treats them as line terminators. Truncated to the same
665
+ * length the response body echoes at, so an oversized value cannot flood the log
666
+ * either.
667
+ */
668
+ function loggableValue(requested: string): string {
669
+ const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
670
+ const escaped = JSON.stringify(bounded).replace(
671
+ /[\u2028\u2029]/g,
672
+ (ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
673
+ );
674
+ return escaped + (bounded.length < requested.length ? " (truncated)" : "");
675
+ }
676
+
677
+ /** The one refusal a bound identity ever sees. Constant on purpose — see below. */
678
+ const TOOLKIT_FORBIDDEN_BODY = JSON.stringify({
679
+ jsonrpc: "2.0",
680
+ id: null,
681
+ error: {
682
+ code: -32600,
683
+ message:
684
+ "Not permitted to use the requested toolkit. This credential is bound " +
685
+ "to a specific toolkit — check the ?toolkit= value in this deployment's " +
686
+ "MCP endpoint URL with the operator.",
687
+ },
688
+ });
689
+
690
+ /**
691
+ * 403 for every binding refusal, with a body that does not depend on WHY.
692
+ *
693
+ * A bound identity asking for a toolkit it may not open, for a toolkit that does
694
+ * not exist, or for no toolkit at all gets byte-identical responses, so a team
695
+ * credential cannot be used to enumerate the org's other teams — the boundary
696
+ * would leak the very structure it exists to hide. The operator log below is
697
+ * where the three cases are told apart.
698
+ */
699
+ function toolkitForbidden(): Response {
700
+ return new Response(TOOLKIT_FORBIDDEN_BODY, {
701
+ status: 403,
702
+ headers: {
703
+ "Content-Type": "application/json",
704
+ "Cache-Control": "no-store",
705
+ },
706
+ });
707
+ }
708
+
709
+ /** How a rejected connection is named in the operator log. */
710
+ function identityLabel(actor: ActivityActor): string {
711
+ return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
712
+ }
713
+
714
+ /**
715
+ * Resolve `?toolkit=<name>` into the registry view this connection may see,
716
+ * enforcing the caller's toolkit binding (§16) on the way.
717
+ *
718
+ * For an UNBOUND identity (no binding configured — the pre-#37 shape):
527
719
  *
528
720
  * - absent → the full registry, byte-identical to a deployment with no toolkits
529
- * - known → a `ScopedRegistry` over that toolkit (the one enforcement point)
721
+ * - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
530
722
  * - anything else, including `?toolkit=` with an empty value → an explicit
531
- * error. Never a silent fallback to the full registry.
723
+ * 404. Never a silent fallback to the full registry.
532
724
  *
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.
725
+ * For a BOUND identity, membership is checked FIRST and refusal is a flat 403:
726
+ * a toolkit outside the binding, an unknown name, and (without `unscoped`) an
727
+ * omitted `?toolkit=` are all refused before any `ScopedRegistry` is built, and
728
+ * all three produce the same response.
729
+ *
730
+ * Neither error enumerates the configured toolkits: the name selects a scope, so
731
+ * a wrong guess gets a flat refusal, not a directory.
732
+ *
733
+ * Because of that — and because SDK clients treat a 404/403 on the transport
734
+ * endpoint as a transport failure and discard the body — every rejection is also
735
+ * logged operator-side (issue #47), which is the channel that actually reaches a
736
+ * human. The log line may name the configured or bound toolkits; the response
737
+ * still may not.
535
738
  */
536
739
  function resolveToolkitScope(
537
740
  url: URL,
538
741
  registry: Registry,
539
742
  toolkits: ReadonlyMap<string, Toolkit> | undefined,
743
+ logger: Logger,
744
+ identity: { actor: ActivityActor; binding?: ToolkitBinding },
540
745
  ):
541
746
  | { ok: true; scope: McpScope }
542
747
  | { ok: false; response: Response } {
543
748
  const requested = url.searchParams.get("toolkit");
749
+ const binding = identity.binding;
750
+ const scopeFor = (toolkit: Toolkit) => ({
751
+ ok: true as const,
752
+ scope: {
753
+ registry: new ScopedRegistry(registry, toolkit),
754
+ toolkitId: toolkit.name,
755
+ },
756
+ });
757
+
758
+ if (binding) {
759
+ const who = identityLabel(identity.actor);
760
+ const bound = `Bound toolkits: ${binding.toolkits.join(", ") || "(none)"}${
761
+ binding.unscoped ? ", plus unscoped access" : ""
762
+ }.`;
763
+ if (requested === null) {
764
+ if (binding.unscoped) return { ok: true, scope: { registry } };
765
+ logger.warn(
766
+ `[connecta] refused an unscoped /mcp connection from ${who} with 403: ` +
767
+ "its toolkit binding does not allow the full registry. " +
768
+ bound +
769
+ " The client sees a transport-level failure and never the reason, so " +
770
+ "give it an MCP endpoint URL with a ?toolkit= value it is bound to.",
771
+ );
772
+ return { ok: false, response: toolkitForbidden() };
773
+ }
774
+ const permitted = binding.toolkits.includes(requested);
775
+ const toolkit = permitted ? toolkits?.get(requested) : undefined;
776
+ if (toolkit) return scopeFor(toolkit);
777
+ logger.warn(
778
+ `[connecta] refused an /mcp connection from ${who} with 403: it asked ` +
779
+ `for toolkit ${loggableValue(requested)}, which ` +
780
+ (permitted
781
+ ? "its binding allows but this deployment does not configure"
782
+ : "its toolkit binding does not include") +
783
+ ". " +
784
+ bound +
785
+ " The client sees a transport-level failure and never the reason, so " +
786
+ "check the ?toolkit= value in its MCP endpoint URL.",
787
+ );
788
+ return { ok: false, response: toolkitForbidden() };
789
+ }
790
+
544
791
  if (requested === null) return { ok: true, scope: { registry } };
545
792
  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
- }
793
+ if (toolkit) return scopeFor(toolkit);
794
+ const configured = toolkits && toolkits.size > 0 ? [...toolkits.keys()] : [];
795
+ logger.warn(
796
+ "[connecta] rejected an /mcp connection asking for unknown toolkit " +
797
+ `${loggableValue(requested)} with 404. ` +
798
+ (configured.length > 0
799
+ ? `Configured toolkits: ${configured.join(", ")}.`
800
+ : "This deployment configures no toolkits, so no ?toolkit= value is accepted.") +
801
+ " The client sees a transport-level failure and never the reason, so " +
802
+ "check the ?toolkit= value in its MCP endpoint URL.",
803
+ );
555
804
  const label =
556
805
  requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
557
806
  TOOLKIT_NAME_RE.test(requested)
@@ -581,6 +830,29 @@ function resolveToolkitScope(
581
830
  };
582
831
  }
583
832
 
833
+ /**
834
+ * True when this identity is confined to one or more toolkits — bound, without
835
+ * `unscoped`. Such a credential belongs to a team's agent, not to the operator
836
+ * running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
837
+ * `/ui/activity`, the credential API) refuse it: their payloads describe every
838
+ * connector in the org, which is exactly what the binding exists to withhold.
839
+ */
840
+ function isToolkitRestricted(binding: ToolkitBinding | undefined): boolean {
841
+ return Boolean(binding && !binding.unscoped);
842
+ }
843
+
844
+ /** The refusal the deployment-wide operator surfaces give a bound identity. */
845
+ function restrictedOperatorSurface(): Response {
846
+ return privateJson(
847
+ {
848
+ error:
849
+ "this credential is bound to a toolkit and may not read " +
850
+ "deployment-wide operator data",
851
+ },
852
+ { status: 403 },
853
+ );
854
+ }
855
+
584
856
  async function serveMcp(
585
857
  request: Request,
586
858
  opts: ServerOptions,
@@ -666,6 +938,10 @@ async function handleOAuthCallback(
666
938
  try {
667
939
  await connector.finishAuth(code, context);
668
940
  await registry.invalidateStored(id);
941
+ // Recovery, without a restart: the grant this connector was reported dead
942
+ // for has just been replaced, so drop the verdict rather than let a stale
943
+ // `auth_required` survive until the next scheduled check.
944
+ await registry.clearCredentialHealth(id);
669
945
  return html(
670
946
  `Connected "${id}". You can close this window.`,
671
947
  200,
@@ -691,6 +967,35 @@ export function createFetchHandler(
691
967
  const url = new URL(request.url);
692
968
  const baseUrl = publicUrl ?? url.origin;
693
969
  const path = url.pathname;
970
+
971
+ /**
972
+ * Piggyback a DUE credential liveness sweep on traffic that has already been
973
+ * authenticated (issue #24). Started beside the request and never awaited by
974
+ * it: it must not add latency or change a result, so it is handed to
975
+ * `ctx.waitUntil` where the runtime has one (Workers, and the Node adapter's
976
+ * shim) to settle after the response. The registry answers `undefined`
977
+ * unless a sweep is actually due, so the ordinary request pays nothing.
978
+ */
979
+ const sweepCredentials = (): void => {
980
+ // Belt and braces: a rejected sweep is already absorbed below, and this
981
+ // catches the synchronous half — arming the gate, or a connector list that
982
+ // throws while deciding whether anything is due. Nothing about a
983
+ // background health check may turn a served request into a 500.
984
+ try {
985
+ const sweep = registry.sweepCredentialHealthIfDue(baseUrl);
986
+ if (!sweep) return;
987
+ const settled = sweep.then(
988
+ () => {},
989
+ (err) => {
990
+ opts.logger.warn("[connecta] credential health sweep failed", err);
991
+ },
992
+ );
993
+ if (runtimeContext?.waitUntil) runtimeContext.waitUntil(settled);
994
+ else void settled;
995
+ } catch (err) {
996
+ opts.logger.warn("[connecta] credential health sweep failed", err);
997
+ }
998
+ };
694
999
  // Container and orchestrator probes reach /health over plain HTTP on
695
1000
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
696
1001
  // the public origin would make an internal liveness check depend on
@@ -770,6 +1075,7 @@ export function createFetchHandler(
770
1075
  headers: {
771
1076
  "Content-Type": "image/svg+xml",
772
1077
  "Cache-Control": "public, max-age=86400",
1078
+ ...INERT_ICON_HEADERS,
773
1079
  },
774
1080
  });
775
1081
  }
@@ -779,6 +1085,7 @@ export function createFetchHandler(
779
1085
  headers: {
780
1086
  "Content-Type": "image/x-icon",
781
1087
  "Cache-Control": "public, max-age=86400",
1088
+ ...INERT_ICON_HEADERS,
782
1089
  },
783
1090
  });
784
1091
  }
@@ -807,8 +1114,14 @@ export function createFetchHandler(
807
1114
  }
808
1115
 
809
1116
  if (path === "/ui/data") {
810
- const authz = await authorize(request, baseUrl, auth);
1117
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
811
1118
  if (!authz.ok) return authz.response;
1119
+ if (isToolkitRestricted(authz.toolkitBinding)) {
1120
+ return restrictedOperatorSurface();
1121
+ }
1122
+ // After the restriction check, not before: an identity that may not
1123
+ // read this surface should not get to trigger background work from it.
1124
+ sweepCredentials();
812
1125
  const data = await buildUiData(
813
1126
  registry,
814
1127
  baseUrl,
@@ -827,8 +1140,11 @@ export function createFetchHandler(
827
1140
  if (request.method !== "GET") {
828
1141
  return privateJson({ error: "method not allowed" }, { status: 405 });
829
1142
  }
830
- const authz = await authorize(request, baseUrl, auth);
1143
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
831
1144
  if (!authz.ok) return authz.response;
1145
+ if (isToolkitRestricted(authz.toolkitBinding)) {
1146
+ return restrictedOperatorSurface();
1147
+ }
832
1148
  if (
833
1149
  opts.activityReadGate &&
834
1150
  !(await opts.activityReadGate(authz.actor))
@@ -866,10 +1182,22 @@ export function createFetchHandler(
866
1182
  if (path === "/mcp") {
867
1183
  // Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
868
1184
  // must not be able to probe which toolkit names exist.
869
- const authz = await authorize(request, baseUrl, auth);
1185
+ const authz = await authorize(request, baseUrl, auth, opts.logger);
870
1186
  if (!authz.ok) return withMcpCors(authz.response);
871
- const selected = resolveToolkitScope(url, registry, opts.toolkits);
1187
+ const selected = resolveToolkitScope(
1188
+ url,
1189
+ registry,
1190
+ opts.toolkits,
1191
+ opts.logger,
1192
+ {
1193
+ actor: authz.actor,
1194
+ ...(authz.toolkitBinding
1195
+ ? { binding: authz.toolkitBinding }
1196
+ : {}),
1197
+ },
1198
+ );
872
1199
  if (!selected.ok) return withMcpCors(selected.response);
1200
+ sweepCredentials();
873
1201
  return withMcpCors(
874
1202
  await serveMcp(
875
1203
  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
 
@@ -235,12 +235,12 @@ export function resolveSkill(
235
235
  return {
236
236
  found: false,
237
237
  message: connectorGuide(bare)
238
- ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available: ${available()}.`
238
+ ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available skills: ${available()}.`
239
239
  : `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
240
240
  };
241
241
  }
242
242
  return {
243
243
  found: false,
244
- message: `Unknown skill "${name}". Available: ${available()}.`,
244
+ message: `Unknown skill "${name}". Available skills: ${available()}.`,
245
245
  };
246
246
  }
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
+ }