@zackbart/connecta 0.24.2 → 0.24.4

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 (97) hide show
  1. package/AGENTS.md +18 -20
  2. package/CHANGELOG.md +205 -1
  3. package/README.md +5 -6
  4. package/dist/auth/bearer.js +2 -0
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/branding.d.ts +31 -2
  8. package/dist/branding.js +116 -8
  9. package/dist/call-admission.d.ts +4 -0
  10. package/dist/call-admission.js +26 -0
  11. package/dist/catalog-drift.js +9 -4
  12. package/dist/catalog-service.d.ts +2 -0
  13. package/dist/catalog-service.js +25 -8
  14. package/dist/catalog.d.ts +2 -0
  15. package/dist/catalog.js +246 -121
  16. package/dist/connectors/api.d.ts +1 -1
  17. package/dist/connectors/api.js +21 -3
  18. package/dist/connectors/guarded-fetch.d.ts +6 -2
  19. package/dist/connectors/guarded-fetch.js +61 -24
  20. package/dist/connectors/remote-mcp.js +92 -57
  21. package/dist/errors.d.ts +28 -3
  22. package/dist/errors.js +60 -1
  23. package/dist/execute.js +85 -23
  24. package/dist/executor-result.js +3 -1
  25. package/dist/executors/quickjs-child.js +5 -1
  26. package/dist/executors/quickjs-protocol.d.ts +4 -0
  27. package/dist/executors/quickjs-runtime.d.ts +1 -1
  28. package/dist/executors/quickjs-runtime.js +38 -21
  29. package/dist/executors/quickjs.js +68 -27
  30. package/dist/index.d.ts +15 -1
  31. package/dist/index.js +36 -4
  32. package/dist/invocation.js +134 -93
  33. package/dist/mcp-result.js +3 -2
  34. package/dist/meta-tools.js +191 -36
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +38 -1
  37. package/dist/operator-ui/view.js +71 -0
  38. package/dist/providers/cloudflare.d.ts +14 -2
  39. package/dist/providers/cloudflare.js +107 -16
  40. package/dist/providers/linear.d.ts +26 -4
  41. package/dist/providers/linear.js +19 -4
  42. package/dist/providers/mixpanel.d.ts +16 -3
  43. package/dist/providers/mixpanel.js +13 -2
  44. package/dist/providers/notion.d.ts +8 -1
  45. package/dist/providers/notion.js +83 -10
  46. package/dist/providers/revenuecat.d.ts +30 -4
  47. package/dist/providers/revenuecat.js +42 -4
  48. package/dist/providers/stripe.d.ts +7 -1
  49. package/dist/providers/stripe.js +30 -4
  50. package/dist/providers/vercel.js +11 -1
  51. package/dist/registry.d.ts +22 -2
  52. package/dist/registry.js +101 -13
  53. package/dist/routes/mcp.d.ts +4 -1
  54. package/dist/routes/mcp.js +84 -13
  55. package/dist/routes/oauth.js +4 -0
  56. package/dist/routes/shared.d.ts +1 -0
  57. package/dist/routes/shared.js +4 -4
  58. package/dist/server.js +15 -3
  59. package/dist/skills.js +6 -5
  60. package/dist/storage/file.d.ts +6 -2
  61. package/dist/storage/file.js +312 -34
  62. package/dist/storage/memory.js +12 -1
  63. package/dist/types.d.ts +37 -0
  64. package/dist/ui.js +18 -10
  65. package/dist/validate.js +3 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/documentation/architecture.md +193 -165
  69. package/documentation/auth.md +199 -145
  70. package/documentation/code-mode.md +433 -316
  71. package/documentation/meta-tools.md +363 -355
  72. package/examples/worker/AGENTS.md +2 -1
  73. package/examples/worker/README.md +12 -10
  74. package/examples/worker/src/index.ts +12 -15
  75. package/package.json +1 -2
  76. package/templates/node/.env.example +3 -3
  77. package/templates/node/AGENTS.md +5 -4
  78. package/templates/node/README.md +2 -1
  79. package/templates/node/package.json +1 -1
  80. package/templates/node/src/index.ts +23 -22
  81. package/documentation/call-admission.md +0 -142
  82. package/documentation/cloudflare.md +0 -471
  83. package/documentation/connector-guides.md +0 -176
  84. package/documentation/connectors.md +0 -389
  85. package/documentation/linear.md +0 -193
  86. package/documentation/mixpanel.md +0 -160
  87. package/documentation/notion.md +0 -308
  88. package/documentation/operations.md +0 -359
  89. package/documentation/operator-ui.md +0 -135
  90. package/documentation/optional-modules-upgrade.md +0 -243
  91. package/documentation/provider-conventions.md +0 -722
  92. package/documentation/request-admission.md +0 -170
  93. package/documentation/revenuecat.md +0 -305
  94. package/documentation/storage-and-credentials.md +0 -201
  95. package/documentation/stripe.md +0 -262
  96. package/documentation/upgrading.md +0 -754
  97. package/documentation/vercel.md +0 -241
