@zackbart/connecta 0.6.1 → 0.7.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 (69) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +24 -20
  3. package/dist/auth/bearer.d.ts +4 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +10 -8
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +8 -7
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +27 -8
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connector-scope.d.ts +13 -0
  12. package/dist/connector-scope.d.ts.map +1 -0
  13. package/dist/connector-scope.js +35 -0
  14. package/dist/connector-scope.js.map +1 -0
  15. package/dist/connectors/api.d.ts +5 -5
  16. package/dist/connectors/api.d.ts.map +1 -1
  17. package/dist/connectors/remote-mcp.d.ts +3 -3
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +309 -10
  20. package/dist/connectors/remote-mcp.js.map +1 -1
  21. package/dist/credential-health.d.ts +8 -5
  22. package/dist/credential-health.d.ts.map +1 -1
  23. package/dist/credential-health.js +99 -51
  24. package/dist/credential-health.js.map +1 -1
  25. package/dist/credentials.d.ts +51 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +68 -3
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/index.d.ts +83 -82
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +74 -24
  32. package/dist/index.js.map +1 -1
  33. package/dist/meta-tools.d.ts +3 -3
  34. package/dist/meta-tools.d.ts.map +1 -1
  35. package/dist/meta-tools.js +16 -7
  36. package/dist/meta-tools.js.map +1 -1
  37. package/dist/registry.d.ts +3 -2
  38. package/dist/registry.d.ts.map +1 -1
  39. package/dist/registry.js +4 -3
  40. package/dist/registry.js.map +1 -1
  41. package/dist/server.d.ts +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +144 -52
  44. package/dist/server.js.map +1 -1
  45. package/dist/toolkits.js +1 -1
  46. package/dist/types.d.ts +41 -27
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/ui.d.ts +24 -10
  49. package/dist/ui.d.ts.map +1 -1
  50. package/dist/ui.js +594 -172
  51. package/dist/ui.js.map +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +3 -2
  55. package/src/auth/bearer.ts +10 -8
  56. package/src/auth/clerk.ts +28 -9
  57. package/src/connector-scope.ts +41 -0
  58. package/src/connectors/api.ts +5 -5
  59. package/src/connectors/remote-mcp.ts +348 -25
  60. package/src/credential-health.ts +120 -57
  61. package/src/credentials.ts +96 -3
  62. package/src/index.ts +169 -106
  63. package/src/meta-tools.ts +22 -7
  64. package/src/registry.ts +4 -3
  65. package/src/server.ts +183 -70
  66. package/src/toolkits.ts +1 -1
  67. package/src/types.ts +41 -27
  68. package/src/ui.ts +631 -170
  69. package/src/version.ts +1 -1
package/src/server.ts CHANGED
@@ -13,6 +13,7 @@ import { InvalidActivityCursorError } from "./activity.js";
13
13
  import {
14
14
  credentialTestRule,
15
15
  describeCredentialTestMismatch,
16
+ storedCredentialShape,
16
17
  } from "./credentials.js";
17
18
  import type { CredentialVault } from "./credentials.js";
18
19
  import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
@@ -22,6 +23,7 @@ import {
22
23
  type Toolkit,
23
24
  } from "./toolkits.js";
