@zackbart/connecta 0.37.1 → 0.39.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 (265) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +5 -4
  3. package/bin/connecta.mjs +1 -39
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +2 -4
  6. package/dist/agent-identity.js +1 -1
  7. package/dist/agent-routes.js +231 -0
  8. package/dist/auth/clerk-sdk/generated.js +2 -1
  9. package/dist/auth/downstream-oauth/consent.js +519 -0
  10. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  11. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  12. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  13. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  14. package/dist/auth/downstream-oauth.js +66 -2516
  15. package/dist/auth/downstream-revocation.js +1 -1
  16. package/dist/auth/native.js +27 -0
  17. package/dist/auth/oauth-grant.js +24 -0
  18. package/dist/auth/oauth-token-response.js +1 -1
  19. package/dist/auth/static-oauth.js +153 -131
  20. package/dist/auth/vercel-agent.js +2 -1
  21. package/dist/auth-elicitation.js +9 -3
  22. package/dist/authorization-handoff.js +11 -5
  23. package/dist/branding.d.ts +1 -15
  24. package/dist/branding.js +2 -2
  25. package/dist/bytes.js +42 -0
  26. package/dist/call-admission-context.js +2 -5
  27. package/dist/call-recovery.js +0 -3
  28. package/dist/catalog-cache.js +130 -43
  29. package/dist/catalog-fingerprint.js +2 -18
  30. package/dist/catalog-policy.js +18 -0
  31. package/dist/catalog-service.js +285 -48
  32. package/dist/catalog.js +18 -2
  33. package/dist/config-defaults.d.ts +1 -0
  34. package/dist/config-defaults.js +1 -0
  35. package/dist/config-schema.d.ts +1 -1
  36. package/dist/config-schema.js +6 -2
  37. package/dist/config.d.ts +57 -25
  38. package/dist/config.js +30 -77
  39. package/dist/connector-caller.d.ts +1 -1
  40. package/dist/connector-health.js +59 -0
  41. package/dist/connector-status.js +117 -0
  42. package/dist/connectors/api-connector.d.ts +7 -2
  43. package/dist/connectors/api-connector.js +11 -7
  44. package/dist/connectors/guarded-fetch.d.ts +0 -6
  45. package/dist/connectors/guarded-fetch.js +1 -11
  46. package/dist/connectors/negotiation-cache.js +22 -4
  47. package/dist/connectors/remote-mcp/call.js +164 -0
  48. package/dist/connectors/remote-mcp/catalog.js +278 -0
  49. package/dist/connectors/remote-mcp/config.js +263 -0
  50. package/dist/connectors/remote-mcp/connection.js +671 -0
  51. package/dist/connectors/remote-mcp/errors.js +488 -0
  52. package/dist/connectors/remote-mcp/oauth.js +469 -0
  53. package/dist/connectors/remote-mcp/resources.js +473 -0
  54. package/dist/connectors/remote-mcp/runtime.js +1 -0
  55. package/dist/connectors/remote-mcp/transport.js +544 -0
  56. package/dist/connectors/remote-mcp.js +61 -2898
  57. package/dist/connectors/resource-uri.js +0 -4
  58. package/dist/credential-contract.d.ts +23 -0
  59. package/dist/credential-reads.js +17 -0
  60. package/dist/credential-verification.js +53 -0
  61. package/dist/credentials.d.ts +7 -0
  62. package/dist/credentials.js +60 -25
  63. package/dist/d1.d.ts +0 -89
  64. package/dist/d1.js +0 -34
  65. package/dist/describe-config.d.ts +12 -2
  66. package/dist/describe-config.js +2 -0
  67. package/dist/discovery-health.js +199 -0
  68. package/dist/downstream-input.js +4 -2
  69. package/dist/errors.d.ts +55 -17
  70. package/dist/errors.js +95 -41
  71. package/dist/execute.js +172 -125
  72. package/dist/executor-result.js +5 -2
  73. package/dist/executors/quickjs-runtime.js +2 -25
  74. package/dist/executors/quickjs.js +1 -1
  75. package/dist/guest-functions.js +83 -0
  76. package/dist/guest-runtime.js +63 -20
  77. package/dist/guest-types.d.ts +24 -10
  78. package/dist/http/argument-problems.js +35 -0
  79. package/dist/http/client.js +1 -1
  80. package/dist/http/errors.js +7 -6
  81. package/dist/http/fields.js +192 -13
  82. package/dist/http/hatch-response.js +73 -0
  83. package/dist/http/hatch.js +88 -23
  84. package/dist/http/json-schema.js +1 -1
  85. package/dist/http/node-transport.js +0 -1
  86. package/dist/http/oauth.js +4 -3
  87. package/dist/http/paging.js +6 -9
  88. package/dist/http/provider.js +33 -5
  89. package/dist/http/safety.js +9 -1
  90. package/dist/http/services.js +1 -8
  91. package/dist/http/tools.js +45 -30
  92. package/dist/http/vendor.js +7 -7
  93. package/dist/identity.js +2 -6
  94. package/dist/index.d.ts +1 -1
  95. package/dist/index.js +9 -2
  96. package/dist/invocation-auth.js +2 -1
  97. package/dist/invocation.js +21 -6
  98. package/dist/membership.d.ts +13 -5
  99. package/dist/membership.js +34 -2
  100. package/dist/meta-tools.js +50 -35
  101. package/dist/native-recovery.js +4 -16
  102. package/dist/native-tools.js +6 -2
  103. package/dist/oauth-handoff.js +2 -2
  104. package/dist/oauth-sealing.js +10 -0
  105. package/dist/operation-vocabulary.js +136 -14
  106. package/dist/operator-record.d.ts +2 -2
  107. package/dist/operator-record.js +2 -49
  108. package/dist/operator-ui/contract.d.ts +12 -2
  109. package/dist/operator-ui/generated.js +3 -3
  110. package/dist/operator-ui/model.d.ts +22 -1
  111. package/dist/program-writes.js +3 -8
  112. package/dist/provider-call.js +0 -7
  113. package/dist/provider.d.ts +1 -1
  114. package/dist/providers/_shared/google/kit.js +1 -6
  115. package/dist/providers/_shared/google/service-account.js +1 -3
  116. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  117. package/dist/providers/_shared/guide.js +3 -0
  118. package/dist/providers/_shared/json.js +12 -0
  119. package/dist/providers/_shared/rest/compact-index.js +1 -1
  120. package/dist/providers/_shared/rest/kit.js +1 -3
  121. package/dist/providers/_shared/rest/operation-index.js +49 -53
  122. package/dist/providers/_shared/rest/value-safety.js +106 -28
  123. package/dist/providers/breeze/api.js +9 -8
  124. package/dist/providers/breeze/index.js +4 -2
  125. package/dist/providers/breeze/skill.generated.js +1 -1
  126. package/dist/providers/breeze/tools.js +7 -7
  127. package/dist/providers/ccb/api.js +3 -7
  128. package/dist/providers/ccb/index.js +7 -1
  129. package/dist/providers/ccb/skill.generated.js +1 -1
  130. package/dist/providers/ccb/tools.js +1 -6
  131. package/dist/providers/cloudflare/api.js +10 -3
  132. package/dist/providers/cloudflare/index.js +2 -1
  133. package/dist/providers/cloudflare/safety.js +3 -3
  134. package/dist/providers/cloudflare/skill.generated.js +1 -1
  135. package/dist/providers/docs/lib/schemas.js +24 -22
  136. package/dist/providers/docs/safety.js +2 -0
  137. package/dist/providers/docs/skill.generated.js +1 -1
  138. package/dist/providers/docs/tools.js +4 -14
  139. package/dist/providers/drive/api.js +1 -0
  140. package/dist/providers/drive/lib/contracts.js +8 -5
  141. package/dist/providers/drive/skill.generated.js +1 -1
  142. package/dist/providers/drive/tools.js +23 -41
  143. package/dist/providers/forms/api.js +1 -0
  144. package/dist/providers/forms/skill.generated.js +1 -1
  145. package/dist/providers/forms/tools.js +11 -16
  146. package/dist/providers/github/api.js +1 -0
  147. package/dist/providers/github/lib/discovery.js +1 -1
  148. package/dist/providers/github/skill.generated.js +1 -1
  149. package/dist/providers/github/tools.js +61 -17
  150. package/dist/providers/gmail/lib/schemas.js +60 -50
  151. package/dist/providers/gmail/skill.generated.js +1 -1
  152. package/dist/providers/gmail/tools.js +12 -26
  153. package/dist/providers/granola/index.js +2 -0
  154. package/dist/providers/granola/skill.generated.js +1 -1
  155. package/dist/providers/overflow/api.js +8 -4
  156. package/dist/providers/overflow/index.js +2 -1
  157. package/dist/providers/overflow/skill.generated.js +1 -1
  158. package/dist/providers/overflow/tools.js +3 -7
  159. package/dist/providers/planning-center/api.js +5 -2
  160. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  161. package/dist/providers/planning-center/lib/json-api.js +1 -9
  162. package/dist/providers/planning-center/skill.generated.js +1 -1
  163. package/dist/providers/planning-center/tools.js +2 -1
  164. package/dist/providers/quickbooks/safety.js +80 -39
  165. package/dist/providers/quickbooks/skill.generated.js +1 -1
  166. package/dist/providers/sheets/api.js +1 -0
  167. package/dist/providers/sheets/skill.generated.js +1 -1
  168. package/dist/providers/sheets/tools.js +14 -23
  169. package/dist/providers/slides/skill.generated.js +1 -1
  170. package/dist/providers/slides/tools.js +19 -26
  171. package/dist/providers/stripe/index.js +2 -1
  172. package/dist/providers/stripe/safety.js +2 -2
  173. package/dist/providers/stripe/skill.generated.js +1 -1
  174. package/dist/providers/tithely/api.js +5 -8
  175. package/dist/providers/tithely/index.js +3 -2
  176. package/dist/providers/tithely/skill.generated.js +1 -1
  177. package/dist/providers/tithely/tools.js +7 -5
  178. package/dist/providers/vercel/api.js +2 -7
  179. package/dist/providers/vercel/lib/pin.js +1 -1
  180. package/dist/providers/vercel/safety.js +1 -1
  181. package/dist/providers/vercel/skill.generated.js +1 -1
  182. package/dist/providers/vercel/tools.js +6 -4
  183. package/dist/redaction-marker.js +2 -2
  184. package/dist/registry.d.ts +11 -5
  185. package/dist/registry.js +46 -20
  186. package/dist/request-principal.js +1 -1
  187. package/dist/request-state.js +2 -2
  188. package/dist/routes/access-tokens.js +1 -1
  189. package/dist/routes/activity-disclosure.js +1 -1
  190. package/dist/routes/connect.js +2 -2
  191. package/dist/routes/credentials.js +130 -61
  192. package/dist/routes/dispatch.js +5 -1
  193. package/dist/routes/mcp.js +34 -17
  194. package/dist/routes/oauth-management.js +1 -1
  195. package/dist/routes/operator.js +6 -5
  196. package/dist/routes/shared.d.ts +13 -8
  197. package/dist/routes/shared.js +15 -9
  198. package/dist/routes/ui-config.js +3 -3
  199. package/dist/routes/ui-data.js +126 -20
  200. package/dist/routes/ui.js +17 -1
  201. package/dist/runtime/call-admission.js +1 -1
  202. package/dist/runtime/call.js +8 -3
  203. package/dist/runtime/native-storage.js +9 -0
  204. package/dist/runtime/request-reads.js +182 -0
  205. package/dist/runtime/request-scope.js +16 -0
  206. package/dist/runtime/settlement.js +1 -1
  207. package/dist/runtime/shared-read.js +2 -15
  208. package/dist/runtime/storage.js +12 -0
  209. package/dist/sent-secrets.d.ts +1 -2
  210. package/dist/sent-secrets.js +3 -8
  211. package/dist/server.js +4 -4
  212. package/dist/skills.js +44 -54
  213. package/dist/sqlite.d.ts +0 -19
  214. package/dist/sqlite.js +2 -83
  215. package/dist/storage/keys.js +69 -128
  216. package/dist/storage/memory.js +8 -2
  217. package/dist/storage/sql-native.js +27 -101
  218. package/dist/storage/sql.d.ts +1 -24
  219. package/dist/storage/sql.js +13 -14
  220. package/dist/storage-digest.js +32 -0
  221. package/dist/timeout.js +8 -0
  222. package/dist/tool-error.js +4 -0
  223. package/dist/types.d.ts +33 -0
  224. package/dist/url-safety.js +0 -4
  225. package/dist/usage-guide.js +18 -37
  226. package/dist/validate.d.ts +3 -0
  227. package/dist/validate.js +220 -158
  228. package/dist/version.d.ts +1 -1
  229. package/dist/version.js +1 -1
  230. package/dist/worker.js +1 -1
  231. package/dist/write-outcome.js +0 -3
  232. package/documentation/README.md +19 -55
  233. package/documentation/architecture.md +436 -515
  234. package/documentation/auth.md +229 -88
  235. package/documentation/code-mode.md +201 -94
  236. package/documentation/deploying.md +25 -43
  237. package/documentation/integrating.md +18 -19
  238. package/documentation/meta-tools.md +225 -51
  239. package/documentation/migration-0.33.md +10 -6
  240. package/documentation/migration-0.34.md +5 -5
  241. package/documentation/migration-retired-layouts.md +74 -0
  242. package/documentation/operator-tests.md +20 -120
  243. package/documentation/operator-ui.md +108 -16
  244. package/documentation/pool-membership-upgrade.md +44 -0
  245. package/documentation/provider-auth-migration.md +4 -10
  246. package/documentation/provider-migration-0.29.md +4 -15
  247. package/documentation/providers.md +105 -0
  248. package/examples/worker/AGENTS.md +14 -16
  249. package/examples/worker/README.md +24 -118
  250. package/examples/worker/src/connecta.config.ts +1 -0
  251. package/guides/connecting-agents.md +9 -31
  252. package/guides/how-it-works.md +6 -4
  253. package/guides/safety-and-trust.md +4 -1
  254. package/package.json +19 -19
  255. package/templates/node/AGENTS.md +13 -12
  256. package/templates/node/README.md +9 -26
  257. package/templates/node/package.json +1 -1
  258. package/templates/node/src/connecta.config.ts +1 -0
  259. package/dist/auth/oauth-v2-migration.js +0 -135
  260. package/dist/provider-call-facts.d.ts +0 -115
  261. package/dist/provider-call.d.ts +0 -112
  262. package/dist/storage/d1-copy.js +0 -255
  263. package/dist/storage/sql-schema.d.ts +0 -6
  264. package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
  265. package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