package/AGENTS.md CHANGED
@@ -32,8 +32,8 @@ Cloudflare Workers.
32
32
  `check:operator-ui` → `check:lint` → `check:unused` → `typecheck` → `test`
33
33
  (both vitest projects) → `build` → `check:examples`. It is also the `prepack`
34
34
  hook. `npm run release:check` adds `check:security` and `check:package` and is
35
- what CI runs on publish; use it when touching packaging, dependencies, or
36
- exports.
35
+ what CI runs on every pull request, on `main`, and again on publish; use it
36
+ when touching packaging, dependencies, or exports.
37
37
 
38
38
  ## The map
39
39
 
@@ -41,13 +41,13 @@ exports.
41
41
  decisions table, and the invariants every change must preserve. Check it
42
42
  before building something new; "we already decided not to" is a real answer
43
43
  there, and its removed/provisional verdicts override anything staler.
44
- - [`documentation/`](./documentation/) — per-subsystem guides for agents
45
- working on the repo. All of them are written now
46
- ([#348](https://github.com/zackbart/connecta/issues/348)); the retired manual
47
- (`docs/<name>.md`) survives only in git history, which is worth mining for
48
- rationale a rewrite dropped and worth nothing where it disagrees with
49
- `ethos.md`. Start with `architecture.md`, then the guide for the subsystem
50
- you are changing.
44
+ - [`documentation/`](./documentation/) — four guides for agents working on the
45
+ repo: `architecture.md`, `meta-tools.md`, `code-mode.md`, and `auth.md`.
46
+ Everything else — connectors, providers, admission, storage, the operator
47
+ UI, operations is documented where it lives, in the source and its tests.
48
+ The guides that once covered those survive only in git history, which is
49
+ worth mining for rationale and worth nothing where it disagrees with
50
+ `ethos.md` or the code. Start with `architecture.md`.
51
51
  - [`README.md`](./README.md) — the human-facing overview.
52
52
  - [`templates/node/`](./templates/node/) — the one standalone Node deployment
53
53
  shape copied by `connecta init`, Docker-ready rather than Docker-only. Keep
@@ -56,13 +56,14 @@ exports.
56
56
  diff away from one of them is the shape
57
57
  [#344](https://github.com/zackbart/connecta/issues/344) deleted.
58
58
 
59
- **Read `ethos.md` and the subsystem's guide before changing a subsystem.**
59
+ **Read `ethos.md` before changing a subsystem, and its guide when it has one.**
60
60
 
61
61
  ## Deployment setup
62
62
 
63
63
  `connecta init [directory]` is the golden path. It copies `templates/node/`,
64
64
  pins the generated deployment to the CLI package's exact version, restores the
65
- template `.gitignore`, and refuses to merge into an existing path.
65
+ template `.gitignore` and the `CLAUDE.md` symlink, and refuses to merge into an
66
+ existing path.
66
67
  `connecta doctor` verifies a running deployment's health, executor, and exact
67
68
  seven-tool surface. The template carries its own `Dockerfile` and
68
69
  `docker-compose.yml`, so the generated project is the container: setup changes
@@ -96,8 +97,9 @@ Two boundaries CI enforces that are not obvious from reading a file:
96
97
  forbidden is a platform-bound adapter becoming importable from the package,
97
98
  not a file appearing in the artifact. `@clerk/backend` and
98
99
  `quickjs-emscripten` are optional peers behind the `./auth/clerk` and
99
- `./quickjs` subpaths and must never become dependencies or install with
100
- core. Enforced by `test/package-surface.test.ts` and
100
+ `./quickjs` subpaths, and `@cloudflare/codemode` is the third, peered
101
+ without a subpath because a Worker deployment imports it directly. None may
102
+ become a dependency or install with core. Enforced by `test/package-surface.test.ts` and
101
103
  `scripts/check-package.mjs`. Anything heavyweight or platform-bound gets a
102
104
  subpath and an optional peer.
103
105
 
@@ -108,10 +110,7 @@ Every `*.test.ts` belongs to exactly one explicit list: runtime-portable suites
108
110
  in `WORKERS_SUITES`, Node-bound suites in `NODE_ONLY_SUITES` with a reason. The
109
111
  `node` project runs both lists; the `workers` project re-runs the portable list
110
112
  inside workerd. `test/suite-partition.test.ts` walks the directory and fails on
111
- an unclassified, double-classified, stale, or reasonless entry. New behavior
112
- also gets a row in the test map in `documentation/operations.md`, which
113
- classifies every suite — one missing from it is either new and undocumented or
114
- dead, and neither is a state to leave the repository in.
113
+ an unclassified, double-classified, stale, or reasonless entry.
115
114
 
116
115
  ## Conventions
117
116
 
@@ -132,9 +131,8 @@ dead, and neither is a state to leave the repository in.
132
131
  documentation and OpenAPI contracts and never reads a provider credential.
133
132
  Remote MCP schemas remain owned by the live `tools/list` response rather than
134
133
  a vendored copy. Findings are read by a human and become GitHub issues;
135
- nothing files itself.
136
- See
137
- [`documentation/provider-conventions.md`](./documentation/provider-conventions.md#the-maintainer-run-drift-check).
134
+ nothing files itself. `scripts/drift-check.mjs` is the reference for what
135
+ each flag covers.
138
136
  - **Releases.** `npm run release:check`, tag `v<version>` matching
139
137
  `package.json` exactly (the publish workflow verifies this and fails
140
138
  otherwise), and publishing fires on GitHub **Release publication**, not on the
package/CHANGELOG.md CHANGED
@@ -2,6 +2,205 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.24.4 — 2026-09-17
6
+
7
+ The operator UI is the release. Its connections page is now a summary line and
8
+ one row per connector instead of a wall of expanded cards, and the stylesheet
9
+ behind it resolves through design tokens a deployment can set with the new
10
+ `branding.theme`. Nothing a deployment configures today changes meaning, no
11
+ storage format moved, and the page shows exactly what it showed before under
12
+ the same gates — an operator who has the page bookmarked will find it reads
13
+ differently, and that is the whole of the upgrade. The documentation cut to
14
+ four guides lands here too, along with one execution-path fix.
15
+
16
+ ### Added
17
+
18
+ - **`branding.theme`.** Five tokens — `accent`, `radius`, `fontFamily`,
19
+ `monoFamily`, and `colorScheme` — set on `operatorUi({ branding })`. Every
20
+ other color on the operator page is mixed from those, so setting one accent
21
+ themes the page rather than leaving half of it on the defaults. Light and
22
+ dark are the same tokens and follow the operator's OS setting unless
23
+ `colorScheme` pins one. Each token is gated the way the branding URLs already
24
+ were: a hex color, a CSS length, a plain font-family list, one of
25
+ `system`/`light`/`dark`. A rejected value takes the default and gets named in
26
+ a startup warning. The gates are narrow because these values land in a
27
+ `:root` block on the page, where anything unvalidated would be CSS injection.
28
+
29
+ ### Changed
30
+
31
+ - **The operator UI reads as a dashboard.** The connections page opens with one
32
+ summary line — connected, anything needing authorization, anything
33
+ unavailable, tools available — and then one row per connector: status dot,
34
+ name, auth scope, tool count, state. What used to be stacked in every card at
35
+ once (the description, the permission line, OAuth actions, the credential
36
+ panel, diagnostics, drift, and the tool list) is now behind a row an operator
37
+ expands, so a deployment with twenty connectors is a screen instead of a
38
+ scroll. Nothing about what the page may show or do changed: same payload,
39
+ same gates, and the drift panel still refuses to render a tool name or a
40
+ schema (#554).
41
+ - **The documentation is four guides.** `documentation/` now ships
42
+ `architecture.md`, `meta-tools.md`, `code-mode.md`, and `auth.md`. The
43
+ operations, upgrading, connector, provider, admission, storage, and operator
44
+ UI guides are removed; the rationale source comments used to defer to them
45
+ for now lives in those comments, and the provider conventions H1–H14 and
46
+ P1–P13 are defined in `test/provider-conventions.test.ts`. Each release's
47
+ opening paragraph here is the upgrade guidance. `records/` and `eval/` are
48
+ removed with the `load:admission` script and the CI job that served them. The
49
+ template and Worker example keep their `AGENTS.md`, which now points at this
50
+ changelog for upgrades (#552, #553).
51
+
52
+ ### Fixed
53
+
54
+ - **`get_result` paging cost.** A stashed result is stored as base64 chunks
55
+ under one key each — 48 KiB of text per chunk, wider for a result over
56
+ roughly 1.5 MB so the key and write count stays bounded — and a page reads
57
+ and decodes only the chunks it covers. Paging a large result no longer reads
58
+ the whole stash — let alone re-encodes it — once per page, so cost tracks the
59
+ page rather than the total. Offsets, `nextOffset`, `totalBytes`, and
60
+ character-boundary alignment are unchanged, and entries stashed in the
61
+ previous formats stay readable for the rest of their 15-minute TTL (#540).
62
+
63
+ ## 0.24.3 — 2026-09-16
64
+
65
+ A bug-fix release from a full audit of the execution path, invocation, catalog
66
+ rendering, downstream OAuth, and the route table, plus the one MCP 2026-07-28
67
+ requirement the previous inventory had missed. Two changes can be felt by a
68
+ deployment: `/mcp` now validates the browser `Origin` header, so a browser MCP
69
+ client hosted on an origin other than `publicUrl` or loopback needs
70
+ `allowedOrigins`; and the overload and shutdown JSON-RPC error codes moved
71
+ from `-32001`/`-32002` to `-31001`/`-31002`. Everything else is a fix a
72
+ deployment can take without action. No storage format changed, but
73
+ `fileStorage` now refuses a second process on the same state file, which a
74
+ deployment sharing one file between two processes was never safe doing.
75
+
76
+ ### Added
77
+
78
+ - **`allowedOrigins`.** `/mcp` and every `/mcp/<pool>` path refuse a
79
+ disallowed `Origin` with a fixed 403 before redirects, admission, auth, or
80
+ preflight, as the Streamable HTTP transport requires against DNS rebinding.
81
+ The default admits the `publicUrl` origin and HTTP(S) loopback at any port;
82
+ a list replaces the default; `"*"` keeps unrestricted CORS. Clients that
83
+ send no `Origin` are unaffected. Allowed preflight now returns 204 without
84
+ admission or auth and echoes valid requested `mcp-param-*` header names.
85
+ - **Host-owned refresh recovery.** A valid token response is a consumed
86
+ refresh token. When the request that owned a refresh is cancelled,
87
+ redirected to authorization, or invalidated before the SDK saves the
88
+ response, the coordinator persists the rotation itself, holds contenders
89
+ behind the pending-mutation marker until that write lands, and hands them
90
+ the saved rotation. A retired refresh token is never redeemed twice, and two
91
+ deterministic waves of eight overlapping scopes pin one grant per wave
92
+ (#526).
93
+ - Downstream JSON-RPC `-32602` refusals map to `invalid_args`, and 4xx
94
+ responses with a JSON message keep that message, bounded, without inferring
95
+ retryability from prose. 429 stays `rate_limited` and 408 becomes `timeout`.
96
+ - `Retry-After` accepts the HTTP-date form as well as delta-seconds.
97
+ - Personal connectors admit calls through their principal registry's own
98
+ budget instead of sharing one root budget; shutdown closes both, and
99
+ eviction never discards a live budget.
100
+ - `records/mcp-2026-07-28.md` gains rows for Origin validation, deterministic
101
+ `tools/list` order, the bearer challenge, SEP-2243 parameter headers, and
102
+ trace propagation, and corrects the stale extensions row.
103
+ - **Bounded `get_result` stash.** `results.maxStashBytes` (default 8 MiB)
104
+ and `results.maxStashEntries` (default 64) bound the paging stash per
105
+ runtime, with accounting that reserves capacity before concurrent writes
106
+ finish and reclaims expired entries before reuse. A refused stash keeps the
107
+ call successful with its preview and the paging-unavailable notice.
108
+ - **Byte-range paging.** Stashed results are stored as a byte-addressable
109
+ envelope, so `get_result` decodes only the requested page instead of
110
+ re-encoding the whole result on every call. Entries stashed before the
111
+ upgrade still page until their TTL expires.
112
+ - **Identity-partitioned results.** The stash partition derives from any
113
+ authenticated subject or principal, no longer only from providers that
114
+ declare `activityActorNamespace`. Open deployments share one partition.
115
+ - **Sanitized `unavailable` detail.** Unreachable downstreams may carry
116
+ `details.host` (origin only) and `details.code` (a closed allowlist of
117
+ network errnos, or `timeout`) so an outage is distinguishable from a typo
118
+ without leaking a path, query, or credential. Activity stays payload-free.
119
+ - **`fileStorage` writer lock.** A second instance or process opening the
120
+ same state file fails at construction naming the holder. The lock is
121
+ heartbeat-based, so a container restart that reuses a pid cannot wedge a
122
+ deployment, and each write uses a unique temp file. The returned store now
123
+ has `close()`.
124
+ - **Logs survive every executor failure.** QuickJS streams console entries
125
+ to the parent, so a program that is cancelled, killed at the deadline,
126
+ crashed, or lost to an IPC failure still returns what it printed.
127
+
128
+ ### Changed
129
+
130
+ - `/health` no longer names connectors: drift reports are keyed by a truncated
131
+ SHA-256 of the connector id and downstream admission is summed without ids.
132
+ `connecta doctor` keeps its stale-allowlist signal.
133
+ - Overload and shutdown JSON-RPC codes are `-31001` and `-31002`, outside the
134
+ reserved range, per the specification's allocation policy.
135
+ - `connecta.search` and `connecta.describe` spend the same host-call budget as
136
+ `connecta.call`; only `connecta.emit` is exempt (L4, M7).
137
+ - One per-call deadline now covers catalog resolution, admission, and the
138
+ connector call, so a hung connector fails with a catchable `timeout` instead
139
+ of consuming the whole execution wall clock.
140
+ - Compact describe caps each shape at 8,192 UTF-8 bytes and sets
141
+ `inputSchemaTruncated` / `outputSchemaTruncated`; use `format: "json"` for
142
+ the exact schema.
143
+ - Top-level discovery measures the complete tool result, both copies and JSON
144
+ escaping, against the 256,000-byte ceiling.
145
+ - Downstream `isError` text is bounded at 512 UTF-8 bytes, and error framing
146
+ fits the call's result cap in both result modes.
147
+ - Open deployments warn whenever any connector is configured, since `api()`
148
+ headers can carry secrets without declaring credential hooks.
149
+
150
+ ### Fixed
151
+
152
+ - **A long downstream error could leak the sandbox's per-run secret to guest
153
+ code and let a program forge a typed failure.** The QuickJS bridge sliced a
154
+ rejected host call at 4,000 characters, which clipped the authenticated
155
+ failure frame mid-JSON. Details are bounded before framing, the bridge
156
+ refuses an oversized frame whole, the prelude hides a malformed frame, and
157
+ the raw transport lives in a private closure so guest code cannot swap
158
+ `Error` or reach the bridge.
159
+ - **A completed destructive call was reported as a retryable failure when the
160
+ `get_result` stash write failed.** The truncated preview is returned with a
161
+ paging-unavailable notice, activity records success, and
162
+ `result_processing_failed` is never retryable.
163
+ - **The OAuth refresh gate wedged permanently** when a token fetch succeeded
164
+ but the SDK's save never ran, answering 503 to every later refresh in the
165
+ isolate until a forced reauthorization.
166
+ - **Compact schema rendering went quartic on `allOf`-of-`$ref` schemas.** A
167
+ 3.5 KB downstream schema took over four seconds of synchronous CPU, and the
168
+ describe path produced a 1.9 MB string from 1.8 KB. Rendering now spends a
169
+ shared 2,000-visit budget with memoized `$ref` expansion.
170
+ - `call_tool` in MCP result mode returned `{"content":[]}` for a downstream
171
+ result carrying only `structuredContent`; a text block is now synthesized,
172
+ and a `null` structured value survives unwrapping.
173
+ - In-program `connecta.search` returned `offset: null` and an empty page for a
174
+ non-numeric offset, and threw a raw `TypeError` for a non-string query; both
175
+ are `invalid_args`.
176
+ - Caller-authored text in `get_result`, `authorize_connector`, `skills`, and
177
+ `search_tools` refusals is bounded; a `connector` over 512 bytes is
178
+ `invalid_args`. A failed stash read is a typed `unavailable`.
179
+ - Legacy `mcp-session-id` DELETE now runs on credential rotation, generation
180
+ change, disconnect, and abandoned connects, not only on scope close.
181
+ - Pool names outside `[a-z0-9_-]` fell through to a generic 404 without CORS
182
+ or authentication; every `/mcp/` suffix now reaches the identical pool 404
183
+ after auth.
184
+ - The absent-grant warning set was unbounded and keyed by caller-derivable
185
+ text; it is capped at 1,024 entries.
186
+ - `requiredInputKeys` could name keys the schema did not declare.
187
+ - Validation error detail embedded whole enums; drift digests recursed without
188
+ a depth bound; refresh token responses were read without a byte ceiling;
189
+ credential revision races could loop without bound.
190
+ - Containment matching for escaped failures ignores messages under eight
191
+ characters, and a QuickJS host-result reply that cannot be serialized settles
192
+ the call instead of hanging until the wall deadline.
193
+ - Every `connecta.call` attempt spends one host call on entry, so unknown
194
+ addresses cannot loop for free; the escaped-failure list keeps the most
195
+ recent 64; an empty terminal error string is a failure, not a success.
196
+ - The compact renderer renders `prefixItems` as a tuple, marks
197
+ `dependentSchemas` and `if`/`then`/`else` shapes conditional with the
198
+ truncation flag, and resolves `$dynamicRef` like `$ref`.
199
+ - The guarded transport's no-body-stream path enforces the byte ceiling on
200
+ `text()` and `json()`.
201
+ - The pool-name timing oracle is accepted and documented; `authorize_connector`
202
+ honoring tool-level grants is documented and pinned.
203
+
5
204
  ## 0.24.2 — 2026-09-16
6
205
 
7
206
  `connectorAccess` can now grant individual tools, and a deployment can declare
@@ -22,7 +221,7 @@ returns `"all"` or connector ids and declares no pools.
22
221
  - **Tool-level grants in `identity.connectorAccess`.** Entries may be a
23
222
  connector id (every tool) or an exact `connector.tool` address (that tool
24
223
  only); grants are additive. The scoped registry view filters below the
25
- catalog service, so `search_tools`, `describe_tools`, `call_tool`,
224
+ catalog service, so `search_tools`, `call_tool`,
26
225
  `call_destructive_tool`, a program's `connecta.search` and `connecta.call`,
27
226
  and the connection UI all see the same list, and an ungranted tool fails as
28
227
  `unknown_tool` exactly like an absent one. There is no wildcard: a remote
@@ -32,6 +231,11 @@ returns `"all"` or connector ids and declares no pools.
32
231
 
33
232
  ## 0.24.1 — 2026-09-08
34
233
 
234
+ A small additive release for deployments whose providers run long: two
235
+ optional `execute.*` settings and a log line for failed calls. Nothing breaks
236
+ and nothing needs migrating; a deployment that sets neither option behaves as
237
+ it did on 0.24.0.
238
+
35
239
  ### Added
36
240
 
37
241
  - `execute.maxHostCalls` and `execute.hostCallTimeoutMs` configure the
package/README.md CHANGED
@@ -95,9 +95,7 @@ Cloudflare Access supplies Worker identity; Node can use Clerk or the optional
95
95
  configured bearer adapter. Connecta owns no accounts or groups and issues no
96
96
  client access tokens. Shared-credential administration and personal connection
97
97
  setup require separate explicit permissions, both denied by default. See
98
- [inbound auth](./documentation/auth.md#principals-visibility-and-operators),
99
- [shared and personal auth](./documentation/storage-and-credentials.md#shared-and-personal-auth),
100
- and the [module migration guide](./documentation/upgrading.md#0240-optional-modules).
98
+ [inbound auth](./documentation/auth.md#principals-visibility-and-operators).
101
99
 
102
100
  Connecta is not a platform, a marketplace, a policy engine, or a multi-tenant
103
101
  service. Those are decisions, and the [ethos](./ethos.md) records each one
@@ -107,9 +105,10 @@ and why.
107
105
 
108
106
  Setup is written for an agent. Point yours at [`AGENTS.md`](./AGENTS.md) and
109
107
  ask it to set up a Connecta deployment; the
110
- [documentation](./documentation/) covers every subsystem if you want to go
111
- deeper, and [upgrading](./documentation/upgrading.md) an existing deployment
112
- is its own runbook.
108
+ [documentation](./documentation/) covers the architecture, the seven tools,
109
+ code mode, and inbound auth if you want to go deeper. When upgrading an
110
+ existing deployment, each [changelog](./CHANGELOG.md) release opens with what
111
+ breaks and what a deployment can ignore.
113
112
 
114
113
  ## Status
115
114
 
@@ -39,6 +39,8 @@ export function bearerToken(secret, options = {}) {
39
39
  status: 401,
40
40
  headers: {
41
41
  "Content-Type": "application/json",
42
+ // A configured secret has no OAuth metadata or issuer to
43
+ // advertise; interactive adapters own resource discovery.
42
44
  "WWW-Authenticate": "Bearer",
43
45
  },
44
46
  }),
@@ -13,6 +13,16 @@ interface OAuthRefreshFlight {
13
13
  release: (outcome: OAuthRefreshFlightOutcome) => void;
14
14
  stopObservingOwnerAbort: () => void;
15
15
  mutationId: object;
16
+ writing: boolean;
17
+ /**
18
+ * Set once the token endpoint answered with valid tokens: the authorization
19
+ * server has consumed the rotating refresh token, so if the SDK's own
20
+ * saveTokens never arrives (owner cancelled, redirected, or invalidated),
21
+ * `fail` persists this copy itself rather than stranding the marker or
22
+ * letting a contender redeem the retired token.
23
+ */
24
+ acceptedTokens?: OAuthTokens;
25
+ persist?: (tokens: OAuthTokens) => Promise<void>;
16
26
  }
17
27
  /**
18
28
  * Share one rotating-token redemption within one connector runtime and OAuth
@@ -41,6 +51,8 @@ export declare class OAuthRefreshCoordinator {
41
51
  /** @internal Opaque basis for issuer-aware provider token reads. */
42
52
  successfulRefreshIdentity(generation: string): object | undefined;
43
53
  coordinatedFetch(provider: KvOAuthProvider, baseFetch: FetchLike, requestSignal?: AbortSignal): FetchLike;
54
+ /** Start storage only while this owner still holds its exact flight. */
55
+ beginMutation(generation: string, flight: OAuthRefreshFlight): boolean;
44
56
  /** Publish one exact owner's successful save without disturbing a newer try. */
45
57
  succeedMutation(generation: string, flight: OAuthRefreshFlight): void;
46
58
  /** Give joined callers a fetch/flow failure, without rejecting the gate. */
@@ -92,7 +104,6 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
92
104
  flowGeneration(): Promise<string>;
93
105
  /** @internal Record the refresh attempt this provider owns. */
94
106
  captureRefreshFlight(generation: string, flight: OAuthRefreshFlight): void;
95
- private succeedRefreshFlight;
96
107
  private failRefreshFlight;
97
108
  /** True when another request saved a refresh result after this flow's read. */
98
109
  refreshBasisChanged(current: OAuthTokens, generation: string): boolean;
@@ -63,18 +63,66 @@ function sdkAcceptsOAuthTokens(value) {
63
63
  }
64
64
  return true;
65
65
  }
66
- async function refreshResponseFailure(response) {
66
+ const MAX_REFRESH_RESPONSE_BYTES = 65_536;
67
+ class OversizedRefreshResponse extends Error {
68
+ constructor() {
69
+ super(`OAuth refresh response exceeded ${MAX_REFRESH_RESPONSE_BYTES} bytes.`);
70
+ }
71
+ }
72
+ async function readRefreshResponse(response) {
73
+ if (Number(response.headers.get("content-length")) > MAX_REFRESH_RESPONSE_BYTES) {
74
+ void response.body?.cancel().catch(() => { });
75
+ throw new OversizedRefreshResponse();
76
+ }
77
+ const reader = response.clone().body?.getReader();
78
+ if (!reader)
79
+ return undefined;
80
+ const decoder = new TextDecoder();
81
+ let size = 0;
82
+ let text = "";
83
+ try {
84
+ while (true) {
85
+ const { done, value } = await reader.read();
86
+ if (done)
87
+ break;
88
+ size += value.byteLength;
89
+ if (size > MAX_REFRESH_RESPONSE_BYTES) {
90
+ // A cloned body's cancellation can await its sibling. Cancel both,
91
+ // without making the refusal wait for the provider to finish sending.
92
+ void reader.cancel().catch(() => { });
93
+ void response.body?.cancel().catch(() => { });
94
+ throw new OversizedRefreshResponse();
95
+ }
96
+ text += decoder.decode(value, { stream: true });
97
+ }
98
+ return JSON.parse(text + decoder.decode());
99
+ }
100
+ finally {
101
+ reader.releaseLock();
102
+ }
103
+ }
104
+ /**
105
+ * Classify the token endpoint's answer once, on a clone, and keep the parsed
106
+ * tokens: a valid response has already consumed the rotating refresh token, so
107
+ * the host persists it (`coordinatedFetch`) rather than trusting the SDK's
108
+ * later `saveTokens` to arrive on a request that may already be cancelled.
109
+ */
110
+ async function refreshResponseOutcome(response) {
67
111
  if (!response.ok) {
68
- return new Error(`OAuth refresh failed with HTTP ${response.status}.`);
112
+ return { failure: new Error(`OAuth refresh failed with HTTP ${response.status}.`) };
69
113
  }
114
+ let parsed;
70
115
  try {
71
- return sdkAcceptsOAuthTokens(await response.clone().json())
72
- ? undefined
73
- : new Error("OAuth refresh response did not match the token schema.");
116
+ parsed = await readRefreshResponse(response);
74
117
  }
75
- catch {
76
- return new Error("OAuth refresh response did not contain JSON tokens.");
118
+ catch (error) {
119
+ if (error instanceof OversizedRefreshResponse)
120
+ throw error;
121
+ return { failure: new Error("OAuth refresh response did not contain JSON tokens.") };
77
122
  }
123
+ return sdkAcceptsOAuthTokens(parsed)
124
+ ? { tokens: parsed }
125
+ : { failure: new Error("OAuth refresh response did not match the token schema.") };
78
126
  }
79
127
  function refreshMutationPendingResponse() {
80
128
  return Response.json({
@@ -189,7 +237,9 @@ export class OAuthRefreshCoordinator {
189
237
  const requestedRefreshToken = init?.body instanceof URLSearchParams
190
238
  ? init.body.get("refresh_token")
191
239
  : new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
192
- while (true) {
240
+ for (let attempt = 0; attempt < 64; attempt++) {
241
+ if (requestSignal?.aborted)
242
+ throw aborted(requestSignal);
193
243
  const revisionBeforeReads = this.stateRevision;
194
244
  const activeGeneration = await provider.generation();
195
245
  this.observeAuthoritativeGeneration(activeGeneration);
@@ -258,6 +308,8 @@ export class OAuthRefreshCoordinator {
258
308
  release: (outcome) => release(outcome),
259
309
  stopObservingOwnerAbort: () => { },
260
310
  mutationId: {},
311
+ writing: false,
312
+ persist: (tokens) => provider.saveTokens(tokens),
261
313
  };
262
314
  this.flights.set(generation, flight);
263
315
  this.advanceStateRevision();
@@ -280,18 +332,35 @@ export class OAuthRefreshCoordinator {
280
332
  }
281
333
  try {
282
334
  const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
283
- // These responses never reach a successful saveTokens callback. Give
335
+ // Failed responses never reach a successful saveTokens callback. Give
284
336
  // current waiters a bounded failure now while leaving the owner's
285
337
  // response untouched for the SDK to parse and classify itself.
286
- const failure = await refreshResponseFailure(response);
287
- if (failure) {
288
- this.fail(generation, flight, failure);
338
+ const outcome = await refreshResponseOutcome(response);
339
+ if (outcome.failure) {
340
+ this.fail(generation, flight, outcome.failure);
341
+ return response;
289
342
  }
290
- else if (requestSignal?.aborted ||
291
- !this.markMutationPending(generation, flight)) {
292
- throw requestSignal?.aborted
293
- ? aborted(requestSignal)
294
- : new Error("OAuth refresh ended before tokens could be saved.");
343
+ // A valid response means the authorization server has consumed the
344
+ // rotating refresh token. The SDK's saveTokens normally persists it;
345
+ // keep this copy so `fail` can persist it instead if that callback
346
+ // never comes (the SDK merges the old refresh token the same way).
347
+ const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentTokens?.refresh_token;
348
+ flight.acceptedTokens = {
349
+ ...outcome.tokens,
350
+ ...(rotatedRefreshToken !== undefined
351
+ ? { refresh_token: rotatedRefreshToken }
352
+ : {}),
353
+ };
354
+ if (!this.markMutationPending(generation, flight)) {
355
+ // Already settled (the owner was cancelled first). Still write the
356
+ // rotation: losing it would leave a dead credential.
357
+ void flight.persist?.(flight.acceptedTokens).catch(() => { });
358
+ throw new Error("OAuth refresh ended before tokens could be saved.");
359
+ }
360
+ if (requestSignal?.aborted) {
361
+ // Same recovery as an abort landing later: `fail` persists.
362
+ this.fail(generation, flight, aborted(requestSignal));
363
+ throw aborted(requestSignal);
295
364
  }
296
365
  return response;
297
366
  }
@@ -300,8 +369,16 @@ export class OAuthRefreshCoordinator {
300
369
  throw error;
301
370
  }
302
371
  }
372
+ return refreshMutationPendingResponse();
303
373
  };
304
374
  }
375
+ /** Start storage only while this owner still holds its exact flight. */
376
+ beginMutation(generation, flight) {
377
+ if (this.flights.get(generation) !== flight)
378
+ return false;
379
+ flight.writing = true;
380
+ return true;
381
+ }
305
382
  /** Publish one exact owner's successful save without disturbing a newer try. */
306
383
  succeedMutation(generation, flight) {
307
384
  if (this.finishMutation(generation, flight)) {
@@ -312,6 +389,37 @@ export class OAuthRefreshCoordinator {
312
389
  }
313
390
  /** Give joined callers a fetch/flow failure, without rejecting the gate. */
314
391
  fail(generation, flight, error) {
392
+ // Only saveTokens owns a live credential write. If it has started, its
393
+ // success/failure callback clears the marker even after owner cancellation.
394
+ if (flight.writing) {
395
+ this.settle(generation, flight, { status: "failed", error });
396
+ return;
397
+ }
398
+ // The token endpoint already answered with valid tokens but the SDK will
399
+ // not save them (the owner was cancelled, redirected, or invalidated).
400
+ // The old refresh token is spent, so the host persists the rotation
401
+ // itself and keeps contenders behind the marker until it lands. Joined
402
+ // callers then receive the saved rotation, never a retired token to
403
+ // redeem again. saveTokens' own bookkeeping settles the flight when the
404
+ // provider still holds it; otherwise settle here once the write ends.
405
+ const tokens = flight.acceptedTokens;
406
+ if (tokens !== undefined &&
407
+ flight.persist !== undefined &&
408
+ this.pendingMutations.get(generation) === flight.mutationId) {
409
+ flight.writing = true;
410
+ void flight.persist(tokens).then(() => {
411
+ if (this.finishMutation(generation, flight)) {
412
+ this.settle(generation, flight, { status: "refreshed" });
413
+ }
414
+ }, (writeError) => {
415
+ if (this.finishMutation(generation, flight)) {
416
+ this.settle(generation, flight, { status: "failed", error: writeError });
417
+ }
418
+ });
419
+ return;
420
+ }
421
+ // A response alone is not a write and must never strand the generation.
422
+ this.finishMutation(generation, flight);
315
423
  this.settle(generation, flight, { status: "failed", error });
316
424
  }
317
425
  /** Finish an exact failed credential write, then publish its failure. */
@@ -417,23 +525,11 @@ export class KvOAuthProvider {
417
525
  captureRefreshFlight(generation, flight) {
418
526
  this.refreshFlight = { generation, flight };
419
527
  }
420
- succeedRefreshFlight() {
421
- const owned = this.refreshFlight;
422
- this.refreshFlight = undefined;
423
- if (owned) {
424
- this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
425
- }
426
- }
427
- failRefreshFlight(error, mutationFinished = false) {
528
+ failRefreshFlight(error) {
428
529
  const owned = this.refreshFlight;
429
530
  this.refreshFlight = undefined;
430
531
  if (owned) {
431
- if (mutationFinished) {
432
- this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
433
- }
434
- else {
435
- this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
436
- }
532
+ this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
437
533
  }
438
534
  }
439
535
  /** True when another request saved a refresh result after this flow's read. */
@@ -650,14 +746,30 @@ export class KvOAuthProvider {
650
746
  return tokens;
651
747
  }
652
748
  async saveTokens(tokens, ctx) {
749
+ // A retired flight (the owner was cancelled or superseded after the
750
+ // token response) still writes: the authorization server has already
751
+ // consumed the old refresh token, so dropping the rotated one would leave a
752
+ // dead credential. writeValue itself refuses when the generation moved.
753
+ // Only the coordinator bookkeeping belongs to the exact live flight.
754
+ const owned = this.refreshFlight;
755
+ const coordinated = owned !== undefined &&
756
+ this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
653
757
  try {
654
758
  await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
655
- this.succeedRefreshFlight();
759
+ if (coordinated)
760
+ this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
656
761
  }
657
762
  catch (error) {
658
- this.failRefreshFlight(error, true);
763
+ if (coordinated)
764
+ this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
659
765
  throw error;
660
766
  }
767
+ finally {
768
+ // The write owns this identity even if an SDK failure callback has
769
+ // already detached the provider's flight while storage was pending.
770
+ if (this.refreshFlight === owned)
771
+ this.refreshFlight = undefined;
772
+ }
661
773
  }
662
774
  /**
663
775
  * OAuth `state`. The SDK calls this (when present) and appends the value to
@@ -701,9 +813,9 @@ export class KvOAuthProvider {
701
813
  return stored.value;
702
814
  }
703
815
  async redirectToAuthorization(authorizationUrl) {
704
- if (!this.allowAuthorization)
705
- throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
706
816
  try {
817
+ if (!this.allowAuthorization)
818
+ throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
707
819
  await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
708
820
  this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
709
821
  }