@zackbart/connecta 0.24.4 → 0.25.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 (107) hide show
  1. package/AGENTS.md +40 -10
  2. package/CHANGELOG.md +253 -0
  3. package/dist/advertised-schema.js +53 -0
  4. package/dist/auth/downstream-oauth.js +490 -106
  5. package/dist/call-admission.js +139 -113
  6. package/dist/catalog-service.js +141 -105
  7. package/dist/catalog.js +258 -33
  8. package/dist/concurrency.js +24 -23
  9. package/dist/connector-scope.js +6 -37
  10. package/dist/connectors/api.d.ts +2 -2
  11. package/dist/connectors/remote-mcp.d.ts +6 -5
  12. package/dist/connectors/remote-mcp.js +317 -241
  13. package/dist/credential-contract.d.ts +9 -0
  14. package/dist/credential-rules.js +2 -2
  15. package/dist/credentials.d.ts +6 -1
  16. package/dist/credentials.js +45 -3
  17. package/dist/execute.js +363 -257
  18. package/dist/executor-admission.js +67 -50
  19. package/dist/executors/quickjs-runtime.js +4 -2
  20. package/dist/executors/quickjs.js +251 -210
  21. package/dist/fix-prompt.js +37 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +51 -20
  24. package/dist/invocation.js +324 -285
  25. package/dist/meta-tools.js +206 -92
  26. package/dist/oauth-callback-outcome.js +94 -0
  27. package/dist/oauth-sealing.js +29 -0
  28. package/dist/operator-ui/fix-prompts.js +87 -0
  29. package/dist/operator-ui/generated.js +2 -2
  30. package/dist/operator-ui/model.d.ts +46 -1
  31. package/dist/operator-ui/setup-commands.js +53 -0
  32. package/dist/operator-ui/view.js +38 -2
  33. package/dist/program-source.js +205 -0
  34. package/dist/providers/notion.js +2 -2
  35. package/dist/providers/revenuecat.js +32 -5
  36. package/dist/providers/stripe.d.ts +3 -2
  37. package/dist/providers/vercel.js +1 -1
  38. package/dist/registry.d.ts +1 -1
  39. package/dist/registry.js +305 -260
  40. package/dist/routes/activity.js +120 -137
  41. package/dist/routes/credentials.js +174 -163
  42. package/dist/routes/mcp.js +129 -70
  43. package/dist/routes/oauth-management.js +60 -71
  44. package/dist/routes/oauth.js +44 -16
  45. package/dist/routes/operator.js +82 -0
  46. package/dist/routes/shared.d.ts +2 -0
  47. package/dist/routes/ui-data.js +210 -0
  48. package/dist/routes/ui.js +64 -32
  49. package/dist/runtime/admission.js +43 -0
  50. package/dist/runtime/call-admission.js +67 -0
  51. package/dist/runtime/connector-scope.js +72 -0
  52. package/dist/runtime/run.js +174 -0
  53. package/dist/runtime/services.js +130 -0
  54. package/dist/runtime/storage.js +66 -0
  55. package/dist/server.js +111 -89
  56. package/dist/skills.js +1 -1
  57. package/dist/storage/file.js +39 -2
  58. package/dist/storage/memory.js +28 -16
  59. package/dist/timeout.js +17 -47
  60. package/dist/types.d.ts +27 -7
  61. package/dist/ui.d.ts +16 -2
  62. package/dist/ui.js +50 -166
  63. package/dist/url-safety.js +157 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/documentation/architecture.md +184 -10
  67. package/documentation/auth.md +80 -0
  68. package/documentation/code-mode.md +69 -24
  69. package/documentation/meta-tools.md +164 -27
  70. package/ethos.md +44 -47
  71. package/examples/worker/README.md +51 -5
  72. package/examples/worker/src/d1-storage.ts +116 -0
  73. package/examples/worker/src/index.ts +6 -6
  74. package/examples/worker/wrangler.jsonc +16 -4
  75. package/package.json +11 -3
  76. package/templates/node/package.json +1 -1
  77. package/dist/auth/downstream-oauth.d.ts +0 -200
  78. package/dist/catalog-fingerprint.d.ts +0 -19
  79. package/dist/catalog-limits.d.ts +0 -9
  80. package/dist/catalog-service.d.ts +0 -263
  81. package/dist/catalog.d.ts +0 -87
  82. package/dist/concurrency.d.ts +0 -3
  83. package/dist/connectors/guarded-fetch.d.ts +0 -96
  84. package/dist/credential-rules.d.ts +0 -85
  85. package/dist/execute.d.ts +0 -154
  86. package/dist/executor-result.d.ts +0 -10
  87. package/dist/executors/quickjs-child.d.ts +0 -1
  88. package/dist/executors/quickjs-protocol.d.ts +0 -67
  89. package/dist/favicon.d.ts +0 -1
  90. package/dist/identity.d.ts +0 -4
  91. package/dist/invocation.d.ts +0 -74
  92. package/dist/mcp-result.d.ts +0 -8
  93. package/dist/meta-tools.d.ts +0 -129
  94. package/dist/operator-ui/generated.d.ts +0 -2
  95. package/dist/operator-ui/view.d.ts +0 -188
  96. package/dist/result-shapes.d.ts +0 -13
  97. package/dist/retired-toolkits.d.ts +0 -2
  98. package/dist/routes/activity.d.ts +0 -2
  99. package/dist/routes/credentials.d.ts +0 -2
  100. package/dist/routes/mcp.d.ts +0 -10
  101. package/dist/routes/oauth-management.d.ts +0 -2
  102. package/dist/routes/oauth.d.ts +0 -2
  103. package/dist/routes/ui.d.ts +0 -2
  104. package/dist/server.d.ts +0 -9
  105. package/dist/skills.d.ts +0 -44
  106. package/dist/timeout.d.ts +0 -21
  107. package/dist/tool-safety.d.ts +0 -9
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.24.4";
7
+ export const CONNECTA_VERSION = "0.25.0";
@@ -43,7 +43,7 @@ channel, `ctx.waitUntil`, threaded through `fetch(request, env, ctx)` — activi
43
43
  writes use it, as does a stale-window catalog refresh, which owns a fresh scope