@@ -163,7 +163,7 @@ const authorizeNative = /* @__PURE__ */ registerAuth(authorize, function (reques
163
163
  return { ok: false, response: privateJson({ error: "authenticated user required" }, { status: 403 }) };
164
164
  }
165
165
  // Every caller of this function is a human route. Agents reach pools only at
166
- // `/agent/<pool>` (#837), so a workload or `cta_` credential refuses here
166
+ // `/agent/<pool>`, so a workload or `cta_` credential refuses here
167
167
  // without verification, and never falls back to a cookie, session or Access context.
168
168
  if (machineCredential) {
169
169
  return {
@@ -299,7 +299,7 @@ function agentChallenge() {
299
299
  };
300
300
  }
301
301
  /**
302
- * Admission at an agent endpoint, `/agent/<pool>` (#837). Only the built-in
302
+ * Admission at an agent endpoint, `/agent/<pool>`. Only the built-in
303
303
  * agent credential adapters are consulted: Clerk and Access never run, cookies and Clerk
304
304
  * handshake parameters are stripped, and the runtime context, which carries
305
305
  * Access's `ctx.access` even on a bypassed path, is never passed. Anything but
@@ -376,7 +376,7 @@ export function isSameOrigin(request, baseUrl) {
376
376
  return false;
377
377
  }
378
378
  }
379
- export function withSecurityHeaders(response, requestUrl, _path) {
379
+ export function withSecurityHeaders(response, requestUrl) {
380
380
  const headers = response.headers.get("Content-Type")?.toLowerCase().startsWith("text/html")
381
381
  ? htmlSecurityHeaders(response.headers)
382
382
  : new Headers(response.headers);
@@ -392,7 +392,7 @@ export function withSecurityHeaders(response, requestUrl, _path) {
392
392
  });
393
393
  }
394
394
  /**
395
- * Plain `/mcp` and every operator route admit a maintainer (#837). An open
395
+ * Every operator route admits a maintainer. An open
396
396
  * deployment has no inbound auth and so no maintainers: its one anonymous
397
397
  * caller keeps the whole default endpoint, as the startup warning says.
398
398
  */
@@ -401,7 +401,7 @@ export function admitsMaintainer(opts, authz) {
401
401
  }
402
402
  /**
403
403
  * The declared pool `/mcp/<name>` serves to this person, or undefined for the
404
- * same 404. An agent is never admitted here, even when listed (#837).
404
+ * same 404. An agent is never admitted here, even when listed.
405
405
  */
406
406
  export function memberPool(opts, name, identity) {
407
407
  if (identity.agent !== undefined)
@@ -409,6 +409,12 @@ export function memberPool(opts, name, identity) {
409
409
  const pool = opts.pools.get(name);
410
410
  return pool?.members.some((member) => isMember(member, identity)) ? pool : undefined;
411
411
  }
412
+ /** Human memberships, matched only against the resolved namespaced principal. */
413
+ export function personPools(opts, identity) {
414
+ if (identity.agent !== undefined)
415
+ return [];
416
+ return [...opts.pools].filter(([, pool]) => pool.members.some((member) => isMember(member, identity)));
417
+ }
412
418
  /** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
413
419
  export function agentPool(opts, name, identity) {
414
420
  if (identity.agent === undefined)
@@ -417,7 +423,7 @@ export function agentPool(opts, name, identity) {
417
423
  return pool?.members.some((member) => member.kind === "agent" && isMember(member, identity)) ? pool : undefined;
418
424
  }
419
425
  /**
420
- * The connectors of every pool this identity is a member of (#837): what a
426
+ * The connectors of every pool this identity is a member of: what a
421
427
  * pool member may ever manage, and only their personal ones among these.
422
428
  */
423
429
  export function memberConnectors(opts, identity) {
@@ -435,7 +441,7 @@ export function memberConnectors(opts, identity) {
435
441
  }
436
442
  /**
437
443
  * Whether this identity may start, finish, or remove a connector's auth
438
- * (#837). Only an interactive person ever may; an agent or any other machine
444
+ * Only an interactive person ever may; an agent or any other machine
439
445
  * credential manages nothing. Shared auth is deployment-wide, so it takes a
440
446
  * maintainer. A personal connector takes a stable principal that is a
441
447
  * maintainer, whose plain `/mcp` includes it, or a member of a pool that
@@ -454,12 +460,12 @@ export function mayManageConnector(opts, authz, connector) {
454
460
  const pools = memberConnectors(opts, identity);
455
461
  return pools === "all" || pools.has(connector.id);
456
462
  }
457
- /** Agent token list, issue, rotate, and revoke: an interactive maintainer only (#837). */
463
+ /** Agent token list, issue, rotate, and revoke: an interactive maintainer only. */
458
464
  export function managesAgentTokens(opts, authz) {
459
465
  return (authz.identity.interactive && authz.identity.agent === undefined && isMaintainer(opts.maintainers, authz.identity));
460
466
  }
461
467
  /**
462
- * Activity reads: a maintainer principal, interactive or not (#837). Members
468
+ * Activity reads: a maintainer principal, interactive or not. Members
463
469
  * and agents read none, and an open deployment has no maintainer.
464
470
  */
465
471
  export function readsActivity(opts, authz) {
@@ -359,7 +359,7 @@ function maintainerRead(context, authz) {
359
359
  .map(([id, tools]) => [id, Object.fromEntries(Object.entries(tools).filter(([name]) => validName(name)))]));
360
360
  // A pool owner is a Workspace mailbox address. Only an authenticated
361
361
  // maintainer reads it; an open deployment's anonymous caller, which this
362
- // route admits, gets null, the same as an unset owner (#837).
362
+ // route admits, gets null, the same as an unset owner.
363
363
  const ownersVisible = opts.config.auth.length > 0 && isMaintainer(opts.maintainers, authz.identity);
364
364
  config.pools = config.pools.map((pool) => ({
365
365
  ...pool,
@@ -553,7 +553,7 @@ function memberRead(context, authz, pools) {
553
553
  return Effect.gen(function* () {
554
554
  const allIds = opts.registry.listConnectors().map((c) => c.id);
555
555
  const ids = new Set(allIds.filter((id) => pools.some(([, pool]) => servesConnector(pool, id))));
556
- const registry = opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, [...ids]));
556
+ const registry = opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, [...ids], pools.length === 1 ? pools[0][0] : undefined));
557
557
  const selected = context.url.searchParams.get("connector");
558
558
  const visible = registry
559
559
  .listConnectors()
@@ -571,7 +571,7 @@ function memberRead(context, authz, pools) {
571
571
  root: null,
572
572
  pools: pools.map(([name, pool]) => ({
573
573
  name,
574
- humanPath: `/mcp/${name}`,
574
+ humanPath: pools.length === 1 ? "/mcp" : `/mcp/${name}`,
575
575
  agentPath: `/agent/${name}`,
576
576
  readOnly: pool.readOnly,
577
577
  surface: pool.native ? "native" : "meta",
@@ -9,14 +9,19 @@ import { describedTools } from "../described.js";
9
9
  import { Effect } from "effect";
10
10
  import { bindActivityRequest } from "../activity-request.js";
11
11
  import { resolveDiscoveryConcurrency } from "../concurrency.js";
12
+ import { credentialReads } from "../credential-reads.js";
12
13
  import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape } from "../credential-rules.js";
14
+ import { readVerification } from "../credential-verification.js";
13
15
  import { failureRecord, failureStatus, logFailure, recordedToolName, statusFailure } from "../operator-record.js";
14
16
  import { closeScope } from "../runtime/connector-scope.js";
17
+ import { requestReads } from "../runtime/request-scope.js";
15
18
  import { withDeadlineEffect } from "../runtime/run.js";
16
19
  import { uiProblemFor } from "../ui.js";
17
20
  import { CONNECTA_VERSION } from "../version.js";
21
+ import { prepareStatusSlot, readStatusObservation, storeStatusObservation, } from "../connector-status.js";
18
22
  const DETAIL_TIMEOUT_MS = 30_000;
19
23
  const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
24
+ const OBSERVE_TIMED_OUT = "Connector did not answer before the operator page's deadline.";
20
25
  function attempt(operation) {
21
26
  return Effect.tryPromise({ try: operation, catch: (error) => error });
22
27
  }
@@ -38,6 +43,35 @@ export function uiData(registry, baseUrl, options) {
38
43
  const logger = registry.contextFor(id, baseUrl, requestScope).logger;
39
44
  logFailure(logger, "operator status", failureRecord({ connector: id, state }, failure), state === "auth_required" ? "info" : "warn");
40
45
  };
46
+ // A stored row: the observation's state and problem, and no tools. They
47
+ // stay in the catalog cache (INV-8), and the browser's live read, which
48
+ // follows in its own request, shows them. Reading them here could connect,
49
+ // and the row's scope would wait for that listing to end.
50
+ // The credential's stored verification is local, so it is read fresh here too.
51
+ const observeStored = (c, stored, drift, requestScope) => drift
52
+ ? Effect.succeed({ status: { state: "auth_required", message: drift }, tools: [], catalogFailed: false })
53
+ : Effect.map(verificationFor(c, requestScope), (verification) => ({
54
+ status: { state: stored.state },
55
+ tools: [],
56
+ catalogFailed: stored.problem === "catalog_failed",
57
+ ...(stored.problem === "connector_timeout" ? { timedOut: true } : {}),
58
+ verification,
59
+ }));
60
+ /**
61
+ * The stored last verification of an operator-managed credential
62
+ * (src/credential-verification.ts). Read only, never probed (INV-10). A
63
+ * record counts only for the save it observed, whose revision the card's
64
+ * memoized vault read already holds; a vault without revisions has none
65
+ * to match.
66
+ */
67
+ const verificationFor = (c, requestScope) => c.credential && vault
68
+ ? attempt(async () => {
69
+ const metadata = await credentialReads(vault, requestReads(requestScope)).metadata(c.id, c.authScope === "personal" ? owner : undefined);
70
+ return metadata?.revision
71
+ ? readVerification(registry.contextFor(c.id, baseUrl, requestScope).storage, metadata.revision)
72
+ : undefined;
73
+ }).pipe(Effect.orElseSucceed(() => undefined))
74
+ : Effect.succeed(undefined);
41
75
  // Status first, then the catalog only when status is ok. A failed status
42
76
  // read is an error status, not a failed row: the row still reports the
43
77
  // connector's credential.
@@ -54,8 +88,9 @@ export function uiData(registry, baseUrl, options) {
54
88
  signal,
55
89
  ...(options.defer ? { defer: options.defer } : {}),
56
90
  }));
91
+ const verification = yield* verificationFor(c, requestScope);
57
92
  if (status.state !== "ok" || signal.aborted) {
58
- return { status, tools: [], catalogFailed: false };
93
+ return { status, tools: [], catalogFailed: false, verification };
59
94
  }
60
95
  // A catalog is configuration the operator chose to load, and its
61
96
  // descriptions are the agent's too, so the page shows them as text.
@@ -71,10 +106,10 @@ export function uiData(registry, baseUrl, options) {
71
106
  name: recordedToolName(t),
72
107
  address: `${c.id}.${recordedToolName(t)}`,
73
108
  classification: t.classification === "read" ? "read" : "write",
74
- }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
109
+ }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false, verification })),
75
110
  // The registry owns the failed catalog observation; the page only
76
111
  // needs to know there was one.
77
- Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })),
112
+ Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted, verification })),
78
113
  // Loading the catalog may replace an earlier clean observation.
