@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -25,6 +25,8 @@ import { Integer, isMaxChars, isMinChars } from "./json-schema.js";
25
25
  import { boundCursor, StandardPage } from "./paging.js";
26
26
  import { Classification, Dispatches, IdempotencyKey, RequestSecrets, Signal } from "./services.js";
27
27
  import { read, write } from "./tools.js";
28
+ /** A page that reports more data it cannot continue must never read as the complete answer. */
29
+ const CONTINUATION_WITHHELD = "This result is incomplete: more data exists and continuation was withheld.";
28
30
  const indexedOperations = new WeakMap();
29
31
  function operationsOf(index) {
30
32
  let operations = indexedOperations.get(index);
@@ -204,7 +206,7 @@ export function hatch(options) {
204
206
  }
205
207
  return fieldsInput(fieldsNote(first ?? preferred));
206
208
  };
207
- const fieldsNote = (example) => fieldsDescription("data", example, `Copy declared paths from ${name}_api_details response.fields. Applied after value safety and privacy filtering.`);
209
+ const fieldsNote = (example) => fieldsDescription("data", example, `Copy declared paths from ${name}_api_details response.fields. A withheld value reads "[redacted]".`);
208
210
  const verdictOf = (op) => verdicts.get(`${op.method} ${op.path}`) ?? "refuse";
209
211
  const refusalOf = (op) => connection.safety.refusal?.({ method: op.method, template: op.path }) ?? options.binding?.refusal?.(op);
210
212
  const discoveryRefusals = new Map();
@@ -259,7 +261,7 @@ export function hatch(options) {
259
261
  }
260
262
  : {}),
261
263
  warnings: Schema.optionalKey(Schema.Array(Schema.String).annotate({
262
- description: `${options.binding?.warnings ? "Reviewed warnings preserved after fields selection, and " : ""}fields entries that matched nothing in this nonempty response, with the properties declared where matching stopped. At most 10 warnings of 512 characters, then an omitted count.`,
264
+ description: `${options.binding?.warnings ? "Reviewed warnings preserved after fields selection, and " : ""}fields entries that matched nothing in this nonempty response, with the properties declared where matching stopped${pages.size ? ", and a notice when continuation was withheld" : ""}. At most 10 warnings of 512 characters, then an omitted count.`,
263
265
  })),
264
266
  });
265
267
  const hasTotals = [...pages.values()].some((adapters) => adapters.some((adapter) => adapter.total !== undefined));