44
44
  and deadline rather than carrying the inbound one past the request. And a fresh
45
45
  `McpServer` per request is what makes the deployment stateless: no sessions, no
46
- server push, no resumability, scope resolved rather than remembered.
46
+ server push, no stream resumability, scope resolved rather than remembered.
47
47
 
48
48
  ## Request lifecycle
49
49
 
@@ -77,7 +77,8 @@ An admitted non-preflight `/mcp` request then takes five steps in
77
77
 
78
78
  1. **Admit.** One permit from the deployment-wide pool, taken before auth so an
79
79
  unauthenticated flood costs a permit rather than a Clerk lookup, and held
80
- until the response *body* completes, not until the handler returns.
80
+ until the response *body* completes or the caller leaves, not until the
81
+ handler returns.
81
82
  2. **Authorize.** Each `InboundAuth` provider's `authorize` in order, bearer
82
83
  before interactive. First `ok` admits; if all fail, the last provider's
83
84
  challenge is returned. No providers means open — development only, and it
@@ -104,9 +105,9 @@ or hands out, and a change usually belongs in exactly one of them:
104
105
  | Module | Owns |
105
106
  | --- | --- |
106
107
  | `src/registry.ts` | The connector set, identity-scoped views, personal storage partitions, address resolution, catalog TTL/persistence/completeness, refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. |
107
- | `src/catalog-service.ts` | Request-local listing, search, and describe. Coalesces reads inside one request and opts agent reads into the runtime's deferred catalog channel when one exists. |
108
+ | `src/catalog-service.ts` | Request-local listing, search, and describe. Caches catalogs inside one request, fans discovery probes out under deadlines, and opts agent reads into the runtime's deferred catalog channel when one exists. |
108
109
  | `src/invocation.ts` | One tool call: argument validation, call admission, one-attempt timeout, provider retry hints, result unwrapping, size capping, and the activity record. |