79
114
  // Read this view's current bounded facts without another status probe.
80
115
  Effect.map((observed) => ({
@@ -87,9 +122,23 @@ export function uiData(registry, baseUrl, options) {
87
122
  catalogFailed: false,
88
123
  })));
89
124
  };
125
+ // The observation under its own deadline. Its abort reaches the status
126
+ // and catalog reads through `inner`, which then settle as an error or a
127
+ // failed catalog; either way the reason on `inner` says the deadline
128
+ // ended them, so the row is a timeout rather than a failure.
129
+ const observeWithin = (c, drift, signal, requestScope, timeoutMs) => {
130
+ const timeoutError = new Error(OBSERVE_TIMED_OUT);
131
+ const timedOut = () => ({
132
+ status: failureStatus(c.id, timeoutError),
133
+ tools: [],
134
+ catalogFailed: false,
135
+ timedOut: true,
136
+ });
137
+ return withDeadlineEffect((inner) => observe(c, drift, inner, requestScope).pipe(Effect.map((observed) => (inner.reason === timeoutError ? timedOut() : observed))), { timeoutMs, signal, timeoutError }).pipe(Effect.catch((error) => (error === timeoutError ? Effect.succeed(timedOut()) : Effect.fail(error))));
138
+ };
90
139
  // The credential card, for someone who may manage this connector's auth.