@@ -268,11 +270,14 @@ export function hatch(options) {
268
270
  ...callEnvelope.fields,
269
271
  page: Schema.optionalKey(Schema.Struct({
270
272
  ...StandardPage.fields,
273
+ hasMore: Schema.Boolean.annotate({
274
+ description: "True without nextCursor means this data is incomplete and continuation was withheld.",
275
+ }),
271
276
  ...(hasTotals ? { total: Schema.optionalKey(Integer.check(Schema.isGreaterThanOrEqualTo(0))) } : {}),
272
277
  param: Schema.String.annotate({
273
278
  description: options.pageCursor === "argument"
274
279
  ? "Vendor parameter restored from cursor; pass nextCursor as the top-level cursor."
275
- : "Put nextCursor in this vendor parameter, unchanged.",
280
+ : "Put nextCursor unchanged in query[param] or body[param], as page.in says, and keep every other argument.",
276
281
  }),
277
282
  in: Schema.Literals(["query", "body"]),
278
283
  })),
@@ -1171,6 +1176,8 @@ export function hatch(options) {
1171
1176
  const send = options.binding
1172
1177
  ? options.binding.send(call(), sendOptions)
1173
1178
  : connection.sendPath(request, sendOptions);
1179
+ // Both send paths share the binding result contract. Keep its optional
1180
+ // metadata typed even when the plain HTTP branch omits those properties.
1174
1181
  const answer = yield* send.pipe(Effect.catch((failure) => Effect.gen(function* () {
1175
1182
  if (!key || args.idempotencyKey)
1176
1183
  return yield* Effect.fail(failure);
@@ -1221,6 +1228,7 @@ export function hatch(options) {
1221
1228
  // Roots were checked before dispatch; the pinned index cannot say whether a
1222
1229
  // deeper level is closed, so a nested miss is reported, never refused.
1223
1230
  const warnings = boundWarnings([
1231
+ ...(page?.hasMore && page.nextCursor === undefined ? [CONTINUATION_WITHHELD] : []),
1224
1232
  ...(options.binding?.warnings && "warnings" in answer ? (answer.warnings ?? []) : []),
1225
1233
  ...fieldsMisses(answered, args.fields, index
1226
1234
  .fields(op)
@@ -1,9 +1,68 @@
1
1
  // Node-only: owned undici dispatcher and Node stream adapters, reached via /node.
2
+ import { Resolver } from "node:dns/promises";
3
+ import { learnedDestination } from "../guarded-transport.js";
4
+ import { learnedUrlRefusal } from "../url-safety.js";
5
+ import { OAuthRequestNotSentError } from "../auth/oauth-request-not-sent.js";
2
6
  import { Readable } from "node:stream";
3
7
  import { isIP } from "node:net";
4
8
  import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
5
9
  import { Client, ProxyAgent } from "undici";
6
10
  import { ConnectorCallError } from "../errors.js";
11
+ /** Each lookup owns its DNS channels; cancellation closes them before returning. */
12
+ async function resolveAddresses(hostname, signal) {
13
+ const resolver = new Resolver();
14
+ const cancel = () => resolver.cancel();
15
+ signal.throwIfAborted();
16
+ signal.addEventListener("abort", cancel, { once: true });
17
+ try {
18
+ const results = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]);
19
+ signal.throwIfAborted();
20
+ for (const result of results) {
21
+ if (result.status === "rejected" && !["ENODATA", "ENOTFOUND"].includes(result.reason?.code))
22
+ throw result.reason;
23
+ }
24
+ return results.flatMap((result, index) => result.status === "fulfilled" ? result.value.map((address) => ({ address, family: index === 0 ? 4 : 6 })) : []);
25
+ }
26
+ finally {
27
+ signal.removeEventListener("abort", cancel);
28
+ resolver.cancel();
29
+ }
30
+ }
31
+ /** Observe even a misbehaving injected resolver, without holding the request open. */
32
+ async function boundedLookup(lookup, hostname, requestSignal) {
33
+ const controller = new AbortController();
34
+ const abort = () => controller.abort(requestSignal?.reason);
35
+ const timer = setTimeout(() => controller.abort(new Error("DNS resolution timed out.")), 10_000);
36
+ requestSignal?.addEventListener("abort", abort, { once: true });
37
+ let onAbort;
38
+ let resolution;
39
+ try {
40
+ if (requestSignal?.aborted)
41
+ abort();
42
+ controller.signal.throwIfAborted();
43
+ resolution = lookup(hostname, controller.signal);
44
+ return await Promise.race([
45
+ resolution,
46
+ new Promise((_, reject) => {
47
+ onAbort = () => reject(controller.signal.reason);
48
+ controller.signal.addEventListener("abort", onAbort, { once: true });
49
+ if (controller.signal.aborted)
50
+ onAbort();
51
+ }),
52
+ ]);
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ requestSignal?.removeEventListener("abort", abort);
57
+ if (onAbort)
58
+ controller.signal.removeEventListener("abort", onAbort);
59
+ controller.abort();
60
+ // Drain the owned resolver after cancelling its channels (INV-7). Test
61
+ // injections may ignore cancellation, but cannot hold send() open.
62
+ if (lookup === resolveAddresses)
63
+ await resolution?.catch(() => { });
64
+ }
65
+ }
7
66
  /** Pull-based bridge: Node 22's Readable.toWeb can enqueue after Web cancellation. */
8
67
  function responseBody(body, signal) {
9
68
  const chunks = body[Symbol.asyncIterator]();
@@ -43,7 +102,7 @@ function responseBody(body, signal) {
43
102
  * HTTP/2 REFUSED_STREAM and pipeline recovery paths, for every method.
44
103
  * The response/abort owns the client until the body ends or is cancelled.
45
104
  */
46
- export function createNodeGuardedTransport(proxy) {
105
+ export function createNodeGuardedTransport(proxy, lookup = resolveAddresses) {
47
106
  let proxyUri;
48
107
  let proxyToken;
49
108
  if (proxy !== undefined) {
@@ -68,7 +127,41 @@ export function createNodeGuardedTransport(proxy) {
68
127
  return {
69
128
  delaysDispatch: true,
70
129
  async send(url, init, beforeDispatch = () => { }) {
130
+ const configured = init[learnedDestination];
131
+ let pinned;
132
+ if (configured && url.origin !== configured.origin) {
133
+ const refuse = () => new OAuthRequestNotSentError("connector_call_failed", "The learned URL resolved to an unsafe destination; nothing was sent.", { retryable: false });
134
+ if (learnedUrlRefusal(configured, url))
135
+ throw refuse();
136
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
137
+ let addresses;
138
+ try {
139
+ addresses = isIP(hostname)
140
+ ? [{ address: hostname, family: isIP(hostname) }]
141
+ : await boundedLookup(lookup, hostname, init.signal);
142
+ }
143
+ catch {
144
+ init.signal?.throwIfAborted();
145
+ throw refuse();
146
+ }
147
+ init.signal?.throwIfAborted();
148
+ if (addresses.length === 0 ||
149
+ addresses.some(({ address, family }) => {
150
+ if (!isIP(address) || isIP(address) !== family)
151
+ return true;
152
+ const resolved = new URL(url);
153
+ resolved.hostname = family === 6 ? `[${address}]` : address;
154
+ return learnedUrlRefusal(configured, resolved) !== undefined;
155
+ }))
156
+ throw refuse();
157
+ pinned = addresses[0];
158
+ }
159
+ const destination = new URL(url);
160
+ if (pinned && proxyUri)
161
+ destination.hostname = pinned.family === 6 ? `[${pinned.address}]` : pinned.address;
71
162
  const framed = new Request(url, init);
163
+ if (pinned)
164
+ framed.headers.set("host", url.host);
72
165
  if (!framed.headers.has("user-agent"))
73
166
  framed.headers.set("user-agent", "connecta");
74
167
  let dispatched = false;
@@ -86,6 +179,7 @@ export function createNodeGuardedTransport(proxy) {
86
179
  uri: proxyUri,
87
180
  ...(proxyToken ? { token: proxyToken } : {}),
88
181
  proxyTunnel: true,
182
+ ...(pinned ? { requestTls: { servername: isIP(url.hostname) ? "" : url.hostname } } : {}),
89
183
  ...clientOptions,
90
184
  interceptors: { Agent: [], Client: [] },
91
185
  // Neither side of CONNECT uses a Pool, interceptor or global dispatcher.
@@ -99,7 +193,7 @@ export function createNodeGuardedTransport(proxy) {
99
193
  // ProxyAgent 6 explicitly sets SNI to the proxy hostname.
100
194
  // Node 26 rejects IP SNI. Omit it for literal IPs; tls.connect
101
195
  // still checks the certificate against its connection host.
102
- connect({ ...parameters, ...(name && isIP(name.replace(/^\[|\]$/g, "")) ? { servername: "" } : {}) }, callback);
196
+ connect({ ...parameters, ...(name ? { servername: isIP(name.replace(/^\[|\]$/g, "")) ? "" : name } : {}) }, callback);
103
197
  },
104
198
  }));
105
199
  },
@@ -135,7 +229,19 @@ export function createNodeGuardedTransport(proxy) {
135
229
  });
136
230
  },
137
231
  })
138
- : new Client(url.origin, clientOptions);
232
+ : new Client(url.origin, {
233
+ ...clientOptions,
234
+ ...(pinned
235
+ ? {
236
+ connect: {
237
+ // No second resolver can replace the checked address. The Client
238
+ // retains the URL hostname for SNI and certificate verification.
239
+ lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
240
+ autoSelectFamily: false,
241
+ },
242
+ }
243
+ : {}),
244
+ });
139
245
  const destroy = () => Promise.allSettled([tunnelClient?.destroy(), client.destroy()]);
140
246
  const abort = () => {
141
247
  void destroy();
@@ -146,7 +252,7 @@ export function createNodeGuardedTransport(proxy) {
146
252
  if (!proxyUri)
147
253
  dispatch();
148
254
  const answer = await client.request({
149
- origin: url.origin,
255
+ origin: destination.origin,
150
256
  path: `${url.pathname}${url.search}`,
151
257
  method: framed.method,
152
258
  headers: Object.fromEntries(framed.headers),
@@ -30,7 +30,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse";
30
30
  import { readRequest, renewedAfterUnauthorized } from "../provider-call.js";
31
31
  import { renewableGrantFor } from "../auth/renewable-grant.js";
32
32
  import { ConnectorCallError } from "../errors.js";
33
- import { responseRequest } from "./client.js";
33
+ import { failedResponseRequest, responseRequest } from "./client.js";
34
34
  import { Refusal, RequestRefused } from "./errors.js";
35
35
  import { Dispatches, Signal, duringCall } from "./services.js";
36
36
  /** The calling owner's renewable token, as the operations a transport needs from it. */
@@ -53,6 +53,9 @@ export function grantFromOAuth(oauth) {
53
53
  return {
54
54
  token: use(grant ? () => grant.token() : missing),
55
55
  refresh: (stale) => use(grant ? () => grant.refresh(stale) : missing),
56
+ ...(grant?.observe
57
+ ? { observe: (token, outcome) => use(() => grant.observe(token, outcome)) }
58
+ : {}),
56
59
  rejected: use(grant ? () => grant.rejected() : missing),
57
60
  };
58
61
  }
@@ -80,12 +83,21 @@ export function refreshOnce(guarded, options) {
80
83
  return yield* Effect.fail(refusal);
81
84
  });
82
85
  const execute = (request) => Effect.gen(function* () {
83
- const response = yield* inner.execute(request).pipe(Effect.catchTag("ResponseTooLarge", (failure) => {
86
+ const grant = yield* Grant;
87
+ const observeOversized = (failure) => {
88
+ const sent = failedResponseRequest(failure);
89
+ return responseRequest(failure)?.status === 401 && sent && grant.observe
90
+ ? grant.observe(sentToken(sent), "refused")
91
+ : Effect.void;
92
+ };
93
+ const response = yield* inner.execute(request).pipe(Effect.tapErrorTag("ResponseTooLarge", observeOversized), Effect.catchTag("ResponseTooLarge", (failure) => {
84
94
  const entry = responseRequest(failure);
85
95
  return options?.challengeMaxBytes !== undefined && entry?.status === 401
86
96
  ? challengeFailure(entry)
87
97
  : Effect.fail(failure);
88
98
  }));
99
+ if (grant.observe && (response.status === 401 || (response.status >= 200 && response.status < 300)))
100
+ yield* grant.observe(sentToken(response.request), response.status === 401 ? "refused" : "accepted");
89
101
  if (response.status !== 401)
90
102
  return response;
91
103
  // Read under the ceiling now, so nothing is left open while renewing.
@@ -94,7 +106,6 @@ export function refreshOnce(guarded, options) {
94
106
  .pipe(Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined
95
107
  ? challengeFailure(responseRequest(response))
96
108
  : Effect.fail(failure)));
97
- const grant = yield* Grant;
98
109
  const stale = sentToken(response.request);
99
110
  const entry = responseRequest(response);
100
111
  if (!readRequest(entry ?? {}) && options?.writeRenewal === "discard") {
@@ -135,11 +146,11 @@ export function refreshOnce(guarded, options) {
135
146
  return yield* Effect.fail(new RequestRefused({ message: "This call has ended; no renewal work may continue." }));
136
147
  renewedAfterUnauthorized(entry, stale, fresh);
137
148
  // `authenticate` runs again on the replay and reads the renewed token.
138
- const replay = yield* inner
139
- .execute(request)
140
- .pipe(Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined && responseRequest(failure)?.status === 401
149
+ const replay = yield* inner.execute(request).pipe(Effect.tapErrorTag("ResponseTooLarge", observeOversized), Effect.catchTag("ResponseTooLarge", (failure) => options?.challengeMaxBytes !== undefined && responseRequest(failure)?.status === 401
141
150
  ? grant.rejected
142
151
  : Effect.fail(failure)));
152
+ if (grant.observe && (replay.status === 401 || (replay.status >= 200 && replay.status < 300)))
153
+ yield* grant.observe(sentToken(replay.request), replay.status === 401 ? "refused" : "accepted");
143
154
  if (replay.status !== 401)
144
155
  return replay;
145
156
  if (options?.replayRejection === "answer") {
@@ -20,7 +20,7 @@ export const StandardPage = Schema.Struct({
20
20
  });
21
21
  /** The input field that carries a cursor back. */
22
22
  export const Cursor = Schema.String.check(Schema.isMinLength(1)).annotate({
23
- description: "Opaque nextCursor from the previous page. Pass it back unchanged.",
23
+ description: "Opaque page.nextCursor from the previous page. Pass it back unchanged with the same arguments.",
24
24
  });
25
25
  /**
26
26
  * Lookahead paging, for a vendor that returns no `has_more`: ask for one row
@@ -46,7 +46,7 @@ const missingGrant = {
46
46
  };
47
47
  /** An ordinary Promise connector factory; Effect remains inside the kit. */
48
48
  export function defineApiProvider(definition) {
49
- const { displayTitle, description, authScope, vendor, baseUrl, maxResponseBytes, timeoutMs, headers, admit, transport, defaultCallAdmission, pageSize: declaredPageSize, idempotencyKey, credential, oauth, oauthWrite401, grant, grantRenewal, authenticate, errors, safety, result, inBandRefusals, probe, guide, transformGuide, hatch: declaredHatch, tools, tagConnector, searchSynonyms, ...metadata } = definition;
49
+ const { displayTitle, description, authScope, vendor, baseUrl, maxResponseBytes, timeoutMs, headers, admit, transport, defaultCallAdmission, pageSize: declaredPageSize, idempotencyKey, credential, oauth, oauthWrite401, grant, grantRenewal, authenticate, publicIdentifierHeaders, errors, safety, result, inBandRefusals, probe, guide, transformGuide, hatch: declaredHatch, tools, tagConnector, searchSynonyms, ...metadata } = definition;
50
50
  if (searchSynonyms !== undefined &&
51
51
  !(Array.isArray(searchSynonyms) &&
52
52
  searchSynonyms.length > 0 &&
@@ -95,18 +95,13 @@ export function defineApiProvider(definition) {
95
95
  kind: "api",
96
96
  kit: {
97
97
  operations: operations(vendor),
98
- ...(pageSize ? { pageSize } : {}),
99
98
  ...(hatchOptions
100
99
  ? {
101
100
  hatch: {
102
101
  name: hatchOptions.name,
103
102
  verdicts: hatchOptions.verdicts,
104
103
  ...(hatchOptions.fieldsRoot ? { fieldsRoot: hatchOptions.fieldsRoot } : {}),
105
- ...(hatchOptions.pageCursor && Object.keys(hatchOptions.pages ?? {}).length
106
- ? { paging: hatchOptions.pageCursor }
107
- : {}),
108
104
  },
109
- refusesWithheld: true,
110
105
  }
111
106
  : {}),
112
107
  ...(meanings ? { errors: meanings } : {}),
@@ -134,6 +129,7 @@ export function defineApiProvider(definition) {
134
129
  ...(headers === undefined ? {} : { headers }),
135
130
  ...(admit === undefined ? {} : { admit }),
136
131
  authenticate: typeof authenticate === "function" ? authenticate(options) : authenticate,
132
+ ...(publicIdentifierHeaders === undefined ? {} : { publicIdentifierHeaders }),
137
133
  ...configured,
138
134
  });
139
135
  const connected = connect(vendor, oauth || machineGrant
@@ -342,6 +342,7 @@ export function connect(vendor, guarded, options) {
342
342
  method: declared.method,
343
343
  template: declared.path,
344
344
  ...(sendOptions?.flags ? { flags: sendOptions.flags } : {}),
345
+ ...(sendOptions?.context ? { context: sendOptions.context } : {}),
345
346
  };
346
347
  return Effect.flatMap(admitAddress({
347
348
  params: request.params,
@@ -370,7 +371,11 @@ export function connect(vendor, guarded, options) {
370
371
  request.form !== undefined ||
371
372
  request.emptyForm))
372
373
  return Effect.fail(new RequestRefused({ message: "A raw body cannot accompany a bulk item or another body encoding." }));
373
- return Effect.flatMap(admitAddress({ path, query: request.query }), () => Effect.flatMap(options.safety.resolve(method, path), (operation) => settle({ ...operation, ...(sendOptions?.flags ? { flags: sendOptions.flags } : {}) }, Effect.suspend(() => {
374
+ return Effect.flatMap(admitAddress({ path, query: request.query }), () => Effect.flatMap(options.safety.resolve(method, path), (operation) => settle({
375
+ ...operation,
376
+ ...(sendOptions?.flags ? { flags: sendOptions.flags } : {}),
377
+ ...(sendOptions?.context ? { context: sendOptions.context } : {}),
378
+ }, Effect.suspend(() => {
374
379
  let built = HttpClientRequest.make(method)(path, request.query ? { urlParams: parameterPairs(request.query) } : {});
375
380
  if (request.headers)
376
381
  built = HttpClientRequest.setHeaders(built, request.headers);
package/dist/index.d.ts CHANGED
@@ -41,6 +41,6 @@ export type { Registry } from "./registry.js";
41
41
  export type { RemoteMcpOptions, RemoteMcpAuth, RemoteMcpRequestAuth, RemoteMcpRedirectPolicy, } from "./connectors/remote-mcp.js";
42
42
  export type { AgentRequestPrincipal, HumanRequestPrincipal, PrincipalForwarding, PrincipalRequestToken, RequestPrincipal, RequestPrincipalPolicy, RequestToken, RequestTokenInput, } from "./request-token.js";
43
43
  export type { ApiHandlerContext, ApiOAuthAccess, ApiOAuthClientAuthentication, ApiOAuthConfig, ApiOptions, ApiTool, } from "./connectors/api.js";
44
- export type { ConnectaBranding, ConnectaTheme, Connector, ConnectorCallAdmissionInput, ConnectorCallAdmissionPolicy, ConnectorCallAdmissionRule, ConnectorRollingWindowBudget, ConnectorCredentialAccess, ConnectorCredentialConfig, ConnectorCredentialFieldConfig, ConnectorCredentialValues, AgentRouteNames, ConnectorContext, ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, ConnectorUsageGuide, ConnectorSkill, ConnectorSkillResourceContents, DescribedEndpoint, ConnectorStatus, CredentialFailureClass, CredentialTestResult, AdmittingExecutor, AdmissionSnapshot, ExecuteResult, Executor, ExecutorLease, ExecutorProvider, InboundAuth, InboundAuthRuntimeContext, UiAuthConfig, AuthResult, AuthenticatedIdentity, IdentityReference, JsonSchema, KVStorage, Logger, ToolDef, ToolAnnotations, } from "./types.js";
44
+ export type { ConnectaBranding, ConnectaTheme, Connector, ConnectorCallAdmissionInput, ConnectorCallAdmissionPolicy, ConnectorCallAdmissionRule, ConnectorRollingWindowBudget, ConnectorCredentialAccess, ConnectorCredentialConfig, ConnectorCredentialFieldConfig, ConnectorCatalogPeek, ConnectorCredentialValues, AgentRouteNames, ConnectorContext, ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, ConnectorUsageGuide, ConnectorSkill, ConnectorSkillResourceContents, DescribedEndpoint, ConnectorStatus, CredentialFailureClass, CredentialTestResult, AdmittingExecutor, AdmissionSnapshot, ExecuteResult, Executor, ExecutorLease, ExecutorProvider, InboundAuth, InboundAuthRuntimeContext, UiAuthConfig, AuthResult, AuthenticatedIdentity, IdentityReference, JsonSchema, KVStorage, Logger, ToolDef, ToolAnnotations, } from "./types.js";
45
45
  export type { ActivityActor, ActivityCatalogChange, ActivityCallSource, ActivityOutcome, ActivityPage, ActivityReadActor, ActivityReadEvent, ActivityReader, ActivityReadGate, ActivityReadPage, ActivitySink, ActivityStore, AgentFriction, ToolCallActivityEvent, } from "./activity.js";
46
46
  export { META_TOOL_NAMES } from "./meta-tool-names.js";
package/dist/index.js CHANGED
@@ -92,14 +92,16 @@ function warnInsecureConfig(config) {
92
92
  : "") +
93
93
  "Configure Clerk, Cloudflare Access on Workers, or accessTokens(storage) to gate access.");
94
94
  }
95
- // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
96
- // derived per-request from the attacker-influenced inbound Host header.
97
- if (oauthConnectors.length > 0 && !config.publicUrl) {
98
- logger.warn("[connecta] publicUrl is unset while OAuth connectors are configured: " +
99
- "the downstream OAuth redirect_uri is derived per-request from the " +
100
- "inbound Host header, so an attacker who controls that header can point " +
101
- "it at their own host and capture the authorization code. Set " +
102
- "`publicUrl` to a fixed https origin.");
95
+ // Unset publicUrl: everything built from the deployment's own URL follows the
96
+ // inbound request, whose Host (and X-Forwarded-Proto on Node) the caller
97
+ // chooses. Clerk without any publicUrl has already refused construction.
98
+ if (!config.publicUrl && (oauthConnectors.length > 0 || inboundAuth.length > 0)) {
99
+ logger.warn("[connecta] publicUrl is unset: the URLs this deployment hands out, including " +
100
+ "protected-resource metadata, authorization handoffs and the downstream OAuth " +
101
+ "redirect_uri, are derived per-request from the inbound Host header (and " +
102
+ "X-Forwarded-Proto on Node). A caller who controls those can point them at " +
103
+ "their own host and, with OAuth connectors, capture the authorization code. " +
104
+ "Set `publicUrl` to a fixed https origin.");
103
105
  }
104
106
  // Branding URLs that failed their scheme gate. Rendering silently falls back
105
107
  // (a bad URL must not take the page down), so this warning is the only way an
@@ -156,19 +158,6 @@ function warnInsecureConfig(config) {
156
158
  `action and POST /ui/credentials/${connector.id}/test answers 400 ` +
157
159
  "until the matching hook is implemented.");
158
160
  }
159
- // A vault written before seal/open existed encrypts credentials but cannot
160
- // seal downstream OAuth state, so tokens stay plaintext at rest beside them.
161
- const vault = config.vault;
162
- if (vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
163
- for (const connector of config.connectors) {
164
- if (!connector.startAuth)
165
- continue;
166
- logger.warn(`[connecta] connector "${connector.id}" stores its downstream OAuth ` +
167
- "tokens, client registration, and PKCE verifier as plaintext: the " +
168
- "configured vault implements no `seal`/`open`. Use " +
169
- "encryptedCredentialVault(...) or add both methods to seal them.");
170
- }
171
- }
172
161
  // OAuth connectors whose callback cannot perform a state/CSRF check. The
173
162
  // public route refuses every callback for these connectors rather than hand
174
163
  // an unverified code to finishAuth, so this warning explains why auth cannot
@@ -222,6 +211,11 @@ export function createConnecta(config) {
222
211
  const requestAdmission = new AdmissionController(resolved.admission.requests);
223
212
  let codeAdmission;
224
213
  let executor = resolved.executor;
214
+ if (executor.deadlineMs !== undefined && executor.deadlineMs >= resolved.execute.watchdogMs) {
215
+ logger.warn(`[connecta] The executor's ${executor.deadlineMs}ms deadline does not end before execute.watchdogMs ` +
216
+ `(${resolved.execute.watchdogMs}ms), so programs end at the watchdog. Raise execute.watchdogMs ` +
217
+ "above it if your MCP clients allow requests that long.");
218
+ }
225
219
  // Read the identity off the configured executor, before any wrapper hides
226
220
  // it behind an anonymous object literal.
227
221
  const configuredExecutorName = executorName(executor);
@@ -0,0 +1,28 @@
1
+ import { keepErrorName } from "./redaction-identity.js";
2
+ export class InvocationFailure extends Error {
3
+ details;
4
+ code;
5
+ retryable;
6
+ retryAfterMs;
7
+ connector;
8
+ operation;
9
+ recovery;
10
+ nextAction;
11
+ retry;
12
+ data;
13
+ constructor(details) {
14
+ super(details.message);
15
+ this.details = details;
16
+ this.name = "InvocationFailure";
17
+ this.code = details.code;
18
+ this.retryable = details.retryable;
19
+ this.retryAfterMs = details.retryAfterMs;
20
+ this.connector = details.connector;
21
+ this.operation = details.operation;
22
+ this.recovery = details.recovery;
23
+ this.nextAction = details.nextAction;
24
+ this.retry = details.retry;
25
+ this.data = details;
26
+ }
27
+ }
28
+ keepErrorName(InvocationFailure, "InvocationFailure");