109
- | `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. |
110
+ | `src/catalog.ts` | Ranking, description summarizing, and the compact and TypeScript schema renderers discovery shows. |
110
111
  | `src/result-shapes.ts` | Bounded runtime-only inference and merging for output shapes learned from successful read-only calls whose providers declared none. |
111
112
 
112
113
  `src/meta-tools.ts` and `src/execute.ts` are two front doors onto the same two
@@ -144,9 +145,19 @@ accounting error a budget exists to prevent. Both layers are pinned by
144
145
  connector state, catalogs, and result paging — a 15-minute TTL with one
145
146
  runtime-wide accounting of stash bytes and entries, where a full stash returns the
146
147
  successful call's preview and a paging-unavailable notice rather than a result id.
147
- Adapters: `src/storage/memory.ts`, `src/storage/file.ts` (Node), and the
148
- Cloudflare KV/D1 pair in `examples/worker/`, copyable reference source and
149
- deliberately not an importable subpath.
148
+ A second optional method, `compareAndSet(key, expected, next)`, is an atomic
149
+ claim: `null` means absent (expired counts) on the way in and delete on the way
150
+ out. Nothing in core requires it yet. Downstream OAuth uses it where the store
151
+ has it, so resealing legacy plaintext and discarding a refused grant cannot
152
+ overwrite a consent that landed in between, and falls back to a read and a
153
+ write where it does not. A subsystem that needs an exactly-once claim will
154
+ require it explicitly rather than emulate it that way.
155
+ Adapters: `src/storage/memory.ts` and `src/storage/file.ts` (Node) both provide
156
+ it, and the namespaced views core hands connectors forward it only when the
157
+ underlying store has it. `examples/worker/` carries two more: Cloudflare KV,
158
+ eventually consistent and so declaring none, and a D1 store that provides it —
159
+ copyable reference source beside the D1 activity store, deliberately not an
160
+ importable subpath. The shared cases live in `test/storage-contract.ts`.
150
161
 
151
162
  `src/credentials.ts` is the AES-GCM vault behind the root-exported
152
163
  `CredentialVault` contract, selected through the `vault` slot. It binds connector
@@ -187,8 +198,12 @@ material code explicitly permitted; it never edits the connector set, catalog,
187
198
  annotations, scopes, or permission rules. Two invariants shape it: a status read
188
199
  never starts authorization, since OAuth begins with an explicit authorized POST,
189
200
  and each lazy details request owns a bounded downstream scope, so one failing
190
- provider leaves the other connections usable. Credential handoff URLs exist only
191
- while the UI is mounted; OAuth callbacks never need it.
201
+ provider leaves the other connections usable. A connector's status message
202
+ never reaches the page: it can quote a downstream error body, and that body can
203
+ quote the secret it rejected, so the details payload carries only a classified
204
+ `problem` — which picks fixed on-screen copy and a fixed fix prompt — and the raw
205
+ text goes to the server log. Credential handoff URLs exist only while the UI is
206
+ mounted; OAuth callbacks never need it.
192
207
 
193
208
  Its appearance is one token layer. `src/operator-ui/browser.css` resolves every
194
209
  color, radius, and font through a custom property and mixes the rest from those
@@ -218,6 +233,165 @@ boundary in the published tarball. The failure mode is not theoretical: one
218
233
  convenience import of `node:crypto` in a shared helper stops the whole Worker
219
234
  shape from building, in someone else's repository rather than this one.
220
235
 
236
+ ## Effect inside
237
+
238
+ The core runs on Effect v4; no API a deployment touches does. `createConnecta`,
239
+ `remoteMcp()`, `api()`, the `Connector` contract, and every shipped `.d.ts`
240
+ are Promise-shaped and name no Effect type, so a connector author never meets a
241
+ second async paradigm. The reason for the rewrite is the first section of this
242
+ guide: the bugs here are lifetime bugs, and Effect makes a lifetime a value —
243
+ a Scope that closes on every exit, an interrupt that reaches whatever a fiber
244
+ is waiting on, a Deferred that one party completes and any number join. The
245
+ conversion found and closed a dozen of them, each with a test that fails on the
246
+ code before it: permits and leases held after their caller left, waits with no
247
+ bound, and races between requests.
248
+
249
+ ### Shell and core
250
+
251
+ Each converted module is a shell and a core. The shell keeps its exported class
252
+ or function exactly — same members, same error classes, same `.d.ts`, private
253
+ member names included, because TypeScript ships those too. The core is an
254
+ Effect program behind it. In a published class it lives in a `static` block,
255
+ the one place that can read private state without adding a member to the
256
+ declarations; elsewhere it lives in an unexported function or in
257
+ `src/runtime/`, which no `exports` target reaches. Errors keep their classes
258
+ (`ConnectorCallError`, `ExecutorAdmissionError`, `CallAdmissionError`) because
259
+ callers and tests check `instanceof`, `name`, and `message`.
260
+
261
+ Effect callers skip the Promise and take the program: `admit` and
262
+ `acquireScoped` for request admission (`src/runtime/admission.ts`), `admitCall`
263
+ and `acquireCallScoped` for call admission, `closeScope` and `closeScopeOnExit`
264
+ for a connector scope, `withDeadlineEffect` where Promise code has
265
+ `withDeadline`. The build prunes every declaration no `exports` target reaches,
266
+ and `npm run check:declarations` fails if what remains names an Effect type.
267
+
268
+ ### One runner
269
+
270
+ `src/runtime/run.ts` is the only place a fiber starts, and
271
+ `test/purity.test.ts` fails if any other file calls `Effect.run*`, `runFork`,
272
+ `forkDaemon`, or `ManagedRuntime.make`. It exports two ways out:
273
+
274
+ - **`runEdge(effect, { signal, runtime? })`** runs an effect at a Promise
275
+ boundary. It rethrows the original failure or defect, never a wrapper. It
276
+ turns an interrupt into the caller's `signal.reason` rather than Effect's
277
+ "All fibers interrupted without error". And it runs every fiber on a
278
+ scheduler that yields through microtasks: Effect's default yields through
279
+ `setImmediate`, which vitest's fake timers freeze and Workers never had. The
280
+ price is that a fiber never yields to I/O, so CPU-heavy work stays out of
281
+ Effect loops.
282
+ - **`detach(effect, ctx)`** is the only fire-and-forget, and it hands the work
283
+ to `ctx.waitUntil` when there is one.
284
+
285
+ Beside them sit `withDeadlineEffect`, which aborts the operation's own signal
286
+ with the labelled timeout error *before* interrupting it, so work that honors
287
+ the signal sees the same reason the caller does, and `fromSignal`, which turns
288
+ an `AbortSignal` into a failure to race against. Nothing runs Effect at module
289
+ scope, where a Worker may not start work, and nothing logs through
290
+ `Effect.log`: logging goes through the configured `Logger`, which is what
291
+ honors `logger: "silent"`.
292
+
293
+ Each Connecta also gets a runtime of its own (`src/runtime/services.ts`):
294
+ `Storage`, `Vault`, `ActivityRecorder` (one that records nothing when the module
295
+ is omitted), `Logger`, and `ResolvedConfig`. Creating it builds nothing, since
296
+ a Worker constructs its Connecta at global scope; the first run that needs the
297
+ services builds them, and `close()` disposes the runtime last. Two things
298
+ deliberately do not run on it. The request pipeline runs on no runtime, so
299
+ `/health` and a closed deployment's 503 keep answering after `close()`. And a
300
+ registry provides its own storage and logger to its programs
301
+ (`runOnPartition` in `src/runtime/storage.ts`), because a personal registry's
302
+ storage is the root's namespaced to its principal and the runtime's `Storage`
303
+ would be another partition's.
304
+
305
+ ### What runs on Effect
306
+
307
+ | Area | Shape |
308
+ | --- | --- |
309
+ | Requests (`src/server.ts`, `src/routes/mcp.ts`, `src/routes/oauth.ts`) | One fiber per `fetch`, tied to `request.signal`. An `/mcp` request's admission permit and every `McpServer` it builds live in a Scope the response carries out, closed when the body ends, fails, or is cancelled, when the signal aborts, or at once if the handler fails first. The OAuth callback is uninterruptible: a single-use code's exchange and catalog invalidation are one commitment. |
310
+ | Admission (`executor-admission.ts`, `call-admission.ts`) | Queued waiters are Deferreds settled by whoever removes them from the queue; a wait is one flat race of grant, Clock timeout, and signal. The uncontended path stays synchronous. Two controllers, as [#453](https://github.com/zackbart/connecta/issues/453) requires. |
311
+ | One tool call (`invocation.ts`) | One fiber: resolution, the read-only and schema refusals, admission, and the downstream attempt sit under a single `withDeadlineEffect`, whose expiry interrupts the call wherever it is. The permit is an `acquireRelease`; the connector call is `Effect.tryPromise` over the unchanged `Connector`. |
312
+ | `execute_code` (`execute.ts`) | One fiber whose Scope owns the run's signal and executor lease, so a result, a throw, the watchdog, and cancellation all release the lease and abort the signal the same way. The executor's `acquire()` and `execute()` stay Promises raced against the signal and `execute.watchdogMs`. Each guest host call is a fiber of its own. |
313
+ | Discovery (`catalog-service.ts`) | A request-scoped cache: one Deferred per connector, completed by the first asker's read from that read's own settlement, so a probe deadline ends one asker's wait and never the read the others share. Fan-out is `Effect.forEach` under the discovery concurrency. |
314
+ | Registry (`registry.ts`) | Catalog persistence and the result stash are programs over `Storage`. A refresh flight is a Deferred its publishing request completes; persisted-catalog writes take per-connector turns, each a Deferred its own request completes. |
315
+ | Remote MCP (`connectors/remote-mcp.ts`) | Each request scope's state holds a Scope, each connection is a lease forked from it, and a connect in flight is a Deferred carrying the client it connected. Closing a session and the transport are each bounded to a second. |
316
+ | Downstream OAuth (`auth/downstream-oauth.ts`) | A refresh flight is a Deferred; the owner's redemption is a fiber its abort interrupts, and committing an answer that already exists is uninterruptible. |
317
+ | Operator and activity data (`routes/operator.ts`) | Each JSON route is one program run by `serveOperator` behind the Promise `handle()`. Reads run under the request's signal; writes do not, so a vault write or OAuth disconnect that started reaches its cache invalidation. |
318
+ | QuickJS pool (`executors/quickjs.ts`, Node only) | Each child is a scoped resource whose release sends SIGTERM, then SIGKILL after a second; crash respawn backoff is a `Schedule`. |
319
+
320
+ ### What stays plain
321
+
322
+ - **Everything a deployment writes against.** The `Connector` contract,
323
+ `api()`, the providers, custom connectors, storage adapters, and the vault
324
+ are Promises. A connector is called with `Effect.tryPromise`, never asked to
325
+ return an Effect.
326
+ - **Inbound authorization.** `authorize` in `src/routes/shared.ts` ships in
327
+ the declarations and in the `./activity` bundle; converting it cost 32 KB
328
+ gzip there for nothing a caller could see.
329
+ - **Pure synchronous code.** Ranking, schema rendering, classification,
330
+ `validateToolInput`, the host-call budget, the emit collector, failure
331
+ framing, and result shaping. The microtask scheduler never yields to I/O, so
332
+ wrapping them would buy nothing and could starve a request.
333
+ - **The MCP edges.** `@modelcontextprotocol/server` upward and the SDK client
334
+ downstream; Effect's own `McpServer` is not used.
335
+ - **The Node and browser leaves.** `node.ts`, `fileStorage`, the QuickJS
336
+ child, runtime, and protocol, and the operator UI's browser app.
337
+
338
+ ### Across requests
339
+
340
+ Work shared across requests meets only through a Deferred that the owning
341
+ request completes, never through a fiber that outlives its request. Two
342
+ workerd rules follow, and both have bitten.
343
+
344
+ **Waiting on a shared Deferred is its own edge.** Deferred resumes a waiting
345
+ fiber synchronously, inside the call that completed it, which on Workers means
346
+ inside the completing request's I/O context. A waiter that goes on to touch its
347
+ own body, transport, or storage there fails with "Cannot perform I/O on behalf
348
+ of a different request", or hangs until its deadline. So the wait runs through
349
+ `runEdge`, and the waiter does its own I/O after awaiting that promise, which
350
+ workerd resumes in the waiter's request as it does any promise resolved from
351
+ another. The same holds one level out for every queue: a queued call, a queued
352
+ `execute_code`, and a queued `/mcp` request are each handed their permit from
353
+ inside the request that released one, so each awaits the controller's
354
+ Promise-shaped `acquire` rather than yielding its Effect program, and does its
355
+ own work only once that promise resolves. Effect's `Semaphore` is not used
356
+ across requests for the same reason, and because a same-tick newcomer can take
357
+ a released permit ahead of a waiter that queued earlier.
358
+
359
+ **Never read another request's signal.** On workerd, reading `aborted` or
360
+ `reason`, or calling `abort()`, on a signal that belongs to another request
361
+ throws; adding and removing listeners does not. Code that runs from another
362
+ request's completion — a queue handing out a permit, `close()` — must not look
363
+ at a waiter's signal. A waiter whose signal aborts leaves the queue from its
364
+ own listener, in its own request.
365
+
366
+ One corollary is easy to break by tidying. Workerd cancels a request as hung
367
+ when its only pending work is waiting on another request and it has no timer or
368
+ I/O of its own. Connecta's deadline timers are what keep a waiting request
369
+ alive and bounded, so no wait may lose its timer.
370
+
371
+ ### Why not Schema or HttpApi
372
+
373
+ Both were measured and both lost. `effect` is imported through its barrel,
374
+ which defeats tree-shaking: the first conversion step measured about +20 ms
375
+ of Worker cold start for it, and that cost was accepted over deep imports at
376
+ every call site. The catch is that a module the barrel reaches is paid for in
377
+ full, however little of it is used. Effect Schema for config validation and the
378
+ meta-tool inputs came to about +62 KB gzip and +13 ms of cold start in the
379
+ root, for a validator no clearer than the hand-written `CONFIG_SCHEMA`; the
380
+ meta-tool inputs stay zod, which the MCP SDK bundles regardless. `HttpApi` for
381
+ the operator and activity routes cost about +100 KB gzip on `./ui` and +130 KB
382
+ on `./activity`, and matching the wire format meant opting out of most of what
383
+ it does: unowned paths fall through rather than 404, a wrong method is a JSON
384
+ 405, and the content-type and size checks run before a body is read. The root
385
+ entry may import only `effect` itself (`test/purity.test.ts`); nothing in
386
+ `src/` uses `effect/unstable/*`, which would have to stay behind a subpath.
387
+
388
+ The core's cost is recorded rather than guessed. Against 0.24.4 the root entry
389
+ grew from 235,346 to 279,526 bytes gzip, the Worker example from 263,949 to
390
+ 314,336, and each provider by about 36 KB, since each reaches Effect through
391
+ `remoteMcp()`; `npm run check:bundle` caps every entry against
392
+ `scripts/bundle-budget.json`. An install carries about 53 MB of `effect`, with
393
+ no dependencies of its own.
394
+
221
395
  ## Where else to look
222
396
 
223
397
  Beyond the modules already named:
@@ -250,7 +424,7 @@ compiling and configuring the real thing.
250
424
  `OPTIONS` opts it into CORS preflight; reordering admission after auth makes
251
425
  the cheapest possible attack the most expensive request.
252
426
  - **`close()` is idempotent and ordered.** Both admission pools, then the
253
- connector limiters, then the executor; Node's `listen()` calls it on
427
+ connector limiters, then the executor, then the Connecta's runtime; Node's `listen()` calls it on
254
428
  SIGTERM/SIGINT.
255
429
  - **Structural mistakes throw at construction.** A duplicate connector id, an
256
430
  invalid admission rule, the removed `accessTokens` option, a missing executor:
@@ -149,6 +149,86 @@ partitions. Literal `auth: { type: "headers" }` cannot be personal, because its
149
149
  secret lives in deployment code; `remoteMcp()` refuses that combination at
150
150
  construction.
151
151
 
152
+ ## Downstream OAuth state at rest
153
+
154
+ With a vault configured, a `remoteMcp()` OAuth connector's tokens, dynamically
155
+ registered client (secret included), and PKCE verifier are sealed with the
156
+ vault's AES-GCM key before they reach storage. The additional authenticated data
157
+ names the connector, the owner partition, and the physical key, which carries
158
+ the authorization epoch, so ciphertext copied to another connector, principal,
159
+ or epoch does not open. Anything that fails to open — a tampered value, a
160
+ rotated key — reads as absent: the connector reports `auth_required` and logs a
161
+ warning. The flow bookkeeping stays plaintext: `state`, the pending URL,
162
+ discovery metadata, and the generation. The callback route reads `state`
163
+ directly, and none of it authenticates anything by itself.
164
+
165
+ Plaintext left by an older release is read, then sealed where it lies through
166
+ the same generation fence as any write, so an upgrade keeps the grant. Sealing
167
+ is one-way. An older release reads sealed state as unusable, so rolling back
168
+ means authorizing again. A vault without the optional `seal`/`open` members
169
+ keeps these values plaintext and draws a startup warning. Without any vault,
170
+ nothing changes: the state is plaintext, as it always was.
171
+
172
+ ## Refresh failures
173
+
174
+ A failed token refresh means one of two things, and connecta decides which
175
+ from the token endpoint's answer, not the SDK's reading of it. A **dead grant**
176
+ is any 4xx except 408, 425, and 429, or a 2xx carrying an OAuth `error` (GitHub
177
+ answers `200 {"error":"bad_refresh_token"}`). It ends as `auth_required`, and
178
+ the refused tokens are deleted on the spot, so no later request or isolate
179
+ sends them again and the next `authorize_connector` goes straight to consent.
180
+ An **outage** is a 5xx, 408, 425, 429, a network failure, or a 2xx that is not
181
+ a token response. It ends as retryable `unavailable`, or `rate_limited` for a
182
+ 429, with `retryAfterMs` when the server sent `Retry-After`. The grant is kept,
183
+ and a passive call writes no consent URL. Every request joined on the same
184
+ in-flight refresh gets the same verdict, even when the request that sent the
185
+ refresh is cancelled after the answer arrives: the refused tokens are deleted
186
+ before anyone waiting is released, so a request arriving meanwhile joins the
187
+ refusal instead of sending the dead token again.
188
+
189
+ The SDK needs this help. On a refresh failure it cannot parse, or one marked
190
+ `server_error`, it falls through to starting authorization, which reports a
191
+ healthy grant as needing consent. It rethrows every other OAuth error
192
+ untouched, which reports a dead grant as a generic outage and keeps re-sending
193
+ it. So the refresh coordinator hands the SDK an answer it classifies correctly,
194
+ and the provider hooks it calls next finish the job. The pinned SDK behavior is
195
+ spelled out beside `refreshResponseOutcome`. An explicit authorization during
196
+ an outage still falls through to consent, since that is what it asked for.
197
+
198
+ ## URLs a downstream advertises
199
+
200
+ A `remoteMcp()` OAuth connector learns most of the URLs it fetches from the
201
+ downstream itself: the `resource_metadata` in its 401 challenge, the
202
+ `authorization_servers` in that metadata, and the token and registration
203
+ endpoints the authorization server publishes. Connecta fetches every one of
204
+ them server-side, so a compromised downstream could otherwise aim a Node or
205
+ Docker host at its own network — cloud metadata at `169.254.169.254`, an admin
206
+ panel on the LAN. Config is the security model, so the rule splits on it:
207
+
208
+ - A URL on the connector's configured origin is trusted. The operator wrote it
209
+ down.
210
+ - Any other URL must be `https` and must not name a private host: `localhost`
211
+ and `*.localhost`, `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`,
212
+ `100.64/10`, `0/8`, `::`, `::1`, `fc00::/7`, `fe80::/10`, or an IPv4-mapped
213
+ IPv6 form of any of those.
214
+ - A connector configured on a loopback host may also learn loopback URLs, over
215
+ `http` too, so a local MCP server with a local authorization server keeps
216
+ working. LAN and link-local addresses stay refused.
217
+
218
+ A refused URL is never requested. Discovery, registration, and code exchange
219
+ fail with a non-retryable `connector_call_failed` that names the host and
220
+ nothing else from the URL. A refused refresh never reaches the token endpoint,
221
+ so there is no verdict to act on: the grant is kept, and the SDK falls through
222
+ to consent as it does for any refresh it could not complete. Redirects cannot
223
+ route around the rule, because the redirect policy only follows same-origin
224
+ hops.
225
+
226
+ The check is syntactic. It reads the host after the WHATWG URL parser has
227
+ folded `2130706433` and `0x7f.1` into `127.0.0.1`, and it never resolves a
228
+ name, because the Workers-safe core has no DNS. A public name that resolves to
229
+ a private address, or rebinds to one after the check, is out of scope; a host
230
+ that must stop that runs connecta behind an egress policy that does.
231
+
152
232
  ## Management permissions
153
233
 
154
234
  Visibility alone grants no authentication-management permission. Two independent
@@ -90,7 +90,9 @@ Connecta passes exactly one provider, named `connecta`. An executor must:
90
90
  5. **Capture `console.log`, `console.warn`, and `console.error`** into `logs` in
91
91
  call order (`R5`), bounding what it retains.
92
92
  6. **Bound the guest**: wall clock, memory, stack, and CPU (`L3`, `L5`), staying
93
- inside the tested `P2`/`X5` boundary.
93
+ inside the tested `P2`/`X5` boundary. Settle `execute()` on your own
94
+ deadline: connecta stops waiting at `execute.watchdogMs` and reports the
95
+ sandbox unresponsive (`L3`).
94
96
  7. **Grant no ambient authority of its own.** Never back this with `eval` or
95
97
  `node:vm`: the sandbox is a containment layer on top of connecta's boundary,
96
98
  not a replacement for it, and every capability arrives through `fns`.
@@ -106,10 +108,15 @@ is just another provider function (`M8`).
106
108
  ## The program
107
109
 
108
110
  **P1.** A program is one JavaScript `async` arrow-function expression, evaluated
109
- once; its resolved value is the result. Both executors also accept
110
- markdown-fenced code and a bare statement body, normalizing those differently —
111
- a courtesy to model output, not contract, so anything else may be accepted,
112
- rejected, or reinterpreted.
111
+ once; its resolved value is the result. The expression may end with a `;`, and
112
+ with whitespace or comments after it: `execute_code` drops those terminators
113
+ before either executor sees the program, because both evaluate it inside
114
+ parentheses, where `};` was a syntax error on QuickJS and on a Dynamic Worker
115
+ either the same error or a program that quietly returned `undefined`. Only
116
+ semicolons that trail the expression go; `async () => 1; 2` is still more than
117
+ one expression. Both executors also accept markdown-fenced code and a bare
118
+ statement body, normalizing those differently — a courtesy to model output,
119
+ not contract, so anything else may be accepted, rejected, or reinterpreted.
113
120
 
114
121
  **P2.** The only capabilities in the contract are `connecta.search`,
115
122
  `connecta.describe`, `connecta.call`, `connecta.emit`, and `console.log` /
@@ -129,7 +136,9 @@ storage carried to the next program, and no request-bound object outliving its
129
136
  request. Within one execution, host calls share one downstream request scope.
130
137
  `S9`'s output observation is host-owned catalog metadata, not guest memory — a
131
138
  later program receives a labeled field/type schema through discovery, never a
132
- prior value or object.
139
+ prior value or object. Planned resumable writes
140
+ ([ethos](../ethos.md#decisions)) will let a paused run's journal survive,
141
+ bounded and expiring; the program still gets no state of its own.
133
142
 
134
143
  **P5.** Plain JavaScript only; TypeScript syntax is a syntax error. Portable
135
144
  code does not import: QuickJS blocks imports, Dynamic Workers expose the `X5`
@@ -164,7 +173,7 @@ const page = await connecta.search({
164
173
  limit: 8, // 1–100, default 8
165
174
  offset: 0,
166
175
  fullDescriptions: false,
167
- includeSchemas: "compact", // or "json"
176
+ includeSchemas: "compact", // or "json" / "typescript"
168
177
  includeSchemaKeys: true, // default true in code mode
169
178
  });
170
179
  ```
@@ -211,6 +220,18 @@ truncation flag; `$dynamicRef` resolves a same-named definition like `$ref`, and
211
220
  an unresolved one becomes `unknown` with the flag. For omitted exact constraints
212
221
  use `format: "json"` or JSON search.
213
222
 
223
+ `includeSchemas: "typescript"` spends those same bounds on a different
224
+ rendering: one `signature` string in place of `inputSchema` and
225
+ `outputSchema`, reading `(args: { runId: number }) => Promise<Run>` for the
226
+ value `connecta.call` returns. It is documentation for the program's author —
227
+ the program itself is still JavaScript, and a type annotation copied into it
228
+ is a syntax error (`P5`). Truncation keeps compact's flags and
229
+ turns what compact would print as a name or raw JSON into a marked `unknown`;
230
+ an observed output opens with `/* observed, not declared */`. The dialect and
231
+ its degradation rules are specified once, under
232
+ [TypeScript signatures](./meta-tools.md#typescript-signatures), and the
233
+ signature is byte-identical to the one `search_tools` returns.
234
+
214
235
  **S1a.** `connector` loads only the named catalog; omit it only when the
215
236
  integration is ambiguous, because an unscoped search fans out across every
216
237
  configured connector. `safety: "readOnly"` returns exactly the tools available
@@ -249,7 +270,7 @@ hint and the stable `code`, `retryable`, and `details` fields (`E1`).
249
270
  const one = await connecta.describe({ address: "ci.get_run" });
250
271
  const many = await connecta.describe({
251
272
  addresses: ["ci.get_run", "ci.get_job_logs"], // ≤ 100
252
- format: "compact", // or "json"
273
+ format: "compact", // or "json" / "typescript"
253
274
  fullDescriptions: false,
254
275
  });
255
276
  ```