91
140
  // Never fails: a vault that cannot be read is the card's own error.
92
- const credentialFor = (c) => {
141
+ const credentialFor = (c, requestScope) => {
93
142
  const mayManageAuth = options.mayManage?.(c.id) ?? (c.authScope === "personal" ? Boolean(owner) : options.oauthManagement);
94
143
  const declared = c.credential;
95
144
  if (!declared || !vault || !mayManageAuth)
@@ -121,7 +170,9 @@ export function uiData(registry, baseUrl, options) {
121
170
  ...(declared.placeholder ? { placeholder: declared.placeholder } : {}),
122
171
  };
123
172
  return attempt(async () => {
124
- const metadata = await vault.metadata(c.id, c.authScope === "personal" ? owner : undefined);
173
+ // Memoized per request: the card only displays what the drift and
174
+ // status reads of this same detail already read.
175
+ const metadata = await credentialReads(vault, requestReads(requestScope)).metadata(c.id, c.authScope === "personal" ? owner : undefined);
125
176
  const fields = credentialFields(metadata);
126
177
  const shape = storedCredentialShape(declared, metadata?.fields ?? null);
127
178
  return {
@@ -152,18 +203,52 @@ export function uiData(registry, baseUrl, options) {
152
203
  });
153
204
  }));
154
205
  };