24
25
  import type {
26
+ ConnectorContext,
25
27
  ConnectorCredentialConfig,
26
28
  ConnectorCredentialValues,
27
29
  ConnectaBranding,
@@ -34,6 +36,8 @@ import { CONNECTA_FAVICON_ICO } from "./favicon.js";
34
36
  import {
35
37
  buildUiData,
36
38
  CONNECTA_FAVICON_SVG,
39
+ credentialManagementCapability,
40
+ operatorPageForPath,
37
41
  resolveBranding,
38
42
  renderUiHtml,
39
43
  } from "./ui.js";
@@ -92,7 +96,7 @@ export interface ServerOptions {
92
96
  probeTimeoutMs?: number;
93
97
  /** When set, the execute_code meta-tool is registered on top of the nine. */
94
98
  executor?: Executor;
95
- /** Encrypted connector-credential storage backing the authenticated /ui controls. */
99
+ /** Encrypted connector-credential storage backing the Credentials page. */
96
100
  credentialVault?: CredentialVault;
97
101
  /** Optional browser UI and OAuth result-page labels. */
98
102
  branding?: ConnectaBranding;
@@ -107,7 +111,7 @@ function msg(err: unknown): string {
107
111
  return err instanceof Error ? err.message : String(err);
108
112
  }
109
113
 
110
- /** Per-request base64 nonce for the /ui page's inline scripts (Node 20+ and Workers). */
114
+ /** Per-request base64 nonce for an operator shell's scripts (Node 20+ and Workers). */
111
115
  function uiScriptNonce(): string {
112
116
  const bytes = crypto.getRandomValues(new Uint8Array(16));
113
117
  let binary = "";
@@ -190,7 +194,7 @@ function html(
190
194
  <h1>Connection status</h1>
191
195
  <div class="copy">
192
196
  <p>${escapeHtml(body)}</p>
193
- <p><a href="/ui">Return to ${escapeHtml(brand.productName)}</a></p>
197
+ <p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
194
198
  </div>
195
199
  </main>
196
200
  </body>
@@ -220,7 +224,7 @@ async function authorize(
220
224
  actor: ActivityActor;
221
225
  providerKind?: string;
222
226
  userId?: string;
223
- /** The admitting identity's toolkit binding, if it has one (§16). */
227
+ /** The admitting identity's toolkit binding (docs/toolkits.md). */
224
228
  toolkitBinding?: ToolkitBinding;
225
229
  }
226
230
  | { ok: false; response: Response }
@@ -316,10 +320,10 @@ function withSecurityHeaders(
316
320
  if (requestUrl.protocol === "https:") {
317
321
  headers.set("Strict-Transport-Security", "max-age=31536000");
318
322
  }
319
- if (path === "/ui") {
320
- // The /ui GET response ships its own nonce-based script CSP (which already
321
- // includes frame-ancestors 'none'); only fall back to the framing-only
322
- // directive when no CSP is present (e.g. HTTPS redirects, error responses).
323
+ if (operatorPageForPath(path) || path === "/ui") {
324
+ // Operator HTML responses ship their own nonce-based script CSP (which
325
+ // already includes frame-ancestors 'none'); only fall back to the
326
+ // framing-only directive when no CSP is present (for example redirects).
323
327
  if (!headers.has("Content-Security-Policy")) {
324
328
  headers.set("Content-Security-Policy", "frame-ancestors 'none'");
325
329
  }
@@ -344,11 +348,11 @@ async function authorizeUiAdmin(
344
348
  //
345
349
  // EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
346
350
  // 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.
351
+ // `gate` and `toolkits` (docs/toolkits.md). Stopping at the first would make
352
+ // admission depend on config order: the team-bound provider listed first
353
+ // would refuse the operator outright, and a refusal here — a failed gate, a
354
+ // missing user, a toolkit-bound identity — is exactly the case where a later
355
+ // provider is the one meant to admit. The last refusal is returned if none do.
352
356
  const providers = auth.filter(
353
357
  (candidate) => candidate.uiAuth?.kind === "clerk",
354
358
  );
@@ -550,8 +554,8 @@ async function handleCredentialRequest(
550
554
  if (request.method !== "POST") {
551
555
  return privateJson({ error: "method not allowed" }, { status: 405 });
552
556
  }
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
557
+ // The declared credential shape picks the hook — the same single rule the
558
+ // Credentials page asks for its Test affordance, so a shown button reaches
555
559
  // that reads the shape the credential was stored in.
556
560
  const rule = credentialTestRule(connector);
557
561
  if (!rule.mode) {
@@ -566,29 +570,32 @@ async function handleCredentialRequest(
566
570
  );
567
571
  }
568
572
  try {
573
+ const values = await opts.credentialVault.getAll(connectorId);
574
+ const shape = storedCredentialShape(connector.credential, values);
575
+ if (shape.state === "missing") {
576
+ return privateJson(
577
+ {
578
+ error:
579
+ rule.mode === "multiple"
580
+ ? "configure the credentials before testing them"
581
+ : "configure the credential before testing it",
582
+ },
583
+ { status: 409 },
584
+ );
585
+ }
586
+ if (shape.state === "mismatch") {
587
+ return privateJson({ error: shape.message }, { status: 409 });
588
+ }
589
+ const storedValues = values!;
569
590
  const ctx = opts.registry.contextFor(connectorId, baseUrl);
570
591
  let result;
571
592
  if (rule.mode === "multiple") {
572
- const values = await opts.credentialVault.getAll(connectorId);
573
- if (!values) {
574
- return privateJson(
575
- { error: "configure the credentials before testing them" },
576
- { status: 409 },
577
- );
578
- }
579
- result = await connector.testCredentials!(values, ctx);
593
+ result = await connector.testCredentials!(storedValues, ctx);
580
594
  } else {
581
- const value = await opts.credentialVault.get(connectorId);
582
- if (!value) {
583
- return privateJson(
584
- { error: "configure the credential before testing it" },
585
- { status: 409 },
586
- );
587
- }
588
- result = await connector.testCredential!(value, ctx);
595
+ result = await connector.testCredential!(storedValues.value, ctx);
589
596
  }
590
597
  // 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.
598
+ // so cached status surfaces agree with what the operator page showed.
592
599
  await opts.registry.recordCredentialHealth(connectorId, {
593
600
  state: result.ok ? "ok" : "auth_required",
594
601
  checkedAt: new Date().toISOString(),
@@ -658,12 +665,12 @@ interface McpScope {
658
665
 
659
666
  /**
660
667
  * 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.
668
+ * identity id, or OAuth callback connector id) for the operator log. Goes
669
+ * through JSON.stringify so a caller-controlled newline or control character
670
+ * cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
671
+ * JSON.stringify leaves raw even though a log reader treats them as line
672
+ * terminators. Truncated to a small shared cap (also the toolkit response's echo
673
+ * limit), so an oversized value cannot flood the log either.
667
674
  */
668
675
  function loggableValue(requested: string): string {
669
676
  const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
@@ -713,7 +720,7 @@ function identityLabel(actor: ActivityActor): string {
713
720
 
714
721
  /**
715
722
  * Resolve `?toolkit=<name>` into the registry view this connection may see,
716
- * enforcing the caller's toolkit binding (§16) on the way.
723
+ * enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
717
724
  *
718
725
  * For an UNBOUND identity (no binding configured — the pre-#37 shape):
719
726
  *
@@ -905,35 +912,105 @@ async function serveMcp(
905
912
  return transport.handleRequest(request);
906
913
  }
907
914
 
915
+ /**
916
+ * Pay the storage read a real downstream-OAuth refusal pays, on the refusal
917
+ * paths that would otherwise pay nothing.
918
+ *
919
+ * Identical bodies do not hide a connector id if the clock still sorts them.
920
+ * `KvOAuthProvider.verifyState` reads `oauth:state` before it can fail, so a
921
+ * configured id costs one storage round trip — on the Workers deployment shape
922
+ * that is a real KV read, tens of milliseconds cold — while an id that names
923
+ * nothing used to return having touched no I/O at all. That gap is an oracle:
924
+ * sample the two and a wordlist recovers the connector list the flat 400 was
925
+ * meant to withhold. So the zero-I/O refusals read the same key in the same
926
+ * `conn:<id>:` namespace, which for an unconfigured id is simply a miss.
927
+ *
928
+ * This is deliberately *not* a constant-time claim, and docs/connectors.md says
929
+ * so in prose: a hit and a miss are not identical in a KV store, and a connector
930
+ * shipping its own `verifyState` may do more or less work than one read. What it
931
+ * removes is the order-of-magnitude "no I/O versus a round trip" difference,
932
+ * which is the only part of the signal that makes enumeration cheap.
933
+ *
934
+ * A throwing read is swallowed: the refusal is the answer either way, and
935
+ * turning it into a 500 would hand back exactly the distinguishable response
936
+ * this whole path exists to deny.
937
+ */
938
+ async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
939
+ try {
940
+ await context.storage.get("oauth:state");
941
+ } catch {
942
+ // Deliberately ignored — see above.
943
+ }
944
+ }
945
+
908
946
  async function handleOAuthCallback(
909
947
  url: URL,
910
948
  registry: Registry,
911
949
  baseUrl: string,
950
+ logger: Logger,
912
951
  branding?: ConnectaBranding,
913
952
  ): Promise<Response> {
914
- const id = url.pathname.slice("/oauth/callback/".length);
915
- const connector = registry.getConnector(id);
916
- if (!connector || !connector.finishAuth) {
917
- return html(`Unknown connector "${id}".`, 404, branding);
918
- }
919
953
  const error = url.searchParams.get("error");
920
954
  if (error) return html(`Authorization denied: ${error}`, 400, branding);
921
955
  const code = url.searchParams.get("code");
922
956
  if (!code) return html("Missing authorization code.", 400, branding);
957
+ const id = url.pathname.slice("/oauth/callback/".length);
958
+ const connector = registry.getConnector(id);
959
+ // Safe to build before we know the id names anything: `contextFor` is a pure
960
+ // constructor — a namespaced storage view over `conn:<id>:` and, only for a
961
+ // connector that declares one, a lazy credential accessor. It neither throws
962
+ // nor touches storage for an unknown id, which is what lets the refusals
963
+ // below borrow it to equalize their cost.
923
964
  const context = registry.contextFor(id, baseUrl);
965
+ const refused = () =>
966
+ html(
967
+ "Authorization could not be completed. Re-run authorization from " +
968
+ "connecta and try again.",
969
+ 400,
970
+ branding,
971
+ );
972
+ if (!connector || !connector.finishAuth) {
973
+ await equalizeRefusalCost(context);
974
+ return refused();
975
+ }
924
976
  // CSRF / login-fixation guard: this route is intentionally public, so verify
925
977
  // the `state` matches the flow connecta started BEFORE exchanging the code.
926
- if (connector.verifyState) {
927
- const state = url.searchParams.get("state");
928
- const ok = await connector.verifyState(state, context);
929
- if (!ok) {
930
- return html(
931
- "Authorization state mismatch this callback did not originate from " +
932
- "a flow started by connecta. Re-run authorization and try again.",
933
- 400,
934
- branding,
935
- );
936
- }
978
+ if (!connector.verifyState) {
979
+ await equalizeRefusalCost(context);
980
+ logger.warn(
981
+ `[connecta] refused an OAuth callback for connector ` +
982
+ `${loggableValue(id)} with 400: it implements finishAuth but no ` +
983
+ "verifyState, so connecta cannot establish that it started this flow. " +
984
+ "No authorization code was exchanged. Implement verifyState before " +
985
+ "trying again.",
986
+ );
987
+ return refused();
988
+ }
989
+ const state = url.searchParams.get("state");
990
+ let stateMatches: boolean;
991
+ try {
992
+ stateMatches = await connector.verifyState(state, context);
993
+ } catch (err) {
994
+ logger.warn(
995
+ `[connecta] refused an OAuth callback for connector ` +
996
+ `${loggableValue(id)} with 400: verifyState threw ` +
997
+ `${loggableValue(msg(err))}. No authorization code was exchanged. ` +
998
+ "Re-run authorization from connecta and check the verifier if it " +
999
+ "fails again.",
1000
+ );
1001
+ return refused();
1002
+ }
1003
+ if (!stateMatches) {
1004
+ logger.warn(
1005
+ `[connecta] refused an OAuth callback for connector ` +
1006
+ `${loggableValue(id)} with 400: ` +
1007
+ (state === null
1008
+ ? "the state parameter was missing"
1009
+ : "the state did not match the pending authorization flow") +
1010
+ ". No authorization code was exchanged. Re-run authorization from " +
1011
+ "connecta and try again.",
1012
+ );
1013
+ return refused();
937
1014
  }
938
1015
  try {
939
1016
  await connector.finishAuth(code, context);
@@ -1008,7 +1085,10 @@ export function createFetchHandler(
1008
1085
  new URL(publicUrl).protocol === "https:" &&
1009
1086
  url.protocol === "http:"
1010
1087
  ) {
1011
- const target = new URL(`${url.pathname}${url.search}`, publicUrl);
1088
+ // Canonicalize the legacy bookmark while upgrading it so an old /ui URL
1089
+ // reaches the new Connections entry point in one permanent redirect.
1090
+ const targetPath = path === "/ui" ? "/" : url.pathname;
1091
+ const target = new URL(`${targetPath}${url.search}`, publicUrl);
1012
1092
  return withSecurityHeaders(
1013
1093
  new Response(null, {
1014
1094
  status: 308,
@@ -1067,7 +1147,13 @@ export function createFetchHandler(
1067
1147
  }
1068
1148
 
1069
1149
  if (path.startsWith("/oauth/callback/")) {
1070
- return handleOAuthCallback(url, registry, baseUrl, opts.branding);
1150
+ return handleOAuthCallback(
1151
+ url,
1152
+ registry,
1153
+ baseUrl,
1154
+ opts.logger,
1155
+ opts.branding,
1156
+ );
1071
1157
  }
1072
1158
 
1073
1159
  if (request.method === "GET" && path === "/favicon.svg") {
@@ -1091,7 +1177,23 @@ export function createFetchHandler(
1091
1177
  }
1092
1178
 
1093
1179
  if (path === "/ui") {
1094
- // Open shell carries no data; data comes only from the gated /ui/data.
1180
+ if (request.method !== "GET") {
1181
+ return privateJson({ error: "method not allowed" }, { status: 405 });
1182
+ }
1183
+ const target = new URL(`/${url.search}`, baseUrl);
1184
+ return new Response(null, {
1185
+ status: 308,
1186
+ headers: { Location: target.toString() },
1187
+ });
1188
+ }
1189
+
1190
+ const operatorPage = operatorPageForPath(path);
1191
+ if (operatorPage) {
1192
+ if (request.method !== "GET") {
1193
+ return privateJson({ error: "method not allowed" }, { status: 405 });
1194
+ }
1195
+ // Open shell — carries no operator data; everything comes from the
1196
+ // authenticated /ui/* APIs after the browser establishes a session.
1095
1197
  const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
1096
1198
  const mcpUrl = new URL("/mcp", baseUrl).toString();
1097
1199
  // Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
@@ -1101,16 +1203,19 @@ export function createFetchHandler(
1101
1203
  // style/font/network needs and the page's inline <style> stay unrestricted
1102
1204
  // — only script execution, the XSS sink, is gated.
1103
1205
  const nonce = uiScriptNonce();
1104
- return new Response(renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce), {
1105
- status: 200,
1106
- headers: {
1107
- "Content-Type": "text/html; charset=utf-8",
1108
- "Content-Security-Policy":
1109
- `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
1110
- "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
1111
- "X-Content-Type-Options": "nosniff",
1206
+ return new Response(
1207
+ renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
1208
+ {
1209
+ status: 200,
1210
+ headers: {
1211
+ "Content-Type": "text/html; charset=utf-8",
1212
+ "Content-Security-Policy":
1213
+ `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
1214
+ "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
1215
+ "X-Content-Type-Options": "nosniff",
1216
+ },
1112
1217
  },
1113
- });
1218
+ );
1114
1219
  }
1115
1220
 
1116
1221
  if (path === "/ui/data") {
@@ -1122,16 +1227,24 @@ export function createFetchHandler(
1122
1227
  // After the restriction check, not before: an identity that may not
1123
1228
  // read this surface should not get to trigger background work from it.
1124
1229
  sweepCredentials();
1230
+ const eligibleClerkOperator =
1231
+ authz.providerKind === "clerk" && Boolean(authz.userId);
1232
+ const credentialManagement = credentialManagementCapability({
1233
+ eligibleClerkOperator,
1234
+ hasCredentialSlots: registry
1235
+ .listConnectors()
1236
+ .some((connector) => Boolean(connector.credential)),
1237
+ hasCredentialVault: Boolean(opts.credentialVault),
1238
+ });
1125
1239
  const data = await buildUiData(
1126
1240
  registry,
1127
1241
  baseUrl,
1128
1242
  serverInfo,
1129
1243
  // The static headless bearer may read connector health, but only a
1130
1244
  // Clerk-authenticated operator receives credential metadata.
1131
- authz.providerKind === "clerk" && authz.userId
1132
- ? opts.credentialVault
1133
- : undefined,
1245
+ eligibleClerkOperator ? opts.credentialVault : undefined,
1134
1246
  Boolean(opts.activity?.list),
1247
+ credentialManagement,
1135
1248
  );
1136
1249
  return privateJson(data);
1137
1250
  }
package/src/toolkits.ts CHANGED
@@ -129,7 +129,7 @@ function toolFilter(
129
129
  * an allowlist is a scope the operator did not write, and a scope nobody wrote
130
130
  * is not one an operator can reason about. (A definition scopes visibility only;
131
131
  * WHICH identity may select it is the separate binding below — see the module
132
- * header and documentation.md §16.) Tool names are checked only for connectors that expose
132
+ * header and docs/toolkits.md.) Tool names are checked only for connectors that expose
133
133
  * `staticTools` (i.e. `api()`); a remote connector's catalog is fetched lazily
134
134
  * over the network and is unknown at construction time.
135
135
  */
package/src/types.ts CHANGED
@@ -63,7 +63,7 @@ export interface ConnectorCredentialFieldConfig {
63
63
  name: string;
64
64
  /** Short field label, e.g. "Account email". */
65
65
  label: string;
66
- /** Plain-language guidance shown in /ui. Never include the credential itself. */
66
+ /** Plain-language guidance shown in /credentials. Never include the credential itself. */
67
67
  description?: string;
68
68
  /** Input placeholder, e.g. "you@example.com". */
69
69
  placeholder?: string;
@@ -75,7 +75,7 @@ export interface ConnectorCredentialFieldConfig {
75
75
  export interface ConnectorCredentialConfig {
76
76
  /** Short group or field label, e.g. "API token" or "Service credentials". */
77
77
  label: string;
78
- /** Plain-language guidance shown in /ui. Never include the credential itself. */
78
+ /** Plain-language guidance shown in /credentials. Never include the credential itself. */
79
79
  description?: string;
80
80
  /** Password-field placeholder, e.g. "Paste API token". */
81
81
  placeholder?: string;
@@ -100,13 +100,14 @@ export interface ConnectorContext {
100
100
  /**
101
101
  * Read-only access to this connector's operator-managed credential. Present
102
102
  * only when the connector declares `credential` and the deployment configures
103
- * `credentialEncryptionKey`.
103
+ * `credentials.encryptionKey`.
104
104
  */
105
105
  credential?: ConnectorCredentialAccess;
106
106
  /**
107
107
  * Identity shared by connector calls that belong to one inbound request.
108
108
  * Connectors may use it to reuse request-safe resources within that request,
109
- * but must never retain I/O resources beyond the scope's lifetime.
109
+ * but must never retain I/O resources beyond the scope's lifetime. For
110
+ * probe-only scopes the core owns, `Connector.closeScope` signals that end.
110
111
  *
111
112
  * Optional for custom/test contexts; the context object itself is the scope
112
113
  * when omitted.
@@ -138,7 +139,7 @@ export interface Connector {
138
139
  /**
139
140
  * Max inline result size (bytes) for this connector's tools before
140
141
  * call_tool/batch_call truncate and stash the full text for get_result
141
- * paging. Overrides the deployment-wide `ConnectaConfig.maxResultBytes`;
142
+ * paging. Overrides `ConnectaConfig.calls.maxResultBytes`;
142
143
  * omit to inherit it (which itself defaults to 50_000). Must be a whole
143
144
  * number of bytes >= 1; anything else warns at startup and is ignored, so
144
145
  * the connector inherits the deployment-wide cap.
@@ -152,14 +153,14 @@ export interface Connector {
152
153
  * and imperative; it is read by agents, not operators.
153
154
  */
154
155
  usageGuide?: string;
155
- /** Optional operator-managed credential slot rendered inside this connector's /ui card. */
156
+ /** Optional operator-managed credential slot rendered on /credentials. */
156
157
  credential?: ConnectorCredentialConfig;
157
- /** Optional server-side check used by /ui's Test action. */
158
+ /** Optional server-side check used by /credentials' Test action. */
158
159
  testCredential?(
159
160
  value: string,
160
161
  ctx: ConnectorContext,
161
162
  ): Promise<CredentialTestResult>;
162
- /** Optional multi-field credential check used by /ui's Test action. */
163
+ /** Optional multi-field credential check used by /credentials' Test action. */
163
164
  testCredentials?(
164
165
  values: ConnectorCredentialValues,
165
166
  ctx: ConnectorContext,
@@ -189,6 +190,18 @@ export interface Connector {
189
190
  args: unknown,
190
191
  ctx: ConnectorContext,
191
192
  ): Promise<unknown>;
193
+ /**
194
+ * Optional best-effort teardown for resources retained under
195
+ * `ctx.requestScope`. The core calls this at most once when a scope it created
196
+ * solely for probing ends, and never uses that scope again. Teardown gets a
197
+ * small, fixed best-effort completion window; a missing, rejected, or
198
+ * never-settling hook cannot change or hold open the operation's result
199
+ * beyond that bound.
200
+ *
201
+ * Per-request `/mcp` scopes are not closed through this hook: their
202
+ * request-local reuse remains in force until the request boundary.
203
+ */
204
+ closeScope?(ctx: ConnectorContext): Promise<void>;
192
205
  /** Optional connector-level health/auth status for list_connectors. */
193
206
  status?(ctx: ConnectorContext): Promise<ConnectorStatus>;
194
207
  /**
@@ -202,11 +215,11 @@ export interface Connector {
202
215
  opts?: { force?: boolean },
203
216
  ): Promise<ConnectorStatus>;
204
217
  /**
205
- * Optional: verify the OAuth `state` returned to /oauth/callback/<id> against
206
- * the value this connector generated when it started the flow. Present only
207
- * on downstream-OAuth connectors. The callback MUST reject before finishAuth
208
- * when this returns false — otherwise anyone holding the pending URL could
209
- * complete consent with their own account.
218
+ * Verify the OAuth `state` returned to /oauth/callback/<id> against the value
219
+ * this connector generated when it started the flow. Required whenever
220
+ * `finishAuth` is present: the callback rejects before `finishAuth` when this
221
+ * hook is absent, throws, or returns false — otherwise anyone holding the
222
+ * pending URL could complete consent with their own account.
210
223
  */
211
224
  verifyState?(state: string | null, ctx: ConnectorContext): Promise<boolean>;
212
225
  /** Optional: complete a downstream OAuth flow (called by /oauth/callback/<id>). */
@@ -215,7 +228,8 @@ export interface Connector {
215
228
  * Optional: serve a connector-owned HTTP route — for example a signed
216
229
  * download link minted by one of the connector's tools. Called only after
217
230
  * every built-in route misses, so a connector can never shadow `/mcp`,
218
- * `/ui`, `/health`, or the credential API. The first connector to return a
231
+ * `/`, `/credentials`, `/activity`, `/health`, or the credential API. The
232
+ * first connector to return a
219
233
  * Response wins, in registration order; return null to decline.
220
234
  *
221
235
  * These routes are PUBLIC: connecta applies no auth gate to them. A
@@ -254,9 +268,9 @@ export interface Executor {
254
268
 
255
269
  /**
256
270
  * Which toolkits one inbound identity may open — the membership half of the
257
- * deployment=org / toolkit=team framing (§16). A mapping, never a policy
258
- * engine: one identity → the toolkit names it may select, plus whether it may
259
- * connect with no `?toolkit=` at all.
271
+ * deployment=org / toolkit=team framing (docs/toolkits.md). A mapping, never a
272
+ * policy engine: one identity → the toolkit names it may select, plus whether
273
+ * it may connect with no `?toolkit=` at all.
260
274
  *
261
275
  * An identity with NO binding is unbound and keeps the pre-binding behavior:
262
276
  * any declared toolkit, or the full registry. A binding is enforced at connect
@@ -304,14 +318,14 @@ export type UiAuthConfig = {
304
318
  kind: "clerk";
305
319
  publishableKey: string;
306
320
  /**
307
- * Origin `/ui` fetches its browser sign-in loader from. **Must be an absolute
321
+ * Origin the operator shell fetches its browser sign-in loader from. **Must be an absolute
308
322
  * `https:` URL** — the value lands in a `<script src>`, so the gate is
309
323
  * stricter than the branding href gate: no `http:`, no loopback exemption, and
310
324
  * no root-relative form (a relative path is rejected, not resolved). The
311
325
  * shipped `clerkAuth` adapter derives this from the publishable key and
312
326
  * Clerk's Frontend API is always https, so nothing legitimate needs a
313
327
  * carve-out. A value that fails the gate reaches neither the loader tag nor
314
- * the page's inline auth config: `/ui` renders without the loader and reports
328
+ * the page's inline auth config: operator pages render without it and report
315
329
  * that Clerk could not load, and `createConnecta` names the drop in a startup
316
330
  * warning.
317
331
  */
@@ -322,7 +336,7 @@ export type UiAuthConfig = {
322
336
  * this value is where Clerk *navigates* the operator's browser. An Account
323
337
  * Portal address is always https, so the stricter gate costs nothing real: a
324
338
  * value that fails it (a `javascript:`/`data:` payload, a cleartext `http:`
325
- * address, a relative path) reaches no part of the page, `/ui` signs in
339
+ * address, a relative path) reaches no part of the page, the shell signs in
326
340
  * through Clerk's default instead, and `createConnecta` names the drop in a
327
341
  * startup warning.
328
342
  */
@@ -345,7 +359,7 @@ export interface ConnectaBranding {
345
359
  ownerName?: string;
346
360
  /** Optional link for the organization or owner label. */
347
361
  ownerUrl?: string;
348
- /** Status-dashboard introduction. */
362
+ /** Operator-page introduction and meta description. */
349
363
  description?: string;
350
364
  /**
351
365
  * Browser tab title and page meta name. Defaults to
@@ -374,16 +388,16 @@ export interface ConnectaBranding {
374
388
  export interface InboundAuth {
375
389
  kind: string;
376
390
  /**
377
- * Optional browser sign-in configuration. When present, `/ui` uses this
391
+ * Optional browser sign-in configuration. When present, operator pages use it
378
392
  * provider instead of asking the operator to paste a static bearer secret.
379
393
  */
380
394
  uiAuth?: UiAuthConfig;
381
395
  /**
382
- * Optional toolkit binding for every identity this provider admits (§16).
383
- * Declared statically so `createConnecta` can validate the names against
384
- * `ConnectaConfig.toolkits` and throw on a typo — a binding nobody wrote is
385
- * not one an operator can reason about. An `authorize` result may narrow it
386
- * per identity with its own `toolkitBinding`.
396
+ * Optional toolkit binding for every identity this provider admits
397
+ * (docs/toolkits.md). Declared statically so `createConnecta` can validate the
398
+ * names against `ConnectaConfig.toolkits` and throw on a typo — a binding
399
+ * nobody wrote is not one an operator can reason about. An `authorize` result
400
+ * may narrow it per identity with its own `toolkitBinding`.
387
401
  */
388
402
  toolkitBinding?: ToolkitBinding;
389
403
  /** Serve/short-circuit .well-known + OPTIONS. Return null when not handled. */