@@ -265,8 +286,11 @@ canonical addresses. More than 100 addresses is `invalid_args`, and the same
265
286
  256,000-byte ceiling applies. Compact describe keeps property prose within a
266
287
  separate 8,192-byte UTF-8 shape cap and shares search's 2,000-visit budget; a
267
288
  capped shape sets `inputSchemaTruncated` or `outputSchemaTruncated`, and
268
- `format: "json"` gives the exact schema. A success whose output shape came from
269
- `S9` carries `outputSchemaSource: "observed"`.
289
+ `format: "json"` gives the exact schema. `format: "typescript"` returns a
290
+ `signature` instead of both schema fields, under the same 8,192-byte cap per
291
+ half with property prose as JSDoc. A success whose output shape came from
292
+ `S9` carries `outputSchemaSource: "observed"`, and a TypeScript signature also
293
+ says so inside its `Promise<…>`.
270
294
 
271
295
  ### connecta.call
272
296
 
@@ -385,12 +409,15 @@ and let the failure reach the model.
385
409
  to `call_destructive_tool`, plus the original arguments when they fit the
386
410
  512-byte echo budget — whole or not at all, since a clipped copy is a different
387
411
  call. The model's short `reason` for the human reviewer grants no authority,
388
- never goes downstream, and generated code cannot mint the capability.
412
+ never goes downstream, and generated code cannot mint the capability. Planned
413
+ resumable writes ([ethos](../ethos.md#decisions)) replace this refusal with a
414
+ pause the host approves; until they ship, it stands as written.
389
415
 
390
416
  **E5.** Failures of the *execution*, not of a call, never appear inside the
391
417
  guest: admission rejection (`executor_overloaded`, retryable, with
392
418
  `retryAfterMs`), cancellation (`executor_cancelled`), shutdown
393
- (`executor_closed`), deadline expiry, and sandbox crashes end the run and reach
419
+ (`executor_closed`), deadline expiry, an executor that never settles (`L3`),
420
+ and sandbox crashes end the run and reach
394
421
  the model as an error result. One seam: a host call still in flight when the run
395
422
  is cancelled fails with `cancelled`, catchable on the way out but never worth
396
423
  acting on (`Y3`). When shutdown tears down a program that had already started,
@@ -582,12 +609,25 @@ block runs — a cancelled QuickJS child is terminated outright. Write programs
582
609
  that need no cleanup.
583
610
 
584
611
  **L2.** What cancellation guarantees: in-flight host calls abort, no further
585
- host call is admitted, the admission lease is released, and nothing
586
- request-bound survives the request.
612
+ host call is admitted — `search` and `describe` included, which fail
613
+ `cancelled` rather than go on loading catalogs — the response returns
614
+ `executor_cancelled` without waiting for the executor to settle, or for an
615
+ `acquire()` that ignores the signal to grant, the admission lease is released
616
+ (a lease granted after the run gave up is released on arrival), and nothing
617
+ request-bound survives the request. Whether the program itself stops is the
618
+ executor's (`X3`).
587
619
 
588
620
  **L3.** Every execution runs under a wall-clock deadline that includes time
589
621
  spent waiting on host calls. Expiry ends the run with an execution error and no
590
- partial result; the deadline's length is executor configuration (`X1`).
622
+ partial result; the deadline's length is executor configuration (`X1`). Above
623
+ it sits a ceiling connecta enforces outside the sandbox, `execute.watchdogMs`
624
+ (120 s by default, above both executors' own deadlines), because an executor's
625
+ deadline may live inside the sandbox it is meant to stop: a wedged Dynamic
626
+ Worker never fires its in-isolate timer. A run whose executor has not settled
627
+ by the ceiling ends as a non-retryable `executor_failed` whose message calls
628
+ the sandbox unresponsive, with no partial result, and its admission lease is
629
+ released, so wedged runs cannot fill the code pool. Raising an executor's
630
+ deadline past the ceiling means raising the ceiling too.
591
631
 
592
632
  **L4.** Per-execution bounds that are contract, identical in both executors
593
633
  because connecta enforces them above the sandbox:
@@ -688,10 +728,15 @@ and guest-CPU limits (`L5`); the Dynamic Worker has no such knobs, so workerd's
688
728
  isolate limits apply untuned. A specific heap ceiling is a Node-only option.
689
729
 
690
730
  **X3. Mid-flight cancellation.** The QuickJS pool receives the request's
691
- `AbortSignal` and kills the child. The Dynamic Worker executor's `execute()`
692
- takes no signal, so a cancelled request's program runs on until its host calls
693
- fail or the deadline expires. `L2` holds either way — the calls abort, the
694
- response does not wait — but "the run ends" is best-effort on Workers.
731
+ `AbortSignal` and kills the child — SIGTERM, then SIGKILL if it has not exited
732
+ within a second — and releasing a lease whose child is still running recycles
733
+ that child, so a run the watchdog abandons (`L3`) ends too.
734
+ The Dynamic Worker executor's `execute()` takes no signal, so a cancelled or
735
+ abandoned program runs on in its isolate until its host calls fail or its own
736
+ deadline expires. `L2` and `L3` hold either way — the calls abort, connecta
737
+ stops awaiting the executor and frees the admission slot — but "the run ends"
738
+ is best-effort on Workers, and for that tail the code pool bounds what
739
+ connecta is waiting on, not what the platform is still running.
695
740
 
696
741
  **X4. Log rendering and capture.** QuickJS JSON-stringifies non-string arguments
697
742
  and captures `log`, `info`, `warn`, `error`, and `debug`; the Dynamic Worker
@@ -776,12 +821,12 @@ passing one table is also the check on the executor duties above, with
776
821
 
777
822
  | Clauses | Test |
778
823
  | --- | --- |
779
- | `P1`, `P5` | `test/guest-api-contract.test.ts` (TypeScript syntax), `test/quickjs-executor.test.ts` (`normalizeCode`) |
824
+ | `P1`, `P5` | `test/guest-api-contract.test.ts` (TypeScript syntax, trailing terminators), `test/program-source.test.ts` (which semicolons are terminators), `test/quickjs-executor.test.ts` (`normalizeCode`) |
780
825
  | `P2`, `X5` | `test/guest-api-contract.test.ts` (Dynamic globals plus loader-only filesystem, HTTP, environment, egress, DNS, and local `data:` boundaries), `test/guest-api-contract-quickjs.test.ts` (exact absent globals and blocked imports), `test/quickjs-child-stderr.test.ts` (empty child-process environment), `test/deployment-shapes.test.ts` (loader-only Worker construction) |
781
826
  | `P3`, `X9` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` |
782
827
  | `P4` | `test/guest-api-contract.test.ts` (no cross-run leakage), `test/execute.test.ts` (one catalog load per connector per execution) |
783
828
  | `A1`, `A2` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (canonical addressing), `test/server.test.ts` (bounded live connector inventory) |
784
- | `S1`, `S1a`, `S2` | `test/guest-api-contract.test.ts` (flat page, connector guides, schema keys, unfiltered browse), `test/execute.test.ts` (guide pagination/partial/no-match behavior and `$ref`/`allOf`), `test/meta-tools-search.test.ts` (mixed complete/partial ranking, stable pagination, the two safety classes) |
829
+ | `S1`, `S1a`, `S2` | `test/guest-api-contract.test.ts` (flat page, connector guides, schema keys, unfiltered browse), `test/execute.test.ts` (guide pagination/partial/no-match behavior and `$ref`/`allOf`), `test/meta-tools-search.test.ts` (mixed complete/partial ranking, stable pagination, the two safety classes), `test/typescript-signatures.test.ts` (the TypeScript format over provider and pathological schemas, search/describe parity, observed labeling), `test/typescript-signatures-parse.test.ts` (every rendering parses) |
785
830
  | `S3` | `test/guest-api-contract.test.ts` (typed uncaught bound), `test/execute.test.ts` (count limits, fan-out bound) |
786
831
  | `S4` | both guest-contract executors (ordered mixed describe results with unknown-address, unknown-tool suggestion, and catalog-failure details), `test/meta-tools-search.test.ts` (top-level routing, no-suggestion, catalog-failure, and hostile-input bounds) |
787
832
  | `S5`, `S6` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`unwrapMcpResult`, fail-closed annotations, activity parity) |
@@ -799,8 +844,8 @@ passing one table is also the check on the executor duties above, with
799
844
  | `R6`–`R8` | `test/guest-api-contract.test.ts` (normal result keys), `test/execute.test.ts` (opt-in operation aggregates, failure paths, payload exclusion) |
800
845
  | `Y1`, `Y2`, `Y3` | `test/guest-api-contract.test.ts` (one attempt per call, retryable flags by code) |
801
846
  | `Y4` | `test/meta-tools-call.test.ts`, `test/call-admission.test.ts` (one attempt, retry hints, caller reissue) |
802
- | `L1`, `L2` | `test/guest-api-contract.test.ts` (in-flight call fails `cancelled`), `test/execute.test.ts` (cancels outstanding host calls) |
803
- | `L3`, `X1` | `test/guest-api-contract.test.ts` (short-deadline executors) |
847
+ | `L1`, `L2` | `test/guest-api-contract.test.ts` (in-flight call fails `cancelled`), `test/execute.test.ts` (cancels outstanding host calls, discovery included, and refuses discovery after the run; a cancelled wedged executor, or one whose `acquire()` ignores the signal, returns promptly and releases its lease) |
848
+ | `L3`, `X1` | `test/guest-api-contract.test.ts` (short-deadline executors), `test/execute.test.ts` (the watchdog ends a never-settling executor, frees the default pool, spares a slow run, and falls back from an unusable value) |
804
849
  | `L4`, `L8` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (shared discovery/call budgets) |
805
850
  | `L5`, `L7`, `X2` | `test/quickjs-executor.test.ts` (CPU, heap), `test/execute.test.ts` and `test/executor-admission.test.ts` (bounded admission and queue) |
806
851
  | `L6`, `X10` | `test/quickjs-executor.test.ts` (bridge and IPC bounds for arguments and result; the address in the over-bound message), `test/quickjs-child-stderr.test.ts` (outer reply serialization failure settles the call) |
@@ -812,7 +857,7 @@ passing one table is also the check on the executor duties above, with
812
857
  | `M8` | two arms passing one case table, `test/codemode-compat.test.ts` |
813
858
  | `M10` | `test/execute-emit.test.ts` (aggregate present, numbers only, absent when nothing emitted) |
814
859
  | `X4` | `test/guest-api-contract.test.ts` (string logs only), `test/quickjs-executor.test.ts` (logs before cancellation), `test/quickjs-child-stderr.test.ts` (crash, shutdown, deadline, IPC failure, bounded parent retention), `test/quickjs-log-limits.test.ts` (unchanged successful logs) |
815
- | `X3`, `X6` | `test/quickjs-executor.test.ts` (cancels a running child, never-settling await) |
860
+ | `X3`, `X6` | `test/quickjs-executor.test.ts` (cancels a running child, never-settling await), `test/execute.test.ts` (a wedged executor stops being awaited) |
816
861
  | `X7` | `P3`'s tests; the Workers superset is deliberately unused |
817
862
 
818
863
  The surface itself is checked by `test/server.test.ts` (the exact seven-tool