155
- const detail = (c, signal, requestScope) => Effect.gen(function* () {
156
- const drift = yield* attempt(() => registry.credentialDriftFor(c.id));
206
+ // Where this row records what it observed. A live read prepares only the
207
+ // generation; a stored read looks the observation up and, finding none,
208
+ // records the live read it falls back to.
209
+ const statusLookup = (c, requestScope) => options.statusObservation === "stored"
210
+ ? readStatusObservation(registry, c.id, baseUrl, requestScope)
211
+ : options.statusObservation === "record"
212
+ ? Effect.map(prepareStatusSlot(registry, c.id, baseUrl, requestScope), (slot) => (slot ? { slot } : {}))
213
+ : Effect.succeed({});
214
+ const detail = (c, signal, requestScope, recording) => Effect.gen(function* () {
215
+ // The status slot is read before the probe starts, so an invalidation
216
+ // during it leaves the observation under the generation it replaced.
217
+ const [drift, stored] = yield* Effect.all([attempt(() => registry.credentialDriftFor(c.id, requestScope)), statusLookup(c, requestScope)], { concurrency: 2 });
218
+ const observation = stored.observation;
219
+ const statusSlot = observation ? undefined : stored.slot;
220
+ // The row's own deadline may end it below; it records from here.
221
+ if (statusSlot)
222
+ recording.slot = statusSlot;
157
223
  // One deadline covers the whole row. The status and catalog reads once
158
224
  // had a second one of the same length inside it, which could only fire
159
225
  // after this one had already ended the row.
160
- const { status, tools, catalogFailed } = yield* observe(c, drift, signal, requestScope);
161
- const credential = yield* credentialFor(c);
226
+ // The operator page's shorter observation deadline, when it has one,
227
+ // bounds only the downstream: the credential card below is local
228
+ // storage and still renders, so a slow vendor's slot stays fixable.
229
+ const { status, tools, catalogFailed, timedOut, verification } = observation
230
+ ? yield* observeStored(c, observation, drift, requestScope)
231
+ : options.observeTimeoutMs === undefined
232
+ ? yield* observe(c, drift, signal, requestScope)
233
+ : yield* observeWithin(c, drift, signal, requestScope, options.observeTimeoutMs);
234
+ const credential = yield* credentialFor(c, requestScope);
162
235
  logStatus(c.id, status.state, statusFailure(status), requestScope);
163
- const problem = uiProblemFor(c, status.state, {
164
- credentialDrift: Boolean(drift),
165
- catalogFailed,
166
- });
236
+ const problem = timedOut
237
+ ? "connector_timeout"
238
+ : uiProblemFor(c, status.state, {
239
+ credentialDrift: Boolean(drift),
240
+ catalogFailed,
241
+ });
242
+ const toolCount = observation ? observation.toolCount : tools.length;
243
+ // Started only while the caller is still here, then awaited to its
244
+ // end even if the row's deadline or the caller ends it meanwhile.
245
+ if (statusSlot && !signal.aborted && !options.signal?.aborted)
246
+ yield* Effect.uninterruptible(storeStatusObservation(statusSlot, {
247
+ state: status.state,
248
+ ...(problem ? { problem } : {}),
249
+ toolCount,
250
+ observedAt: Date.now(),
251
+ }));
167
252
  return {
168
253
  id: c.id,
169
254
  authScope: c.authScope ?? "shared",
@@ -171,7 +256,7 @@ export function uiData(registry, baseUrl, options) {
171
256
  ...(c.description !== undefined ? { description: c.description } : {}),
172
257
  status: status.state,
173
258
  ...(problem ? { problem } : {}),
174
- toolCount: tools.length,
259
+ toolCount,
175
260
  tools: tools.map((tool) => ({
176
261
  name: tool.name,
177
262
  address: tool.address,
@@ -186,7 +271,9 @@ export function uiData(registry, baseUrl, options) {
186
271
  ...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
187
272
  catalogAgeMs: registry.catalogAgeMs(c.id),
188
273
  lastCall: null,
274
+ ...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
189
275
  },
276
+ ...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
190
277
  // Counts only, and only when a refresh in this runtime produced them.
191
278
  // `Registry.withCatalogObservations` rebuilt the report through
192
279
  ...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
@@ -196,43 +283,62 @@ export function uiData(registry, baseUrl, options) {
196
283
  : {}),
197
284
  oauth: Boolean(c.startAuth && c.disconnectAuth),
198
285
  ...(status.registrationPath ? { registrationPath: status.registrationPath } : {}),
286
+ ...(verification ? { verification } : {}),
199
287
  ...(credential ? { credential } : {}),
200
288
  };
201
289
  });
202
- const unavailable = (c, error, requestScope) => Effect.sync(() => {
290
+ const unavailable = (c, error, requestScope, timeoutError) => Effect.sync(() => {
203
291
  logStatus(c.id, "failed", error, requestScope);
292
+ // The row's own deadline is a timeout too, whichever ran out first.
293
+ const problem = error === timeoutError ? "connector_timeout" : "connector_unavailable";
204
294
  return {
205
295
  id: c.id,
206
296
  ...(c.title ? { title: c.title } : {}),
207
297
  authScope: c.authScope ?? "shared",
208
298
  status: "error",
209
- problem: "connector_unavailable",
299
+ problem,
210
300
  oauth: Boolean(c.startAuth && c.disconnectAuth),
211
301
  toolCount: 0,
212
302
  tools: [],
213
303
  overlay: {
214
304
  id: c.id,
215
305
  status: "error",
216
- problem: "connector_unavailable",
306
+ problem,
217
307
  tools: [],
218
308
  catalogAgeMs: registry.catalogAgeMs(c.id),
219
309
  lastCall: null,
220
310
  },
221
311
  };
222
312
  });
313
+ // The row's own deadline ended the read: record that timeout before the
314
+ // response, so the next stored read parks this row too. A caller who has
315
+ // already left records nothing. Once started, the write is awaited to its
316
+ // end, never raced or detached: storage writes cannot be withdrawn once
317
+ // sent, so the request settles only after it does, bounded by the storage
318
+ // layer like every other awaited write (INV-7).
319
+ const recordRowTimeout = (slot) => !slot || options.signal?.aborted
320
+ ? Effect.void
321
+ : Effect.uninterruptible(storeStatusObservation(slot, {
322
+ state: "error",
323
+ problem: "connector_timeout",
324
+ toolCount: 0,
325
+ observedAt: Date.now(),
326
+ }));
223
327
  const row = (c) => Effect.suspend(() => {
224
328
  // A row ends independently. Revoking its probe must not revoke a live
225
329
  // sibling that happens to belong to the same operator payload.
226
330
  const requestScope = {};
227
331
  if (options.activityContext)
228
332
  bindActivityRequest(requestScope, options.activityContext);
229
- return withDeadlineEffect((signal) => detail(c, signal, requestScope), {
333
+ const timeoutError = new Error(DETAIL_TIMED_OUT);
334
+ const recording = {};
335
+ return withDeadlineEffect((signal) => detail(c, signal, requestScope, recording), {
230
336
  timeoutMs: options.timeoutMs ?? DETAIL_TIMEOUT_MS,
231
337
  ...(options.signal ? { signal: options.signal } : {}),
232
- timeoutError: new Error(DETAIL_TIMED_OUT),
338
+ timeoutError,
233
339
  }).pipe(
234
340
  // A hook that throws where it should reject fails its own row too.
235
- Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => unavailable(c, error, requestScope)), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
341
+ Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => Effect.andThen(recordRowTimeout(error === timeoutError ? recording.slot : undefined), unavailable(c, error, requestScope, timeoutError))), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
236
342
  });
237
343
  return Effect.forEach(registry.listConnectors(), row, {
238
344
  concurrency: resolveDiscoveryConcurrency(options.discoveryConcurrency),
package/dist/routes/ui.js CHANGED
@@ -134,6 +134,16 @@ function operatorView({ opts }, authz, registry) {
134
134
  : "requires_operator";
135
135
  return { visible, mayManage, permissions, activityEnabled, credentialManagement };
136
136
  }
137
+ /**
138
+ * How long a detail read waits on the downstream before its row reports
139
+ * `connector_timeout`. A cold remote MCP handshake with an OAuth refresh is
140
+ * about five sequential round trips, so 8 s allows ~1.5 s each; anything
141
+ * slower is shown as slow or unreachable rather than holding one of the
142
+ * page's few detail slots for the full discovery deadline. A deployment that
143
+ * set a shorter `discovery.probeTimeoutMs` keeps that one deadline, which also
144
+ * ends in `connector_timeout`.
145
+ */
146
+ const DETAIL_OBSERVE_TIMEOUT_MS = 8000;
137
147
  /** One connector's probed row, the page's second request per card. */
138
148
  function connectorDetail(context, id) {
139
149
  const { request, baseUrl, opts, defer } = context;
@@ -158,7 +168,13 @@ function connectorDetail(context, id) {
158
168
  discoveryConcurrency: 1,
159
169
  personalCredentialOwner: authz.principalKey,
160
170
  mayManage,
171
+ // A live read records its observation; `?observation=stored` answers
172
+ // from the current one instead and leaves the live read to the browser.
173
+ statusObservation: context.url.searchParams.get("observation") === "stored" ? "stored" : "record",
161
174
  timeoutMs: opts.config.discovery.probeTimeoutMs,
175
+ ...(opts.config.discovery.probeTimeoutMs > DETAIL_OBSERVE_TIMEOUT_MS
176
+ ? { observeTimeoutMs: DETAIL_OBSERVE_TIMEOUT_MS }
177
+ : {}),
162
178
  signal: request.signal,
163
179
  });
164
180
  const detail = data.connectors[0];
@@ -182,7 +198,7 @@ function summary(context) {
182
198
  const authz = yield* authorized(context);
183
199
  const registry = yield* visibleRegistry(context, authz);
184
200
  const { visible, mayManage, permissions, activityEnabled, credentialManagement } = operatorView(context, authz, registry);
185
- // Only maintainers reach here (#837); setup commands are offered for every
201
+ // Only maintainers reach here; setup commands are offered for every
186
202
  // declared pool, since they administer them all.
187
203
  const pools = [...opts.pools.keys()];
188
204
  return privateJson({
@@ -7,7 +7,7 @@
7
7
  // block: the one place outside its instance methods that may read its private
8
8
  // bookkeeping, and one that emits nothing into the `.d.ts`.
9
9
  //
10
- // The two controllers stay separate on purpose (#453): request admission
10
+ // The two controllers stay separate on purpose: request admission
11
11
  // bounds executor work, this bounds calls to one downstream connector, and a
12
12
  // shared queue between them was refused.
13
13
  import { Effect } from "effect";
@@ -1,12 +1,13 @@
1
1
  // Operation and call services. Only the Promise view bridge is keyed on a
2
2
  // context object; authority lives in Context values, shared by derived views.
3
+ import { Routes, routesOf } from "../agent-routes.js";
3
4
  import { Context, Effect } from "effect";
4
5
  import { ConnectorCallError } from "../errors.js";
5
6
  import { dispatchProviderRequest, } from "../provider-call.js";
6
7
  import { SentSecrets, sentSecretsForRequest, registerCredentialValues } from "../sent-secrets.js";
7
8
  import { recordRecovery } from "../call-recovery.js";
8
9
  import { fromSignal, runEdge } from "./run.js";
9
- import { RequestScope, requestOwner as requestOf } from "./request-scope.js";
10
+ import { RequestScope, requestOwner as requestOf, requestReads } from "./request-scope.js";
10
11
  /** Who admitted the operation; absent for a probe or a callback no request admitted. */
11
12
  export class Caller extends Context.Service()("connecta/call/Caller") {
12
13
  }
@@ -106,7 +107,7 @@ function ended() {
106
107
  return new ConnectorCallError("connector_call_failed", "This call has ended; a capability kept from it can no longer be used.", { retryable: false });
107
108
  }
108
109
  /** Build one call's services. Invocation calls this in the scoped attempt that owns the permit (B.1). */
109
- export function openCall(inputs) {
110
+ function openCall(inputs) {
110
111
  let open = true;
111
112
  const parentSignal = inputs.parent ? Context.get(inputs.parent.services, Signal) : undefined;
112
113
  const parents = [inputs.signal, parentSignal, inputs.requestSignal].filter((signal) => signal !== undefined);
@@ -197,7 +198,7 @@ export function openCall(inputs) {
197
198
  throw ended();
198
199
  (inputs.ledger ?? detachedLedger).dispatch(request, headers, method);
199
200
  },
200
- }), Context.add(CallMemo, new Map()), Context.add(ProviderLedger, inputs.providerLedger), Context.add(LogicalLedger, inputs.operation ? serviceFor(inputs.operation, LogicalLedger) : undefined));
201
+ }), Context.add(CallMemo, new Map()), Context.add(ProviderLedger, inputs.providerLedger), Context.add(LogicalLedger, inputs.operation ? serviceFor(inputs.operation, LogicalLedger) : undefined), Context.add(Routes, routesOf(inputs.operation)));
201
202
  const lease = {
202
203
  get services() {
203
204
  return Context.add(Context.add(services, ActiveCall, lease), Operation, inputs.operation);
@@ -338,6 +339,10 @@ export function chargeView(view) {
338
339
  const budget = recordedService(view, Budget);
339
340
  return budget ? runEdge(budget.charge) : Promise.reject(ended());
340
341
  }
342
+ /** The read memo of the request this context belongs to (request-reads.ts). */
343
+ export function viewReads(view) {
344
+ return requestReads(views.get(view)?.request ?? view.requestScope ?? view);
345
+ }
341
346
  /** Admission is sticky until request close; ending a call never makes its copies standalone. */
342
347
  export function callScopeKnown(view) {
343
348
  const scope = requestOf(views.get(view)?.request ?? view.requestScope ?? view);
@@ -23,8 +23,17 @@ function current(receiver, key, own, args, native) {
23
23
  export function registerNativeStorage(edge, native) {
24
24
  const own = { get: edge.get, set: edge.set, delete: edge.delete, list: edge.list, compareAndSet: edge.compareAndSet };
25
25
  storageMethods.set(edge, own);
26
+ const getMany = native.getMany;
26
27
  storage.set(edge, {
27
28
  get: (key) => current(edge, "get", own.get, [key], () => native.get(key)),
29
+ // A replaced `get` answers each key itself, in order, as before.
30
+ ...(getMany
31
+ ? {
32
+ getMany: (keys) => typed(() => Reflect.get(edge, "get") === own.get
33
+ ? getMany(keys)
34
+ : Effect.forEach(keys, (key) => current(edge, "get", own.get, [key], () => native.get(key)))),
35
+ }
36
+ : {}),
28
37
  set: (key, value, options) => current(edge, "set", own.set, [key, value, options], () => native.set(key, value, options)),
29
38
  delete: (key) => current(edge, "delete", own.delete, [key], () => native.delete(key)),
30
39
  list: (prefix) => current(edge, "list", own.list, [prefix], () => native.list(prefix)),