@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
@@ -1,7 +1,10 @@
1
1
  import { failureRecord, logFailure } from "../operator-record.js";
2
2
  import { Effect } from "effect";
3
- import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
3
+ import { claimConnectorScopeCleanup } from "../connector-scope.js";
4
4
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
5
+ import { credentialFailureClass, credentialTestOutcome, writeVerification, } from "../credential-verification.js";
6
+ import { closeScope } from "../runtime/connector-scope.js";
7
+ import { withDeadlineEffect } from "../runtime/run.js";
5
8
  import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
6
9
  import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
7
10
  /** The longest body, in characters, a credential write accepts. */
@@ -51,9 +54,19 @@ function readCredentialInput(request, config) {
51
54
  // `null` parses, but a credential cannot be read from it.
52
55
  if (body === null)
53
56
  return yield* refuse("invalid JSON body", 400);
54
- return yield* credentialInput(body, config);
57
+ if (body.verify !== undefined && body.verify !== "test" && body.verify !== "skip") {
58
+ return yield* refuse('verify must be "test" or "skip"', 400);
59
+ }
60
+ const input = yield* credentialInput(body, config);
61
+ return { input, verify: body.verify ?? "skip" };
55
62
  });
56
63
  }
64
+ /** The test hook's refusal when the declared shape picks none, in the page's words. */
65
+ function untestable(mismatch) {
66
+ return refuse(mismatch
67
+ ? "this connector cannot test its credential: " + describeCredentialTestMismatch(mismatch)
68
+ : "this connector does not support credential testing", 400);
69
+ }
57
70
  function credentialRequest(context, connectorId, action) {
58
71
  const { request, baseUrl, opts } = context;
59
72
  return Effect.gen(function* () {
@@ -79,6 +92,64 @@ function credentialRequest(context, connectorId, action) {
79
92
  const updatedBy = authz.identity.principal
80
93
  ? `${authz.identity.principal.namespace}:${authz.identity.principal.id}`
81
94
  : (authz.actor.id ?? authz.actor.kind);
95
+ /**
96
+ * Run the connector's test hook on `values` — the stored credential or an
97
+ * unsaved candidate, which stays in this request's memory — under the
98
+ * deployment's probe deadline. Settles to an outcome class and never
99
+ * fails: whatever goes wrong is the test's result, not the route's. Each
100
+ * run gets its own context and request scope, so concurrent tests share
101
+ * no connection.
102
+ *
103
+ * Only the class leaves. A hook's message, and anything it throws, can
104
+ * quote the downstream's reply, and that reply can quote the credential
105
+ * it just rejected — so neither the page nor the log repeats it (INV-6):
106
+ * the page says fixed copy for the class, and the log records a
107
+ * failure's class.
108
+ */
109
+ const runTest = (mode, values) => {
110
+ const timeoutError = new Error("credential test timed out");
111
+ return withDeadlineEffect((signal) => {
112
+ const ctx = registry.contextFor(connectorId, baseUrl, {}, { signal });
113
+ const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
114
+ return Effect.tryPromise({
115
+ try: async () => {
116
+ const result = mode === "multiple"
117
+ ? await connector.testCredentials(values, ctx)
118
+ : // The single-value shape guarantees this key.
119
+ await connector.testCredential(values.value, ctx);
120
+ // A hook that turned the deadline's abort into a result still ran out of time.
121
+ return signal.reason === timeoutError ? "timeout" : credentialTestOutcome(result);
122
+ },
123
+ catch: (error) => (signal.reason === timeoutError ? timeoutError : error),
124
+ }).pipe(
125
+ // The scope closes here, once, however the test ends: a hook that
126
+ // never settles is interrupted by the deadline, and a cleanup in
127
+ // its Promise would never run (INV-7).
128
+ Effect.ensuring(Effect.suspend(() => {
129
+ releaseCleanup();
130
+ return closeScope(connector, ctx, context.defer);
131
+ })));
132
+ }, { timeoutMs: opts.config.discovery.probeTimeoutMs, timeoutError }).pipe(Effect.catch((error) => {
133
+ if (error === timeoutError)
134
+ return Effect.succeed("timeout");
135
+ logFailure(opts.config.logger, "credential test threw", failureRecord({ connector: connectorId }, error));
136
+ return Effect.succeed(credentialFailureClass(error));
137
+ }), Effect.map((outcome) => {
138
+ if (outcome !== "ok")
139
+ logFailure(opts.config.logger, "credential test failed", failureRecord({ connector: connectorId }));
140
+ return outcome;
141
+ }));
142
+ };
143
+ /**
144
+ * Record what was observed about the save with `revision`, or (`null`)
145
+ * forget it, in the connector's own owner-partitioned storage. A save
146
+ * without a revision (a vault that keeps none) is never recorded. Best
147
+ * effort: a lost record only leaves status saying the credential is not
148
+ * verified, so it never fails the request.
149
+ */
150
+ const record = (outcome, revision) => Effect.promise(async () => outcome && !revision
151
+ ? undefined
152
+ : writeVerification(registry.contextFor(connectorId, baseUrl).storage, outcome, revision).catch((error) => void logFailure(opts.config.logger, "credential verification record failed", failureRecord({ connector: connectorId }, error))));
82
153
  if (action === "test") {
83
154
  if (request.method !== "POST")
84
155
  return yield* refuse("method not allowed", 405);
@@ -86,79 +157,74 @@ function credentialRequest(context, connectorId, action) {
86
157
  // Credentials page asks for its Test affordance, so a shown button reaches
87
158
  // that reads the shape the credential was stored in.
88
159
  const { mode, mismatch } = credentialTestRule(connector);
89
- if (!mode) {
90
- return yield* refuse(mismatch
91
- ? "this connector cannot test its credential: " + describeCredentialTestMismatch(mismatch)
92
- : "this connector does not support credential testing", 400);
93
- }
94
- // Whatever goes wrong past this point is the test's result, not the
95
- // route's: the page shows it beside the Test button. Only `ok` leaves
96
- // the host. A hook's message, and anything it throws, can quote the
97
- // downstream's reply, and that reply can quote the credential it just
98
- // rejected — so neither the page nor the log repeats it (INV-6): the
99
- // page says a fixed sentence for the outcome, and the log records a
100
- // failure's class.
101
- return yield* Effect.tryPromise({
102
- try: async () => {
103
- const values = await vault.getAll(connectorId, owner);
104
- const shape = storedCredentialShape(credential, values);
105
- // Both refusals are the route's own words, and each carries the
106
- // problem kind the page keys its copy and fix prompt off.
107
- if (shape.state === "missing") {
108
- return privateJson({
109
- error: mode === "multiple"
110
- ? "configure the credentials before testing them"
111
- : "configure the credential before testing it",
112
- problem: "credential_required",
113
- }, { status: 409 });
114
- }
115
- if (shape.state === "mismatch") {
116
- return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
117
- }
118
- const storedValues = values;
119
- const ctx = registry.contextFor(connectorId, baseUrl);
120
- const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
121
- try {
122
- const result = mode === "multiple"
123
- ? await connector.testCredentials(storedValues, ctx)
124
- : await connector.testCredential(
125
- // The single-value shape check above guarantees this key.
126
- storedValues.value, ctx);
127
- const ok = result?.ok === true;
128
- if (!ok)
129
- logFailure(opts.config.logger, "credential test failed", failureRecord({ connector: connectorId }, undefined));
130
- return privateJson({ ok });
131
- }
132
- finally {
133
- releaseCleanup();
134
- await closeConnectorScope(connector, ctx, context.defer);
135
- }
136
- },
160
+ if (!mode)
161
+ return yield* untestable(mismatch);
162
+ // One envelope read gives both the values tested and the revision of
163
+ // the save they came from, so the record can never vouch for another
164
+ // save. A vault without snapshots is tested but never recorded.
165
+ const stored = yield* Effect.tryPromise({
166
+ try: async () => vault.snapshot
167
+ ? ((await vault.snapshot(connectorId, owner)) ?? { values: null })
168
+ : { values: await vault.getAll(connectorId, owner) },
137
169
  catch: (error) => error,
138
170
  }).pipe(Effect.catch((error) => {
139
171
  logFailure(opts.config.logger, "credential test threw", failureRecord({ connector: connectorId }, error));
140
- return Effect.succeed(privateJson({ ok: false }));
172
+ return Effect.succeed(undefined);
141
173
  }));
174
+ if (!stored)
175
+ return privateJson({ ok: false, failure: "failed" });
176
+ const shape = storedCredentialShape(credential, stored.values);
177
+ // Both refusals are the route's own words, and each carries the
178
+ // problem kind the page keys its copy and fix prompt off.
179
+ if (shape.state === "missing") {
180
+ return privateJson({
181
+ error: mode === "multiple"
182
+ ? "configure the credentials before testing them"
183
+ : "configure the credential before testing it",
184
+ problem: "credential_required",
185
+ }, { status: 409 });
186
+ }
187
+ if (shape.state === "mismatch") {
188
+ return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
189
+ }
190
+ const outcome = yield* runTest(mode, stored.values);
191
+ if ("metadata" in stored)
192
+ yield* record(outcome, stored.metadata.revision);
193
+ return privateJson(outcome === "ok" ? { ok: true } : { ok: false, failure: outcome });
142
194
  }
143
195
  if (action)
144
196
  return yield* refuse("not found", 404);
145
197
  if (request.method === "PUT") {
146
- const input = yield* readCredentialInput(request, credential);
147
- return yield* Effect.tryPromise({
148
- try: async () => {
149
- const metadata = input.kind === "single"
150
- ? await vault.set(connectorId, input.value, updatedBy, owner)
151
- : await vault.setAll(connectorId, input.values, updatedBy, owner);
152
- await registry.invalidateStored(connectorId);
153
- return privateJson({ credential: metadata });
154
- },
198
+ const { input, verify } = yield* readCredentialInput(request, credential);
199
+ const values = input.kind === "single" ? { value: input.value } : input.values;
200
+ if (verify === "test") {
201
+ // Test then save: the candidate is tested in memory and stored only
202
+ // if the test passes. A failure stores nothing and names its class;
203
+ // the operator may still save it unverified with `verify: "skip"`,
204
+ // so an outage at the vendor never blocks a replacement.
205
+ const { mode, mismatch } = credentialTestRule(connector);
206
+ if (!mode)
207
+ return yield* untestable(mismatch);
208
+ const outcome = yield* runTest(mode, values);
209
+ if (outcome !== "ok")
210
+ return privateJson({ saved: false, failure: outcome });
211
+ }
212
+ const metadata = yield* Effect.tryPromise({
213
+ try: () => input.kind === "single"
214
+ ? vault.set(connectorId, input.value, updatedBy, owner)
215
+ : vault.setAll(connectorId, input.values, updatedBy, owner),
155
216
  catch: (error) => error,
156
217
  }).pipe(Effect.catch((error) => refuse(msg(error), 400)));
218
+ // The write's own revision: a concurrent save cannot lend it one.
219
+ const { revision, ...saved } = metadata;
220
+ const verification = yield* record(verify === "test" ? "ok" : "unverified", revision);
221
+ yield* Effect.promise(() => registry.invalidateStored(connectorId));
222
+ return privateJson({ saved: true, credential: saved, ...(verification ? { verification } : {}) });
157
223
  }
158
224
  if (request.method === "DELETE") {
159
225
  // Clearing nothing is refused, as testing nothing is, so the route
160
226
  // agrees with the operator contract, which offers `clear` only once a
161
- // value is stored in the caller's partition (#837). A value that cannot
227
+ // value is stored in the caller's partition. A value that cannot
162
228
  // be read may still be cleared: removing it is how one recovers.
163
229
  const stored = yield* Effect.tryPromise({
164
230
  try: () => vault.metadata(connectorId, owner),
@@ -168,6 +234,9 @@ function credentialRequest(context, connectorId, action) {
168
234
  return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
169
235
  }
170
236
  yield* Effect.promise(() => vault.delete(connectorId, owner));
237
+ // A record about a value no longer stored would match nothing anyway;
238
+ // removing it keeps the partition clean.
239
+ yield* record(null);
171
240
  yield* Effect.promise(() => registry.invalidateStored(connectorId));
172
241
  return new Response(null, {
173
242
  status: 204,
@@ -28,7 +28,11 @@ export function createRouteDispatcher(mcp, metadata, health) {
28
28
  });
29
29
  const afterUi = (program) => (context) => Effect.gen(function* () {
30
30
  const ui = context.opts.config.ui;
31
- const response = ui ? yield* Effect.promise(() => ui.handle(context)) : null;
31
+ // The operator surface is a Promise edge that owns the request's
32
+ // signal: when the caller leaves, its routes stop on their own. Wait
33
+ // for it to settle rather than abandoning it, so writes it has
34
+ // already started end inside this request (INV-7).
35
+ const response = ui ? yield* Effect.uninterruptible(Effect.promise(() => ui.handle(context))) : null;
32
36
  if (response?.status === 401) {
33
37
  const authorizationHeader = authorizationCredential(context.request).kind;
34
38
  logFailure(context.opts.config.logger, "operator_refused", failureRecord({
@@ -1,4 +1,6 @@
1
+ import { routeNames } from "../agent-routes.js";
1
2
  import { upwardStep } from "../upward.js";
3
+ import { bindDiscoveryEndpoint } from "../discovery-health.js";
2
4
  import { authCall, authInvoke, authFlow, authMethod } from "../auth/native.js";
3
5
  import { registryProgram } from "../runtime/registry.js";
4
6
  import { requestOwner, RequestScope } from "../runtime/request-scope.js";
@@ -20,7 +22,7 @@ import { failureRecord, logFailure } from "../operator-record.js";
20
22
  import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
21
23
  import { detach } from "../runtime/run.js";
22
24
  import { agentEndpointPool, isAgentPath } from "./agent-path.js";
23
- import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, } from "./shared.js";
25
+ import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, personPools, } from "./shared.js";
24
26
  /**
25
27
  * Core checks admission.requests.maxBodyBytes after authentication and endpoint
26
28
  * admission, before any SDK/body-hint reader. The SDK's separate 4 MiB default
@@ -32,7 +34,7 @@ const deadlineResponses = new WeakSet();
32
34
  /**
33
35
  * The most a `subscriptions/listen` classification reads. A listen is a small
34
36
  * JSON-RPC request; this read happens before request admission, so it is
35
- * bounded by this fixed cap rather than by the host's body limit (#857).
37
+ * bounded by this fixed cap rather than by the host's body limit.
36
38
  */
37
39
  export const MAX_LISTEN_CLASSIFICATION_BYTES = 64 * 1024;
38
40
  /**
@@ -286,7 +288,7 @@ function admitted(controller, signal) {
286
288
  }), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
287
289
  }
288
290
  /**
289
- * 404 for any `?toolkit=` value, kept after the feature's retirement (#178).
291
+ * 404 for any `?toolkit=` value, kept after the feature's retirement.
290
292
  *
291
293
  * Toolkits are gone, but the URLs that named them are not: clients were handed
292
294
  * MCP endpoint URLs with `?toolkit=` baked in, and nothing about upgrading the
@@ -396,8 +398,11 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
396
398
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
397
399
  });
398
400
  const diagnostics = native ? { ...native, catalogErrors: [] } : undefined;
401
+ // A native pool's text uses the native contract's wording.
402
+ const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
399
403
  const nativeRoute = registerMetaTools(server, registry, {
400
404
  native: diagnostics,
405
+ routes,
401
406
  authElicitation,
402
407
  client,
403
408
  baseUrl,
@@ -409,6 +414,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
409
414
  credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
410
415
  ...(activity ? { activity } : {}),
411
416
  defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
417
+ maxProgramResultChars: opts.config.execute.maxResultChars,
412
418
  probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
413
419
  discoveryConcurrency: opts.config.discovery.concurrency,
414
420
  requestSignal,
@@ -429,6 +435,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
429
435
  ...executeLimits(opts.config),
430
436
  readOnly,
431
437
  native: native !== undefined,
438
+ routes,
432
439
  });
433
440
  }
434
441
  if (native && nativeRoute && hint) {
@@ -596,6 +603,9 @@ export function createMcpRoute(opts) {
596
603
  const requestScope = {};
597
604
  const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
598
605
  const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
606
+ // Discovery health is kept per admitted endpoint: the same person and
607
+ // pool at `/mcp` and `/mcp/<pool>` keep separate records.
608
+ bindDiscoveryEndpoint(requestScope, JSON.stringify([endpoint.kind, poolName ?? null]));
599
609
  const agentEndpoint = endpoint.kind === "agent";
600
610
  // Access attaches `ctx.access` even to a path it bypasses; an agent
601
611
  // endpoint never reads it, so nothing past this point receives it.
@@ -638,7 +648,7 @@ export function createMcpRoute(opts) {
638
648
  }));
639
649
  // A request naming `subscriptions/listen` defers its permit: nothing of
640
650
  // its body is read until auth and endpoint admission pass, and then at
641
- // most MAX_LISTEN_CLASSIFICATION_BYTES (#857). Only a body-confirmed
651
+ // most MAX_LISTEN_CLASSIFICATION_BYTES. Only a body-confirmed
642
652
  // modern listen goes without a permit; trusting the header alone would
643
653
  // let a legacy tools/call bypass the pool, so anything else takes one
644
654
  // after classification. Every other request takes its permit here,
@@ -718,13 +728,17 @@ export function createMcpRoute(opts) {
718
728
  false,
719
729
  opts.config.logger,
720
730
  ]));
721
- // Plain /mcp admits maintainers and serves the whole catalog; a pool
731
+ // Plain /mcp serves maintainers the whole catalog and other people their unique pool; a pool
722
732
  // admits its members, people at /mcp/<pool> and agents at
723
733
  // /agent/<pool>, and serves exactly its tools (INV-4).
724
734
  const scopeFor = (admitted) => {
725
735
  if (poolName === undefined) {
726
- return admitsMaintainer(opts, admitted)
727
- ? { access: { connectorIds: "all" }, readOnly: opts.config.readOnly }
736
+ if (admitsMaintainer(opts, admitted))
737
+ return { access: { connectorIds: "all" }, readOnly: opts.config.readOnly };
738
+ // Exactly one membership routes; none or several fail closed.
739
+ const [match, ...more] = personPools(opts, admitted.identity);
740
+ return match && !more.length
741
+ ? { pool: match[0], access: match[1].access, readOnly: match[1].readOnly }
728
742
  : undefined;
729
743
  }
730
744
  const pool = agentEndpoint
@@ -734,6 +748,7 @@ export function createMcpRoute(opts) {
734
748
  // same caller facts (#837 P5: agents act as the pool owner).
735
749
  return pool
736
750
  ? {
751
+ pool: poolName,
737
752
  access: pool.access,
738
753
  readOnly: pool.readOnly,
739
754
  ...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
@@ -779,14 +794,16 @@ export function createMcpRoute(opts) {
779
794
  ? "MCP agent endpoint request denied"
780
795
  : "MCP pool request denied", failureRecord({
781
796
  reason: poolName === undefined
782
- ? "not_maintainer"
797
+ ? personPools(opts, authz.identity).length > 1
798
+ ? "ambiguous_pool"
799
+ : "not_maintainer"
783
800
  : opts.pools.has(poolName)
784
801
  ? "not_pool_member"
785
802
  : "pool_not_declared",
786
803
  }));
787
804
  return notFound();
788
805
  }
789
- const { access, readOnly, poolOwner } = scope;
806
+ const { access, readOnly } = scope;
790
807
  // Admission, not completed-call activity. This fact carries no live
791
808
  // identity or credential and never changes the admission verdict.
792
809
  if (agentEndpoint && authz.identity.agent && opts.config.agents.includes(authz.identity.agent)) {
@@ -811,15 +828,15 @@ export function createMcpRoute(opts) {
811
828
  }
812
829
  // The caller rides on the view, and the paging recheck builds the
813
830
  // same one: an agent's Workspace visibility depends on the owner.
814
- const callerFor = (identity, owner) => ({
831
+ const callerFor = (identity, admittedScope) => ({
815
832
  identity,
816
833
  // `authorize` admits an open deployment's every request as the
817
834
  // anonymous actor; only a provider's `ok` is an authentication.
818
835
  authenticated: opts.config.auth.length > 0,
819
836
  // Both pool endpoints build the caller here from the same
820
837
  // admitted scope, so an agent at /agent/<pool> carries the owner.
821
- ...(poolName !== undefined ? { pool: poolName } : {}),
822
- ...(owner !== undefined ? { poolOwner: owner } : {}),
838
+ ...(admittedScope.pool !== undefined ? { pool: admittedScope.pool } : {}),
839
+ ...(admittedScope.poolOwner !== undefined ? { poolOwner: admittedScope.poolOwner } : {}),
823
840
  });
824
841
  let scopedRegistry;
825
842
  try {
@@ -843,14 +860,14 @@ export function createMcpRoute(opts) {
843
860
  return false;
844
861
  // Paging re-admits the caller at this endpoint, as a new request would.
845
862
  const currentScope = scopeFor(current);
846
- if (!currentScope)
863
+ if (!currentScope || currentScope.pool !== scope.pool)
847
864
  return false;
848
- const { access: currentAccess, readOnly: currentReadOnly, poolOwner: currentOwner } = currentScope;
865
+ const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
849
866
  const view = opts.registry.scoped({
850
867
  ...currentAccess,
851
868
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
852
869
  ...(current.principalKey ? { principalKey: current.principalKey } : {}),
853
- caller: callerFor(current.identity, currentOwner),
870
+ caller: callerFor(current.identity, currentScope),
854
871
  });
855
872
  if (classification === "write" && currentReadOnly)
856
873
  return false;
@@ -897,7 +914,7 @@ export function createMcpRoute(opts) {
897
914
  }
898
915
  }
899
916
  })),
900
- caller: callerFor(authz.identity, poolOwner),
917
+ caller: callerFor(authz.identity, scope),
901
918
  });
902
919
  }
903
920
  catch {
@@ -909,7 +926,7 @@ export function createMcpRoute(opts) {
909
926
  return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
910
927
  const connector = scopedRegistry.getConnector(id);
911
928
  return Boolean(connector && mayManageConnector(opts, authz, connector));
912
- }, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
929
+ }, authz.principalKey, runtimeContext, readOnly, scope.pool, authz.identity.principal
913
930
  ? {
914
931
  kind: authz.actor.kind,
915
932
  id: authz.identity.principal.id,
@@ -23,7 +23,7 @@ function oauthManagementRequest(context, connectorId) {
23
23
  const registry = yield* managementRegistry(context, authz);
24
24
  const connector = registry.getConnector(connectorId);
25
25
  const disconnectAuth = connector?.disconnectAuth?.bind(connector);
26
- // Each verb needs only its own hooks (#837): connecting needs the whole
26
+ // Each verb needs only its own hooks: connecting needs the whole
27
27
  // flow, through the callback's state check; disconnecting needs only
28
28
  // disconnectAuth, so a grant provisioned out of band can still be dropped.
29
29
  const connects = Boolean(connector?.startAuth && connector.finishAuth && connector.verifyState);
@@ -1,3 +1,4 @@
1
+ import { bytesToHex } from "../bytes.js";
1
2
  // The Effect plumbing the operator and activity data routes share.
2
3
  //
3
4
  // Each JSON route under /ui/* is one Effect program, run at its Promise edge
@@ -84,7 +85,7 @@ export function operatorViewerKey(context, authz) {
84
85
  ]));
85
86
  const signingKey = key;
86
87
  const digest = yield* attempt(() => crypto.subtle.sign("HMAC", signingKey, identity));
87
- return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
88
+ return bytesToHex(new Uint8Array(digest));
88
89
  }), {
89
90
  timeoutMs: 500,
90
91
  signal: context.request.signal,
@@ -92,16 +93,16 @@ export function operatorViewerKey(context, authz) {
92
93
  }).pipe(Effect.catch(() => refuse("operator identity could not be read", 503)));
93
94
  }
94
95
  /** The partition an identity carries into a registry view of the given connectors. */
95
- export function scopeFor(authz, authenticated, connectorIds = "all") {
96
+ export function scopeFor(authz, authenticated, connectorIds = "all", pool) {
96
97
  return {
97
98
  connectorIds,
98
99
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
99
100
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
100
- operatorCaller: { identity: authz.identity, authenticated },
101
+ operatorCaller: { identity: authz.identity, authenticated, ...(pool !== undefined ? { pool } : {}) },
101
102
  };
102
103
  }
103
104
  /**
104
- * The whole registry, for a maintainer only (#837). Everyone else gets the
105
+ * The whole registry, for a maintainer only. Everyone else gets the
105
106
  * private 404 plain `/mcp` gives them.
106
107
  */
107
108
  export function visibleRegistry({ opts }, authz) {
@@ -111,7 +112,7 @@ export function visibleRegistry({ opts }, authz) {
111
112
  return Effect.succeed(opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0)));
112
113
  }
113
114
  /**
114
- * The connectors a connector-auth route may resolve (#837): every connector
115
+ * The connectors a connector-auth route may resolve: every connector
115
116
  * for a maintainer, the connectors of their pools for a pool member, and the
116
117
  * private 404 for anyone else. mayManageConnector still decides each action.
117
118
  */
@@ -1,3 +1,4 @@
1
+ import type { AgentRouteNames } from "../types.js";
1
2
  import type { ActivityActor } from "../activity.js";
2
3
  import type { DeferredWork } from "../connector-scope.js";
3
4
  import type { AdmissionController } from "../executor-admission.js";
@@ -28,6 +29,8 @@ export interface ServerOptions {
28
29
  executorName?: string | undefined;
29
30
  /** Global FIFO boundary for all non-preflight `/mcp` requests. */
30
31
  requestAdmission: AdmissionController;
32
+ /** Route names agent text uses outside native pools: meta, or program form for the eval code arm only. */
33
+ routes: AgentRouteNames;
31
34
  }
32
35
  export interface RuntimeExecutionContext extends InboundAuthRuntimeContext {
33
36
  waitUntil(promise: Promise<unknown>): void;
@@ -78,7 +81,7 @@ export declare function authorize(request: Request, baseUrl: string, auth: reado
78
81
  response: Response;
79
82
  }>;
80
83
  /**
81
- * Admission at an agent endpoint, `/agent/<pool>` (#837). Only the built-in
84
+ * Admission at an agent endpoint, `/agent/<pool>`. Only the built-in
82
85
  * agent credential adapters are consulted: Clerk and Access never run, cookies and Clerk
83
86
  * handshake parameters are stripped, and the runtime context, which carries
84
87
  * Access's `ctx.access` even on a bypassed path, is never passed. Anything but
@@ -88,31 +91,33 @@ export declare function authorizeAgent(request: Request, baseUrl: string, auth:
88
91
  /** Human routes reject machine credentials before any verification or storage I/O. */
89
92
  export declare function authorizeUiIdentity(request: Request, baseUrl: string, auth: readonly InboundAuth[], purpose: string, runtimeContext?: RuntimeExecutionContext, logger?: Logger): Promise<Awaited<ReturnType<typeof authorize>>>;
90
93
  export declare function isSameOrigin(request: Request, baseUrl: string): boolean;
91
- export declare function withSecurityHeaders(response: Response, requestUrl: URL, _path: string): Response;
94
+ export declare function withSecurityHeaders(response: Response, requestUrl: URL): Response;
92
95
  type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
93
96
  ok: true;
94
97
  }>;
95
98
  /**
96
- * Plain `/mcp` and every operator route admit a maintainer (#837). An open
99
+ * Every operator route admits a maintainer. An open
97
100
  * deployment has no inbound auth and so no maintainers: its one anonymous
98
101
  * caller keeps the whole default endpoint, as the startup warning says.
99
102
  */
100
103
  export declare function admitsMaintainer(opts: ServerOptions, authz: AuthorizedIdentity): boolean;
101
104
  /**
102
105
  * The declared pool `/mcp/<name>` serves to this person, or undefined for the
103
- * same 404. An agent is never admitted here, even when listed (#837).
106
+ * same 404. An agent is never admitted here, even when listed.
104
107
  */
105
108
  export declare function memberPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
109
+ /** Human memberships, matched only against the resolved namespaced principal. */
110
+ export declare function personPools(opts: ServerOptions, identity: Readonly<AuthenticatedIdentity>): Array<[string, ResolvedPool]>;
106
111
  /** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
107
112
  export declare function agentPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
108
113
  /**
109
- * The connectors of every pool this identity is a member of (#837): what a
114
+ * The connectors of every pool this identity is a member of: what a
110
115
  * pool member may ever manage, and only their personal ones among these.
111
116
  */
112
117
  export declare function memberConnectors(opts: ServerOptions, identity: Readonly<AuthenticatedIdentity>): "all" | ReadonlySet<string>;
113
118
  /**
114
119
  * Whether this identity may start, finish, or remove a connector's auth
115
- * (#837). Only an interactive person ever may; an agent or any other machine
120
+ * Only an interactive person ever may; an agent or any other machine
116
121
  * credential manages nothing. Shared auth is deployment-wide, so it takes a
117
122
  * maintainer. A personal connector takes a stable principal that is a
118
123
  * maintainer, whose plain `/mcp` includes it, or a member of a pool that
@@ -124,10 +129,10 @@ export declare function mayManageConnector(opts: ServerOptions, authz: Authorize
124
129
  id: string;
125
130
  authScope?: "shared" | "personal";
126
131
  }): boolean;
127
- /** Agent token list, issue, rotate, and revoke: an interactive maintainer only (#837). */
132
+ /** Agent token list, issue, rotate, and revoke: an interactive maintainer only. */
128
133
  export declare function managesAgentTokens(opts: ServerOptions, authz: AuthorizedIdentity): boolean;
129
134
  /**
130
- * Activity reads: a maintainer principal, interactive or not (#837). Members
135
+ * Activity reads: a maintainer principal, interactive or not. Members
131
136
  * and agents read none, and an open deployment has no maintainer.
132
137
  */
133
138
  export declare function readsActivity(opts: ServerOptions, authz: AuthorizedIdentity): boolean;