@zackbart/connecta 0.28.0 → 0.29.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 (320) hide show
  1. package/CHANGELOG.md +961 -5
  2. package/README.md +62 -39
  3. package/bin/connecta.mjs +110 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  12. package/dist/auth/clerk-sdk/client.js +5 -0
  13. package/dist/auth/clerk-sdk/generated.js +1845 -0
  14. package/dist/auth/clerk-transport.js +19 -0
  15. package/dist/auth/clerk.d.ts +17 -7
  16. package/dist/auth/clerk.js +189 -107
  17. package/dist/auth/cloudflare-access.js +25 -26
  18. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  19. package/dist/auth/downstream-client-metadata.js +133 -0
  20. package/dist/auth/downstream-oauth.js +1632 -1509
  21. package/dist/auth/downstream-revocation.js +106 -0
  22. package/dist/auth/oauth-token-response.js +231 -0
  23. package/dist/auth/oauth-v2-migration.js +116 -0
  24. package/dist/auth/static-oauth.js +526 -0
  25. package/dist/auth-elicitation.js +283 -0
  26. package/dist/branding.d.ts +8 -16
  27. package/dist/branding.js +26 -59
  28. package/dist/byte-read-response.js +39 -0
  29. package/dist/call-admission.js +12 -16
  30. package/dist/catalog-cache.js +739 -0
  31. package/dist/catalog-drift.d.ts +50 -15
  32. package/dist/catalog-drift.js +365 -128
  33. package/dist/catalog-fingerprint.js +2 -4
  34. package/dist/catalog-freshness.js +13 -0
  35. package/dist/catalog-limits.js +2 -2
  36. package/dist/catalog-service.js +368 -224
  37. package/dist/catalog.js +43 -84
  38. package/dist/concurrency.js +3 -4
  39. package/dist/config-defaults.d.ts +39 -0
  40. package/dist/config-defaults.js +46 -0
  41. package/dist/config-schema.d.ts +181 -0
  42. package/dist/config-schema.js +435 -0
  43. package/dist/config-value-sources.js +102 -0
  44. package/dist/config.d.ts +631 -0
  45. package/dist/config.js +503 -0
  46. package/dist/connector-access.d.ts +1 -0
  47. package/dist/connector-access.js +13 -15
  48. package/dist/connector-caller.d.ts +8 -0
  49. package/dist/connector-caller.js +15 -6
  50. package/dist/connector-scope.js +1 -1
  51. package/dist/connectors/api-connector.d.ts +220 -0
  52. package/dist/connectors/api-connector.js +214 -0
  53. package/dist/connectors/api.d.ts +8 -71
  54. package/dist/connectors/api.js +13 -99
  55. package/dist/connectors/guarded-fetch.d.ts +144 -0
  56. package/dist/connectors/guarded-fetch.js +108 -25
  57. package/dist/connectors/negotiation-cache.js +46 -0
  58. package/dist/connectors/option-shapes.js +27 -0
  59. package/dist/connectors/remote-mcp.d.ts +30 -4
  60. package/dist/connectors/remote-mcp.js +1780 -351
  61. package/dist/connectors/resource-uri.js +335 -0
  62. package/dist/credential-contract.d.ts +10 -0
  63. package/dist/credential-rules.js +1 -3
  64. package/dist/credential-url.js +108 -0
  65. package/dist/credentials.d.ts +6 -1
  66. package/dist/credentials.js +27 -18
  67. package/dist/d1.d.ts +130 -0
  68. package/dist/d1.js +290 -0
  69. package/dist/describe-config.d.ts +165 -0
  70. package/dist/describe-config.js +315 -0
  71. package/dist/described.js +106 -0
  72. package/dist/downstream-input-context.js +119 -0
  73. package/dist/downstream-input.js +323 -0
  74. package/dist/errors.d.ts +97 -20
  75. package/dist/errors.js +158 -80
  76. package/dist/execute.js +453 -305
  77. package/dist/executor-admission.d.ts +1 -0
  78. package/dist/executor-admission.js +11 -15
  79. package/dist/executor-contract.d.ts +20 -0
  80. package/dist/executor-contract.js +48 -0
  81. package/dist/executor-result.js +24 -9
  82. package/dist/executors/quickjs-child.js +3 -4
  83. package/dist/executors/quickjs-runtime.js +147 -59
  84. package/dist/executors/quickjs.js +29 -35
  85. package/dist/favicon.js +1 -2
  86. package/dist/fix-prompt.js +1 -3
  87. package/dist/guest-runtime.js +188 -0
  88. package/dist/guest-types.d.ts +176 -0
  89. package/dist/guest-types.js +1 -0
  90. package/dist/html-security.js +24 -0
  91. package/dist/identity.js +1 -3
  92. package/dist/inbound-credential.js +52 -0
  93. package/dist/index.d.ts +25 -257
  94. package/dist/index.js +65 -478
  95. package/dist/invocation-auth.js +69 -0
  96. package/dist/invocation.js +230 -73
  97. package/dist/json-schema.d.ts +1 -1
  98. package/dist/mcp-client-context.js +35 -0
  99. package/dist/mcp-result.js +11 -7
  100. package/dist/meta-output.js +76 -0
  101. package/dist/meta-tool-names.d.ts +2 -0
  102. package/dist/meta-tool-names.js +3 -0
  103. package/dist/meta-tools.js +285 -209
  104. package/dist/module-contracts.d.ts +8 -23
  105. package/dist/node.d.ts +0 -2
  106. package/dist/node.js +2 -2
  107. package/dist/oauth-callback-outcome.js +1 -3
  108. package/dist/oauth-handoff.js +117 -0
  109. package/dist/oauth-partition.js +17 -0
  110. package/dist/oauth-sealing.js +20 -0
  111. package/dist/operator-record.d.ts +137 -0
  112. package/dist/operator-record.js +420 -0
  113. package/dist/operator-ui/assets.js +31 -0
  114. package/dist/operator-ui/contract.d.ts +72 -0
  115. package/dist/operator-ui/contract.js +1 -0
  116. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  117. package/dist/operator-ui/generated.js +5 -4
  118. package/dist/operator-ui/model.d.ts +12 -87
  119. package/dist/operator-ui/model.js +2 -9
  120. package/dist/operator-ui/setup-commands.js +1 -3
  121. package/dist/operator-ui/view.d.ts +323 -0
  122. package/dist/operator-ui/view.js +62 -189
  123. package/dist/page-styles.js +2 -3
  124. package/dist/program-source.js +14 -2
  125. package/dist/{exempt-writes.js → program-writes.js} +55 -18
  126. package/dist/provider.d.ts +142 -0
  127. package/dist/provider.js +187 -0
  128. package/dist/providers/_shared/google/result-size.js +57 -0
  129. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  130. package/dist/providers/_shared/google/service-account.js +500 -0
  131. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  132. package/dist/providers/_shared/google/workspace.js +518 -0
  133. package/dist/providers/basecamp/index.d.ts +43 -0
  134. package/dist/providers/basecamp/index.js +997 -0
  135. package/dist/providers/basecamp/skill.generated.js +9 -0
  136. package/dist/providers/breeze/index.d.ts +34 -0
  137. package/dist/providers/breeze/index.js +1446 -0
  138. package/dist/providers/breeze/skill.generated.js +9 -0
  139. package/dist/providers/ccb/index.d.ts +103 -0
  140. package/dist/providers/ccb/index.js +1725 -0
  141. package/dist/providers/ccb/skill.generated.js +9 -0
  142. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  143. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  144. package/dist/providers/cloudflare/skill.generated.js +10 -0
  145. package/dist/providers/docs/index.d.ts +54 -0
  146. package/dist/providers/docs/index.js +1237 -0
  147. package/dist/providers/docs/skill.generated.js +9 -0
  148. package/dist/providers/drive/index.d.ts +53 -0
  149. package/dist/providers/drive/index.js +1523 -0
  150. package/dist/providers/drive/skill.generated.js +9 -0
  151. package/dist/providers/forms/index.d.ts +54 -0
  152. package/dist/providers/forms/index.js +1217 -0
  153. package/dist/providers/forms/skill.generated.js +11 -0
  154. package/dist/providers/github/auth.d.ts +32 -0
  155. package/dist/providers/github/auth.js +361 -0
  156. package/dist/providers/github/hosted.js +218 -0
  157. package/dist/providers/github/index.d.ts +13 -0
  158. package/dist/providers/github/index.js +140 -0
  159. package/dist/providers/github/key.js +100 -0
  160. package/dist/providers/github/rest.js +321 -0
  161. package/dist/providers/github/scope.d.ts +29 -0
  162. package/dist/providers/github/scope.js +124 -0
  163. package/dist/providers/github/skill.generated.js +8 -0
  164. package/dist/providers/gmail/index.d.ts +51 -0
  165. package/dist/providers/gmail/index.js +1436 -0
  166. package/dist/providers/gmail/skill.generated.js +9 -0
  167. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  168. package/dist/providers/linear/index.js +352 -0
  169. package/dist/providers/linear/skill.generated.js +8 -0
  170. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  171. package/dist/providers/mixpanel/index.js +416 -0
  172. package/dist/providers/mixpanel/skill.generated.js +9 -0
  173. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  174. package/dist/providers/{notion.js → notion/index.js} +226 -239
  175. package/dist/providers/notion/skill.generated.js +10 -0
  176. package/dist/providers/overflow/index.d.ts +60 -0
  177. package/dist/providers/overflow/index.js +1535 -0
  178. package/dist/providers/overflow/skill.generated.js +8 -0
  179. package/dist/providers/planning-center/index.d.ts +59 -0
  180. package/dist/providers/planning-center/index.js +2199 -0
  181. package/dist/providers/planning-center/skill.generated.js +10 -0
  182. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  183. package/dist/providers/revenuecat/index.js +557 -0
  184. package/dist/providers/revenuecat/skill.generated.js +11 -0
  185. package/dist/providers/sheets/index.d.ts +54 -0
  186. package/dist/providers/sheets/index.js +1237 -0
  187. package/dist/providers/sheets/skill.generated.js +10 -0
  188. package/dist/providers/slides/index.d.ts +53 -0
  189. package/dist/providers/slides/index.js +2645 -0
  190. package/dist/providers/slides/skill.generated.js +9 -0
  191. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  192. package/dist/providers/stripe/index.js +266 -0
  193. package/dist/providers/stripe/skill.generated.js +11 -0
  194. package/dist/providers/tithely/index.d.ts +49 -0
  195. package/dist/providers/tithely/index.js +1003 -0
  196. package/dist/providers/tithely/skill.generated.js +8 -0
  197. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  198. package/dist/providers/vercel/index.js +1038 -0
  199. package/dist/providers/vercel/mcp-ownership.js +212 -0
  200. package/dist/providers/vercel/skill.generated.js +11 -0
  201. package/dist/registry.d.ts +108 -171
  202. package/dist/registry.js +513 -925
  203. package/dist/request-state.js +21 -0
  204. package/dist/resource-read.js +100 -0
  205. package/dist/result-shapes.js +17 -49
  206. package/dist/retired-toolkits.js +1 -1
  207. package/dist/routes/access-tokens.js +3 -6
  208. package/dist/routes/activity-context.js +21 -0
  209. package/dist/routes/activity-disclosure.js +40 -0
  210. package/dist/routes/activity.js +59 -21
  211. package/dist/routes/actor-labels.js +3 -50
  212. package/dist/routes/connect.js +221 -0
  213. package/dist/routes/credentials.js +15 -27
  214. package/dist/routes/mcp.js +356 -130
  215. package/dist/routes/oauth-client-metadata.js +15 -0
  216. package/dist/routes/oauth-management.js +45 -107
  217. package/dist/routes/oauth.js +136 -57
  218. package/dist/routes/operator.js +3 -3
  219. package/dist/routes/shared.d.ts +25 -64
  220. package/dist/routes/shared.js +160 -80
  221. package/dist/routes/skills.js +30 -0
  222. package/dist/routes/ui-config.js +225 -0
  223. package/dist/routes/ui-data.js +40 -35
  224. package/dist/routes/ui.js +66 -56
  225. package/dist/runtime/admission.js +4 -2
  226. package/dist/runtime/call-admission.js +3 -1
  227. package/dist/runtime/connector-scope.js +1 -4
  228. package/dist/runtime/run.js +10 -39
  229. package/dist/runtime/services.js +12 -117
  230. package/dist/runtime/shared-read.js +1 -3
  231. package/dist/runtime/storage.js +8 -1
  232. package/dist/sent-secrets.d.ts +52 -0
  233. package/dist/sent-secrets.js +474 -0
  234. package/dist/server.js +28 -71
  235. package/dist/skills.js +406 -174
  236. package/dist/sqlite.d.ts +48 -0
  237. package/dist/sqlite.js +166 -0
  238. package/dist/storage/keys.js +428 -0
  239. package/dist/storage/memory.js +7 -0
  240. package/dist/storage/sql.d.ts +66 -0
  241. package/dist/storage/sql.js +534 -0
  242. package/dist/timeout.js +4 -2
  243. package/dist/tool-name.js +17 -0
  244. package/dist/tool-safety.d.ts +9 -35
  245. package/dist/tool-safety.js +20 -40
  246. package/dist/types.d.ts +320 -54
  247. package/dist/types.js +0 -1
  248. package/dist/ui.d.ts +7 -19
  249. package/dist/ui.js +76 -90
  250. package/dist/url-safety.js +3 -6
  251. package/dist/usage-guide.js +137 -0
  252. package/dist/validate.d.ts +7 -0
  253. package/dist/validate.js +309 -47
  254. package/dist/version.d.ts +1 -7
  255. package/dist/version.js +3 -7
  256. package/dist/worker.d.ts +11 -0
  257. package/dist/worker.js +434 -0
  258. package/documentation/architecture.md +665 -230
  259. package/documentation/auth.md +962 -458
  260. package/documentation/code-mode.md +501 -312
  261. package/documentation/meta-tools.md +449 -200
  262. package/documentation/operator-tests.md +93 -0
  263. package/documentation/provider-migration-0.29.md +60 -0
  264. package/examples/worker/AGENTS.md +34 -12
  265. package/examples/worker/README.md +286 -255
  266. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  267. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  268. package/examples/worker/src/connecta.config.ts +151 -0
  269. package/examples/worker/src/index.ts +14 -197
  270. package/examples/worker/wrangler.jsonc +15 -35
  271. package/package.json +125 -35
  272. package/templates/node/.dockerignore +1 -2
  273. package/templates/node/.env.example +20 -21
  274. package/templates/node/AGENTS.md +21 -17
  275. package/templates/node/Dockerfile +3 -5
  276. package/templates/node/README.md +133 -58
  277. package/templates/node/docker-compose.yml +11 -12
  278. package/templates/node/package.json +4 -2
  279. package/templates/node/src/connecta.config.ts +119 -0
  280. package/templates/node/src/index.ts +9 -154
  281. package/templates/node/src/provision-token.ts +17 -0
  282. package/AGENTS.md +0 -170
  283. package/dist/artifacts/connector.d.ts +0 -58
  284. package/dist/artifacts/connector.js +0 -730
  285. package/dist/artifacts/document.js +0 -86
  286. package/dist/artifacts/guide.js +0 -140
  287. package/dist/artifacts/html-scan.js +0 -329
  288. package/dist/artifacts/json.js +0 -78
  289. package/dist/artifacts/kv-store.d.ts +0 -20
  290. package/dist/artifacts/kv-store.js +0 -140
  291. package/dist/artifacts/markdown.js +0 -478
  292. package/dist/artifacts/module.d.ts +0 -37
  293. package/dist/artifacts/module.js +0 -66
  294. package/dist/artifacts/operations.d.ts +0 -197
  295. package/dist/artifacts/operations.js +0 -1002
  296. package/dist/artifacts/refresh.d.ts +0 -55
  297. package/dist/artifacts/refresh.js +0 -236
  298. package/dist/artifacts/routes.js +0 -206
  299. package/dist/artifacts/types.d.ts +0 -214
  300. package/dist/artifacts/types.js +0 -22
  301. package/dist/artifacts/validate.d.ts +0 -62
  302. package/dist/artifacts/validate.js +0 -651
  303. package/dist/artifacts.d.ts +0 -6
  304. package/dist/artifacts.js +0 -9
  305. package/dist/auth/bearer.d.ts +0 -11
  306. package/dist/auth/bearer.js +0 -50
  307. package/dist/providers/linear.js +0 -217
  308. package/dist/providers/mixpanel.js +0 -247
  309. package/dist/providers/revenuecat.js +0 -317
  310. package/dist/providers/stripe.js +0 -253
  311. package/dist/providers/vercel.js +0 -1253
  312. package/dist/storage/file.d.ts +0 -15
  313. package/dist/storage/file.js +0 -455
  314. package/ethos.md +0 -93
  315. package/examples/worker/src/cloudflare-kv.ts +0 -43
  316. package/examples/worker/src/d1-activity-row.ts +0 -155
  317. package/examples/worker/src/d1-activity.ts +0 -147
  318. package/examples/worker/src/d1-storage.ts +0 -138
  319. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  320. package/templates/node/src/file-activity.ts +0 -182
@@ -1,31 +1,60 @@
1
1
  # Inbound auth
2
2
 
3
- Inbound auth decides who may reach the MCP endpoint. Import `bearerToken` from
4
- `@zackbart/connecta/auth/bearer`, `clerkAuth` from `/auth/clerk`, or
5
- `cloudflareAccessAuth` from `/auth/cloudflare-access`. Providers may be combined;
6
- static bearers are checked first, then other providers in configuration order.
7
- An `InboundAuth` provider's `authorize(request, baseUrl, runtimeContext)`
8
- returns either `{ ok: true, userId?, subjectId?, principal? }` or a refusal
9
- carrying its own `Response`, so the provider owns its challenge. Connecta
10
- issues managed client tokens only when the optional `accessTokens` module is configured.
11
-
12
- The bearer adapter challenges with `WWW-Authenticate: Bearer` and deliberately
13
- omits `resource_metadata`: its credential is configured out of band, so it has no
14
- authorization server or registration endpoint to advertise. Interactive adapters
15
- or the edge own OAuth discovery. An open deployment with any connector warns at
16
- construction — including API connectors carrying static auth headers, and with
17
- sharper wording for credential and OAuth connectors.
3
+ Inbound auth decides who may reach the MCP endpoint. People use `clerkAuth`
4
+ from `@zackbart/connecta/auth/clerk` or `cloudflareAccessAuth` from
5
+ `/auth/cloudflare-access`. Machines use `accessTokens(storage)` from
6
+ `/auth/access-tokens`, which installs the `cta_` adapter before human providers.
7
+ Access is Workers-only; Node uses Clerk.
8
+
9
+ An `InboundAuth` provider recognizes credential syntax or trusted runtime
10
+ context with synchronous `recognizesCredential(request, runtimeContext)`.
11
+ Recognition does no verification or I/O. The first recognizing provider owns
12
+ the request, including refusals, so an invalid machine credential cannot fall
13
+ back to an ambient browser identity. An explicit Authorization header is
14
+ decisive on every protected route. A shared parser normalizes the
15
+ case-insensitive Bearer scheme; unsupported schemes, malformed spacing, empty
16
+ values, combined duplicate headers, and verification failures return 401.
17
+ Cookies, Clerk browser handshake credentials, and trusted Access context are
18
+ excluded from that request. Access cannot verify caller headers and refuses
19
+ them. If no provider recognizes a valid header, the first eligible provider
20
+ owns its verdict. Without an Authorization header, providers are tried in
21
+ configuration order until admission or a response other than 401.
22
+ Browser OAuth starts and callbacks retain the 401 challenge for an explicit
23
+ header refusal.
24
+ Custom providers can omit recognition when they supply no distinct credential
25
+ syntax; providers with credentials should implement it. A recognition throw
26
+ refuses the request without logging the thrown text.
27
+
28
+ The `cta_` syntax is reserved even without the optional token verifier: it
29
+ refuses rather than becoming an ambient human or an open anonymous request.
30
+ Async recognition hooks fail at construction; unexpected rejected promises
31
+ are consumed before refusal so their text cannot reach runtime output.
32
+ Human routes reject recognized machine credentials without consulting token
33
+ storage, then ask only interactive providers. Replace custom `final` and
34
+ `finalRefusals` handling with recognition; handshake redirects and admission
35
+ policy refusals are provider verdicts too. Recognition grants no identity.
36
+
37
+ `authorize` returns `{ ok: true, userId?, subjectId?, principal? }` or a refusal
38
+ carrying a `Response`. On 401, routing and challenge selection use the first
39
+ provider that actually answers the endpoint's protected-resource metadata
40
+ request. Its `challenge(request, baseUrl)` supplies `WWW-Authenticate`, including
41
+ `scope`; an unrelated metadata hook does not own resource discovery. Machine-only
42
+ endpoints challenge with `Bearer` and advertise no authorization server. With
43
+ Clerk, even a refused `cta_` token receives Clerk's resource metadata challenge.
44
+ An open deployment with connectors warns at construction.
45
+
46
+ Clerk denial logs carry checked fixed reason codes. Provider user IDs, email
47
+ addresses, and email domains stay out of denial logs, activity, and status,
48
+ including email lookup failures and admission policy refusals.
18
49
 
19
50
  ## Managed client tokens and upgrading from v0.23
20
51
 
21
- Import `accessTokens` from `@zackbart/connecta/auth/access-tokens` and pass its
22
- module to `createConnecta`. It installs its inbound adapter and, beside `ui`,
23
- the Access tokens page and `/ui/access-tokens` lifecycle routes. Omitting the
24
- module loads none of its implementation and serves none of those routes.
52
+ `accessTokens` from `@zackbart/connecta/auth/access-tokens` installs its inbound
53
+ adapter and, beside `ui`, the Access tokens page and `/ui/access-tokens`
54
+ routes. Omitted, none of it loads or is served.
25
55
 
26
56
  ```ts
27
57
  import { accessTokens } from "@zackbart/connecta/auth/access-tokens";
28
-
29
58
  createConnecta({
30
59
  storage,
31
60
  accessTokens: accessTokens(storage),
@@ -37,46 +66,41 @@ createConnecta({
37
66
  principal?.namespace === "clerk:your-existing-namespace" &&
38
67
  principal.id === "your-operator-id",
39
68
  },
40
- connectors,
41
- executor,
69
+ connectors, executor,
42
70
  });
43
71
  ```
44
72
 
45
- For a v0.23 deployment, replace the old `accessTokens: true` or options object
46
- with `accessTokens: accessTokens(storage)`, using **the same storage and key
47
- namespace**. Keep the existing `access-token:v1:record:*` and
48
- `access-token:v1:lookup:*` records. Their unrevoked `cta_…` secrets keep working;
49
- no secret recovery, rewrite, or client rotation is needed. Preserve the existing
50
- identity namespaces and connector/pool grant callbacks too. Storage compatibility
51
- does not translate deployment configuration or invent replacement grants.
52
-
53
- The adapter preserves `access_token` actor ids, the
54
- `connecta:access-tokens:v1` activity namespace, friendly names, and any stored
55
- principal. A token without a principal stays unbound. Every request evaluates
56
- current identity/tool/pool grants; a token never becomes an interactive operator.
57
- Names are labels, never permissions. New UI-issued tokens belong to the issuing
58
- human's principal, and any explicitly permitted token manager can list, rename,
59
- or revoke deployment tokens. Static and managed bearers cannot manage tokens or
60
- connection credentials. Operators need an interactive auth provider.
61
-
62
- Storage must implement `list`. Existing-token verification, rename, and revoke
63
- also work on older adapters without `compareAndSet`; **new issuance requires
64
- atomic `compareAndSet`**, because counting records before writing admits too many
65
- concurrent creates. Active capacity defaults to 100, configurable with
66
- `accessTokens(storage, { maxActive: 200 })`, up to 1,000. A durable reservation
67
- counts before a secret is written. Failures before lookup publication release
68
- their reservation; an uncertain lookup write retains its metadata and capacity
69
- so an operator can revoke it, even when creation returned no secret. Creation is
70
- never automatically retried. Avoid creating new tokens through old-version instances once
71
- new-version issuance has started, since those instances do not honor reservations.
72
-
73
- Secrets contain 256 random bits and only their SHA-256 digests persist. Creation
74
- returns the secret once; list and rename never return it. Revocation removes its
75
- lookup before updating metadata, and authorization has no token cache. A strongly
76
- consistent store makes revocation effective on the next authorization; an
77
- eventually consistent backend retains its own propagation delay. Requests already
78
- admitted are not recalled. Management writes require an exact same-origin Origin,
79
- and responses are private and non-cacheable.
73
+ From v0.23, replace `accessTokens: true` or its options object with
74
+ `accessTokens(storage)` on **the same storage and key namespace**, keeping the
75
+ `access-token:v1:record:*` and `access-token:v1:lookup:*` records: unrevoked
76
+ `cta_…` secrets keep working with no recovery, rewrite, or client rotation. Keep
77
+ the identity namespaces and connector/pool grant callbacks too; storage
78
+ compatibility translates no configuration and invents no grants. `access_token`
79
+ actor ids, the `connecta:access-tokens:v1` activity namespace, friendly names,
80
+ and stored principals carry over; a token without a principal stays unbound.
81
+ Every request evaluates current identity, tool, and pool grants, and a token
82
+ never becomes an interactive operator. Names are labels, never permissions. A
83
+ UI-issued token belongs to the issuing human's principal, and any explicitly
84
+ permitted token manager can list, rename, or revoke deployment tokens. Machine tokens manage neither tokens nor connection credentials; operators
85
+ need an interactive provider.
86
+
87
+ Issuance claims capacity with `compareAndSet`, because counting records before
88
+ writing admits too many concurrent creates; every supported store provides
89
+ it. Active
90
+ capacity defaults to 100, configurable with
91
+ `accessTokens(storage, { maxActive: 200 })` up to 1,000, counted by a durable
92
+ reservation before a secret is written. A failure before lookup publication
93
+ releases it; an uncertain lookup write keeps its metadata and capacity so an
94
+ operator can revoke it, even if creation returned no secret. Creation is never
95
+ retried automatically, and once new-version issuance starts, old-version
96
+ instances must create no tokens: they ignore reservations.
97
+
98
+ Secrets carry 256 random bits; only SHA-256 digests persist. Creation returns
99
+ the secret once, list and rename never. Revocation removes the lookup before
100
+ updating metadata and authorization caches no token, so revocation takes
101
+ effect on the next authorization (D1 and SQLite are strongly consistent);
102
+ admitted requests are not recalled. Management writes
103
+ require an exact same-origin Origin; responses are private and non-cacheable.
80
104
 
81
105
  ## Origins
82
106
 
@@ -92,60 +116,60 @@ silently never matching. A permitted origin still has to authenticate.
92
116
 
93
117
  ## Principals, visibility, and operators
94
118
 
95
- One authorization yields three roles. The **actor** identifies the caller in
96
- activity, the **subject** owns transient results such as `get_result` pages, and
97
- the **principal** is the human owner of personal connector auth. An interactive
98
- Clerk or Access user supplies all three; a Cloudflare service identity has an
99
- actor and subject but no principal.
100
-
101
- The principal is whichever comes first: an explicit `principal: { namespace, id }`
102
- returned by `authorize`, accepted whenever it validates, or else one derived from
103
- `userId` plus the provider's `activityActorNamespace`. A provider that returns its
104
- own principal therefore needs no namespace declared; one relying on the derived
105
- path does.
106
-
107
- A subject or user id always selects a result-stash partition, even with no
108
- `activityActorNamespace` declared — the namespace is then
109
- `connecta:auth:<provider kind>`, and subject ids must be distinct within it. That
110
- fallback grants no personal-auth ownership and changes no activity attribution. A
111
- provider supplying only an explicit principal uses it as the subject too. Open
112
- deployments and providers that return no identity share one result partition.
113
-
114
- `identity.connectorAccess` returns `"all"` — the default — or a list of grants:
115
- a declared connector id opens every tool on it, a `connector.tool` address opens
116
- that tool alone, and `{ tool: "connector.tool", requireReadOnly: true }` opens
117
- that exact tool only while the loaded catalog says it is explicitly read-only.
118
- The guarded form accepts no connector-wide grant. Grants are additive, so a
119
- bare id or an unrestricted address beside a guarded address wins for that
120
- tool. It governs discovery and use alike.
119
+ One authorization yields three roles: the **actor** identifies the caller in
120
+ activity, the **subject** owns transient results such as `connecta.result` pages,
121
+ and the **principal** is the human owner of personal connector auth. An
122
+ interactive Clerk or Access user supplies all three; Access service identities are refused; machines present `cta_` tokens. The principal is an explicit
123
+ `principal: { namespace, id }` from `authorize`, accepted whenever it
124
+ validates, or else one derived from `userId` and the provider's
125
+ `activityActorNamespace`, so only the derived path needs a namespace.
126
+
127
+ A subject or user id always selects a result-stash partition; undeclared, the
128
+ namespace is `connecta:auth:<provider kind>`, where subject ids must be
129
+ distinct, and that fallback grants no personal-auth ownership and changes no
130
+ activity attribution. An explicit principal alone is the subject too. Open
131
+ deployments and providers returning no identity share one result partition.
132
+
133
+ A maintained provider that acts _as_ the caller downstream — `gmail()` through
134
+ Google Workspace domain-wide delegation — reads none of these roles on its own.
135
+ Its `subject` option, a deployment-config function from the admitted identity
136
+ to a downstream account (a Clerk principal id to a Workspace address, say) or
137
+ one fixed address, is applied once per call and only to a request an inbound
138
+ provider authenticated: any `ok`, a subject-less custom credential included, never an open
139
+ deployment's anonymous admission. Otherwise, or on `undefined`, the call fails
140
+ `auth_required` before any request leaves. Arguments, headers, and programs
141
+ never reach it; a deployment's own connectors still cannot read the caller.
142
+
143
+ `identity.connectorAccess` returns `"all"` (the default) or grants, for
144
+ discovery and use alike: a connector id opens every tool on it, a
145
+ `connector.tool` address that tool alone, and
146
+ `{ tool: "connector.tool", requireReadOnly: true }` that tool only while the
147
+ loaded catalog says it is explicitly read-only, never connector-wide. Grants
148
+ are additive, so a bare id or unrestricted address beside a guarded one wins.
121
149
 
122
150
  Tool grants are enforced in the scoped registry view, below the catalog service,
123
- so `search_tools`, both call tools, a program's `connecta.search`,
124
- `connecta.describe`, and `connecta.call`, and the connection UI all read the same
125
- filtered list. Connector-level discovery, guides, and `authorize_connector` keep
126
- a connector when any tool on it is granted, so a `docs.read` grant permits the
127
- `docs` authorization handoff. Without any grant on `docs`, `authorize_connector`
128
- returns the same "Unknown connector" refusal as an absent connector, and an
129
- ungranted tool fails exactly like one the connector never had: `unknown_tool`,
130
- with no hint that it exists. That is the whole security claim, and it lives in
131
- one place on purpose.
132
-
133
- There is no *caller-selected* tool set. A narrower slice is a branch in this
134
- resolver or a config-declared pool; a bot that needs its own slice is its own
135
- bearer subject. What a request may never do is name its own scope.
151
+ so `search_tools`, `describe_tools`, both call tools, a program's
152
+ `connecta.search`, `connecta.describe`, and `connecta.call`, and the connection
153
+ UI read one filtered list. Connector-level discovery, guides, and
154
+ `authorize_connector` keep a connector when any of its tools is granted —
155
+ `docs.read` permits the `docs` handoff. Without one it is absent: discovery
156
+ omits it, and `authorize_connector` returns the same "Unknown connector" refusal
157
+ as for a connector that does not exist. An ungranted tool fails exactly like one
158
+ the connector never had: `unknown_tool`, with no hint that it exists. That is
159
+ the whole security claim, in one place on purpose. There is no _caller-selected_
160
+ tool set: a narrower slice is a branch in this resolver or a pool, and a bot
161
+ needing its own is its own stored token subject. A request may never name its own
162
+ scope.
136
163
 
137
164
  ### Team read sets
138
165
 
139
- `connectorAccess` is the supported way to give Engineering and Marketing
140
- different views of one deployment. Keep membership in deployment code and
141
- review the exact tool addresses you intend to grant:
166
+ Keep team membership in deployment code and review exact addresses:
142
167
 
143
168
  ```ts
144
169
  const engineering = new Set(["engineer-id"]);
145
170
  const marketing = new Set(["marketer-id"]);
146
171
  const engineeringGrants = ["issues", { tool: "campaigns.list", requireReadOnly: true }] as const;
147
172
  const marketingGrants = ["campaigns", { tool: "issues.search", requireReadOnly: true }] as const;
148
-
149
173
  createConnecta({
150
174
  auth: cloudflareAccessAuth(),
151
175
  identity: {
@@ -158,73 +182,85 @@ createConnecta({
158
182
  ])];
159
183
  },
160
184
  },
161
- connectors: [issues, campaigns, billing],
162
- executor,
185
+ connectors: [issues, campaigns, billing], executor,
163
186
  });
164
187
  ```
165
188
 
166
- Here Engineering sees every `issues` tool and only `campaigns.list`;
167
- Marketing sees every `campaigns` tool and only `issues.search`. Neither sees
168
- `billing`. Someone in both groups gets both connectors in full because grants
169
- are additive. An unknown or unprincipalled identity gets an empty view. The
170
- example assumes the two exact tools have been reviewed as read-only; the names
171
- themselves carry no safety meaning. The guarded grants also check the current
172
- loaded annotations before discovery or use. Use a stable authenticated
173
- principal or subject for membership, never a request header or tool argument. A pool can
174
- narrow any of these views on its endpoint, but cannot make plain `/mcp` narrower.
175
-
176
- New tool names are excluded until added to a reviewed list. A removed or
177
- renamed name is unreachable and warned when the scoped view reads its catalog.
178
- For a guarded grant, missing, false, or contradictory read-only annotations
179
- also remove that tool from discovery and every invocation path, including
180
- `call_destructive_tool` and approval-exempt programs. An unrestricted exact
181
- string grant has the earlier behavior: a tool that changes from read to write
182
- stays granted, though `call_tool` refuses it and the write paths take over.
183
- Changing schema alone does not revoke either kind of grant. Review schemas,
184
- annotations, and downstream behavior before changing lists or upgrading a
185
- maintained provider. An arbitrary remote MCP catalog can drift without a
186
- package upgrade; a valid stale cached catalog may keep its earlier
187
- classification until refresh. A downstream can also keep a read-only
188
- annotation while changing the behavior behind it. For `api()` tools, the
189
- deployment author owns both declaration and handler. Connecta enforces the
190
- loaded declaration, not the downstream's promise of no side effects. A
191
- restricted downstream credential adds protection where the provider offers
192
- one, but does not replace the per-identity grant.
193
-
194
- When the resolver returns `[]`, connector-level discovery and the connection
195
- UI have no connectors to show. An address-only grant can leave a connector
196
- visible while its named tool is absent; the tool stays unreachable. A failed
197
- remote catalog load is an error, not an empty successful catalog; a valid stale
198
- catalog may be served within its configured stale window. A guarded grant
199
- whose tool loses qualification can leave its connector visible with no tools.
200
- Personal OAuth ownership and credential-administration permissions remain separate from
201
- visibility. These are the checks for a team read set:
202
-
203
- | Boundary | Expected result for a reviewed address and an excluded write |
204
- | --- | --- |
205
- | `search_tools`, `describe_tools`, connector guides | Show the granted address; omit the write. |
206
- | `call_tool` | Invoke an explicitly read-only granted address; return `unknown_tool` for the write. |
207
- | `call_destructive_tool` | Return `unknown_tool` for the excluded write, without dispatch. |
208
- | `execute_code` search, describe, call | See granted tools only; refuse the excluded write. A granted write still follows the separate approval rules. |
209
- | Connection UI | List granted tools only; never turn visibility into credential administration. |
210
- | `/mcp/<pool>` | Intersect its grants with the identity view; plain `/mcp` keeps the identity view. |
211
-
212
- Integration tests in `test/identity-scope.test.ts` exercise these paths and
213
- the older unrestricted exact-grant behavior.
189
+ Engineering sees all `issues` tools and only `campaigns.list`; Marketing sees all
190
+ `campaigns` tools and only `issues.search`. Neither sees `billing`; both memberships
191
+ grant both connectors in full. Unknown or unprincipalled identities get `[]`.
192
+ Use a stable authenticated principal or subject, never request headers or tool
193
+ arguments. A token's stored principal is vouched for by inbound auth. Pools narrow their endpoint, not plain `/mcp`.
194
+
195
+ | Catalog change | Guarded exact grant | Unrestricted exact string grant |
196
+ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
197
+ | New name | Excluded until reviewed and added | Excluded until reviewed and added |
198
+ | Removed or renamed name | Unreachable; warned when the scoped view reads the catalog | Same |
199
+ | Stored write verdict after overrides and provider review | Removed from discovery and every invocation path, including `call_destructive_tool` and trusted programs | Still granted; `call_tool` refuses writes and write paths take over |
200
+ | Schema change alone | Does not revoke, unless it breaks a reviewed digest: then it is a write, as above | Same as above |
201
+
202
+ The example assumes both exact tools were reviewed as read-only; names imply no safety.
203
+ Review schemas, annotations, and downstream behavior
204
+ before changing lists or upgrading providers. Remote catalogs can drift without
205
+ upgrades; fresh cached facts are classified against the current review on every read; downstreams
206
+ can change behavior while keeping read-only annotations. For `api()`, the author
207
+ owns declarations and handlers. Connecta enforces loaded declarations, not
208
+ absence of side effects. Restricted downstream credentials do not replace grants.
209
+
210
+ `[]` hides all connectors from discovery and the connection UI. Address-only or
211
+ disqualified guarded grants can leave a connector visible with its tool unreachable.
212
+ Failed remote loads are errors, not empty catalogs. A transient first-page tool
213
+ listing failure may serve complete facts in the same auth partition for five
214
+ minutes after expiry. Failed pagination, permission and auth failures do not
215
+ use this fallback. The expired deadline cannot authorize auth recovery. Personal OAuth ownership and credential
216
+ administration are separate from visibility; program writes follow the endpoint's trust tier and host approval. `test/identity-scope.test.ts` exercises these boundaries.
217
+
218
+ ## Migrating static bearer clients
219
+
220
+ The `/auth/bearer` export, `bearerToken`, and its `assertedPrincipal` header
221
+ mode are removed. Existing static secrets cannot be converted into `cta_`
222
+ secrets. Use a separate stored token for each machine or human owner:
223
+
224
+ 1. Configure the existing D1 or SQLite store as `storage`, and install
225
+ `accessTokens(storage)`. Keep its existing keys and namespaces.
226
+ 2. Sign in through Clerk or Worker Access. Grant the intended token manager
227
+ `identity.accessTokenManagement`, then create a token on Access tokens.
228
+ It is bound to that human's principal. For a machine without a human owner,
229
+ provision `new AccessTokenManager(storage).create("machine-name", "deployment-provisioning")` from
230
+ trusted deployment code. The Node template provides `npm run provision-token -- machine-name`.
231
+ 3. Update `identity.connectorAccess` and pool grants to the returned token
232
+ metadata id with `actor.kind === "access_token"`. Names and `tokenPrefix`
233
+ are display labels. Keep existing human principal namespaces unchanged.
234
+ 4. Save the once-returned secret in the client's secret store and send
235
+ `Authorization: Bearer cta_…`. Behind Worker Access, also satisfy the edge
236
+ with Access service credentials; an Access-only machine no longer admits.
237
+ 5. Verify MCP initialization and the intended grants. Confirm the old static
238
+ secret refuses, then remove its environment secret and the old import.
239
+ Remove `X-Connecta-Principal`; a token's stored principal cannot vary by
240
+ request. Provision one human-bound token per represented user instead.
241
+ To retain an old asserted user's personal state, trusted provisioning must
242
+ call `manager.create(name, { namespace: existingNamespace, id: existingId })`
243
+ with the exact existing namespace and canonical id, after applying the old
244
+ admission policy. A Clerk UI token uses the Clerk principal instead and
245
+ cannot inherit a different asserted namespace's partitions.
246
+
247
+ Revocation acts on the next request. Rotate by creating a replacement,
248
+ updating the client, verifying it, and revoking the old token. Keep token text
249
+ out of logs and reports.
214
250
 
215
251
  ## Pools
216
252
 
217
253
  A pool is a named slice of the deployment served at its own endpoint,
218
254
  `/mcp/<pool>`, for when one identity needs different capability sets on different
219
255
  clients: a support agent that sees three Notion tools and Linear, a calendar bot
220
- that sees one tool, both over the same credentials and catalog cache.
256
+ that sees one tool, both over the same credentials. Private remote catalog
257
+ cache entries remain partitioned by the admitted pool.
221
258
 
222
259
  ```ts
223
260
  createConnecta({
224
- auth: [
225
- bearerToken(botSecret, { subjectId: "calendar-bot" }),
226
- clerkAuth({ publishableKey, secretKey }),
227
- ],
261
+ storage,
262
+ accessTokens: accessTokens(storage),
263
+ auth: clerkAuth({ publishableKey, secretKey }),
228
264
  pools: {
229
265
  support: {
230
266
  tools: ["linear", "notion.search_pages", "notion.fetch_page"],
@@ -232,50 +268,34 @@ createConnecta({
232
268
  },
233
269
  calendar_bot: {
234
270
  tools: ["calendar.create_event"],
235
- grant: ({ actor }) => actor.id === "calendar-bot",
271
+ grant: ({ actor }) => actor.kind === "access_token" && actor.id === calendarTokenId,
236
272
  },
237
273
  },
238
274
  identity: { connectorAccess },
239
- connectors,
240
- executor,
275
+ connectors, executor,
241
276
  });
242
277
  ```
243
278
 
244
- Pools are meaningless without configured `auth`: an open deployment builds one
245
- anonymous, non-interactive identity, so grants like these evaluate false and
246
- every pool path 404s. The rules, each of which is a test:
247
-
248
- - **A pool narrows; it never widens.** The view on `/mcp/<pool>` is the pool
249
- intersected with the identity's own `connectorAccess`. Plain `/mcp` is
250
- unchanged. The security boundary is still the resolver; the pool decides which
251
- part of it a given client sees.
252
- - **Grant defaults to deny.** A pool with no `grant` serves nobody. Only a
253
- literal `true` admits; any other return, a throw, and an undeclared pool name
254
- produce one 404 identical in status, body, and headers, reached only after auth
255
- succeeds — so pool names are not anonymously enumerable, and a valid credential
256
- cannot tell the three cases apart by response content. Timing is not hidden: a
257
- declared name awaits its grant while an undeclared name returns without that
258
- lookup. We accept that oracle because names grant no access and a fixed delay
259
- could not hide unbounded grant I/O anyway. Keep grants pure and fast; don't
260
- treat pool names as secrets. The operator log carries the refusal reason.
261
- - **Structural mistakes throw at construction.** A malformed name, an unknown
262
- option, an unknown connector, an empty pool, and a `connector.tool` address an
263
- `api()` connector's static catalog lacks all refuse to boot. Remote catalogs
264
- load lazily, so their addresses are checked at load instead.
265
- - **OAuth discovery follows the path.** On Clerk, the 401 challenge for
266
- `/mcp/<pool>` names `/.well-known/oauth-protected-resource/mcp/<pool>`, whose
267
- `resource` is the pool URL, so RFC 9728 clients see a match. Cloudflare
268
- Managed OAuth is application-level and needs nothing.
269
-
270
- An address the live catalog does not contain is unreachable and warned once
271
- while it sits in a 1,024-entry FIFO, so caller-derived grant text cannot grow
272
- retained warning state without bound. Later catalog drift can never widen a
273
- grant: there is no wildcard, and every tool grant is an exact name.
279
+ Without configured `auth`, an open deployment has one anonymous, non-interactive
280
+ identity; grants like these return false and every pool path 404s.
281
+
282
+ | Rule | Contract |
283
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
284
+ | Scope | `/mcp/<pool>` intersects pool tools with the identity's `connectorAccess`, never widening it. Plain `/mcp` is unchanged; the resolver remains the security boundary. |
285
+ | Grant | Defaults to deny; only literal `true` admits. Other returns, throws, and undeclared names produce an identical 404 status, body, and headers, after auth. Names are not anonymously enumerable; valid credentials cannot distinguish these cases by content. |
286
+ | Timing | Declared names await grants, so timing is not hidden. Keep grants pure and fast and names unsecret; names grant no access. The operator log records the refusal reason. |
287
+ | Construction | Malformed names, unknown options or connectors, empty pools, and addresses absent from an `api()` static catalog throw. Remote catalogs are checked at lazy load. |
288
+ | Discovery | Clerk's pool 401 names `/.well-known/oauth-protected-resource/mcp/<pool>` with the pool URL as `resource`, matching RFC 9728. Cloudflare Managed OAuth is application-level and needs no pool setup. |
289
+ | Drift | No wildcards; tool grants are exact names. Missing live tools are unreachable and warned once while in a bounded 1,024-entry FIFO. |
274
290
 
275
291
  ## Shared and personal auth
276
292
 
277
293
  Connector auth defaults to `authScope: "shared"` — its credential, OAuth state,
278
- tokens, catalog cache, and connector storage belong to the deployment. Set
294
+ tokens and connector storage belong to the deployment. Private remote catalog
295
+ cache entries still include the admitted principal and pool; shared credentials
296
+ do not authorize cross-principal or cross-pool cache reuse. Public entries
297
+ require a downstream `cacheScope: "public"` declaration and contain only
298
+ intake-redacted successes, keyed by connector configuration. Set
279
299
  `authScope: "personal"` when every human needs a separate downstream account:
280
300
 
281
301
  ```ts
@@ -284,155 +304,308 @@ remoteMcp("linear", { url: "https://mcp.linear.app/mcp", authScope: "personal",
284
304
  ```
285
305
 
286
306
  A personal connector is absent — not refused — from any request without a
287
- stable namespaced principal. For a principal that can see one, connecta
288
- partitions connector storage, vault records, catalog caches, OAuth generations,
289
- and observed result shapes under an opaque SHA-256 identity key. Refreshes
290
- coalesce within one owner; another owner's authorization epoch cannot retire
291
- that refresh. Keep namespaces
292
- and principal ids stable across upgrades; changing either selects different
293
- partitions. Literal `auth: { type: "headers" }` cannot be personal, because its
294
- secret lives in deployment code; `remoteMcp()` refuses that combination at
295
- construction.
307
+ stable namespaced principal. For one that can see it, connector storage, vault
308
+ records, OAuth generations, and observed result shapes are
309
+ partitioned under an opaque SHA-256 identity key. Remote catalog cache keys
310
+ include that owner and the admitted pool, plus the credential digest and OAuth
311
+ epoch; refreshes coalesce within
312
+ one owner, and another owner's epoch cannot retire them. Keep namespaces and
313
+ principal ids stable across upgrades, since changing either selects different
314
+ partitions. Literal `auth: { type: "headers" }` keeps its secret in deployment
315
+ code, so `remoteMcp()` refuses it as personal at construction.
296
316
 
297
317
  ## Downstream OAuth state at rest
298
318
 
299
- With a vault configured, a `remoteMcp()` OAuth connector's tokens, dynamically
300
- registered client (secret included), and PKCE verifier are sealed with the
301
- vault's AES-GCM key before they reach storage. The additional authenticated data
302
- names the connector, the owner partition, and the physical key, which carries
303
- the authorization epoch, so ciphertext copied to another connector, principal,
304
- or epoch does not open. Anything that fails to open — a tampered value, a
305
- rotated key — reads as absent: the connector reports `auth_required` and logs a
306
- warning. The flow bookkeeping stays plaintext: `state`, the pending URL,
307
- discovery metadata, and the generation. The callback route reads `state`
308
- directly, and none of it authenticates anything by itself.
309
-
310
- Plaintext left by an older release is read, then sealed where it lies through
311
- the same generation fence as any write, so an upgrade keeps the grant. Sealing
312
- is one-way. An older release reads sealed state as unusable, so rolling back
313
- means authorizing again. A vault without the optional `seal`/`open` members
314
- keeps these values plaintext and draws a startup warning. Without any vault,
315
- nothing changes: the state is plaintext, as it always was.
316
-
317
- ## Starting, restarting, and retiring an OAuth epoch
318
-
319
- Every downstream OAuth value lives under an **epoch**, the value of
320
- `oauth:generation`. A restart or disconnect publishes a new one before it
321
- deletes anything, and a write from a flow that captured an older epoch lands
322
- in that epoch's own key namespace, where no reader looks. The fence is what
323
- keeps a retired grant out of use; deleting the old keys is hygiene on top.
324
-
325
- The operator's `POST /ui/oauth/<id>` takes a `mode` query parameter:
326
-
327
- | Request | What it does |
328
- | --- | --- |
329
- | `POST /ui/oauth/<id>` or `?mode=restart` | `startAuth(ctx, { force: true })`: a new epoch, with tokens, the registered client, discovery, and any pending flow wiped, so the next flow registers a client again. |
330
- | `?mode=continue` | `startAuth(ctx, { force: false })`: hand back the pending authorization URL if it was written in the last ten minutes; otherwise start a flow in the current epoch, reusing the stored client registration. A disconnected connector still gets its new epoch first. |
331
- | `DELETE /ui/oauth/<id>` | Disconnect: a disconnected epoch that passive reads never turn back into a consent flow. |
332
-
333
- Any other `mode`, or more than one, is a 400 before anything starts; the
334
- management permission, visibility, and principal checks are the same for
335
- both modes, and a personal connector continues only the caller's own flow.
336
- A 200 answers `{ state, authorizationUrl?, reused? }`, with `reused` present
337
- beside every URL: `true` when an earlier start's URL came back unchanged. A
338
- continue that reused a URL or found the connection healthy changed nothing,
339
- so it leaves the cached catalog alone; every other start invalidates it. The
340
- ten minutes are `PENDING_AUTHORIZATION_MAX_AGE_MS`. Connecta never expires
341
- its own half of a URL, but authorization servers expire theirs, and a fresh
342
- start that keeps the registration costs one authorization request. A URL
343
- stored without a write time — by an earlier release, or by a connector never
344
- reset since epochs arrived — is stale by definition. `authorize_connector`
345
- without `force` follows the same rule.
346
-
347
- Continue trusts the stored registration, which carries the `redirect_uris`
348
- it was registered with. After a `publicUrl` change the authorization server
349
- refuses that client's new callback at consent. Restart registers a client
350
- for the current URL and recovers.
351
-
352
- A restart cannot know which retired epochs a late write reached, so it
353
- publishes a **cleanup lineage** under the new epoch before activating it:
354
- the retired epochs, as the same plain list of names every release reads, and
355
- beside it a record of when each retired. A published lineage is never
356
- rewritten. A stale writer whose own cleanup fails appends to the live
357
- lineage, and a restart that rewrote the list it had read could drop that
358
- append. An epoch leaves the lineage only by being left out of its
359
- successor's.
360
-
361
- A restart does the same storage work however many came before it. After the
362
- fence it deletes the one epoch it retired: six values, then that epoch's own
363
- lineage records. An epoch's manifest outlives its values only when their
364
- deletion failed, so the restart also reads the manifests of the eight most
365
- recently retired epochs (`RETRY_PROBES`) and cleans up again any that still
366
- have one. A Disconnect or Restart that reported a failed cleanup therefore
367
- deletes the old grant when the operator retries it. Before publishing, it
368
- sweeps up to 16 epochs retired more than `CLEANUP_GRACE_MS` (24 hours) ago,
369
- oldest first, and leaves out only those whose values and records were all
370
- deleted. A failed sweep is carried and tried again later, and never fails
371
- the restart. Any other epoch inside its grace is not deleted again. A late write into it is unreadable behind the fence,
372
- and the late writer deletes it itself. If that delete fails, the writer
373
- records the epoch again as retired at that moment, appending it or, when it
374
- is already listed, moving only its time. A restart that swept it re-reads the
375
- lineage before publishing and keeps it. Whatever remains is swept once the
376
- grace has passed. Two such writers racing can lose one time update, which
377
- leaves the earlier time; a time missing altogether reads as retired at the
378
- moment of the restart that reads it, which only lengthens a grace.
379
-
380
- **The assumption:** no request holds a retired epoch for a day. The writers
381
- that can land late are OAuth flows, refreshes, and readers whose Workers KV
382
- replica still serves the old generation for a minute or more; none of them
383
- run that long. If it fails, only a writer that dies between its write and
384
- its own cleanup leaves residue no restart tracks, and that residue is never
385
- readable.
386
-
387
- The lineage holds at most 5,000 epochs, the ones retired within the last day
388
- plus any the sweep has not reached. A restart that would exceed that is
389
- refused before the fence moves, which takes more than 4,000 restarts of one
390
- connector in a day on top of the 1,000 an earlier release allowed. A
391
- connector that reached that release's 1,000 wall restarts again the day it
392
- upgrades, and its old entries drain 16 per restart a day later. An older
393
- release ignores the times, so a rollback still restarts unless a lineage has
394
- grown past its own 1,000 cap. Deletes run six at a time, the Workers limit
395
- on simultaneous connections, and catalog invalidation deletes its chunks
396
- concurrently under the registry's chunk I/O bound.
319
+ Each owner of an OAuth connector (the deployment when shared, each principal
320
+ when personal) has one **grant record**, `oauth:grant` in the connector's
321
+ namespace: the live **epoch**, the latest consent's state digest, and one
322
+ authorization server's client registration, tokens, and discovery. Each
323
+ consent has a **flow record**, `oauth:flow:<sha256(state)>`, holding its
324
+ epoch, consent URL, and PKCE verifier for fifteen minutes; the state itself is
325
+ stored nowhere. Keys come from `src/storage/keys.ts`.
326
+
327
+ With a vault, the grant's body and each verifier are sealed with its AES-GCM
328
+ key, the additional authenticated data naming the connector, owner partition,
329
+ record, and epoch, so ciphertext moved to another connector, principal,
330
+ record, or epoch does not open. Anything that fails to open (tampered, or
331
+ under a rotated key) reads as absent: `auth_required` and a logged warning.
332
+ The epoch, consent URL, and state digest stay plaintext and authenticate
333
+ nothing. A plaintext body written before the vault could seal is sealed where
334
+ it lies on first read, by compare-and-set. An [`api()` OAuth
335
+ connector](#downstream-oauth-on-api) stores no client. A vault without the
336
+ optional `seal`/`open` members keeps the body plaintext with a startup
337
+ warning; no vault, plaintext as always.
338
+
339
+ **From 0.28 (layout 2).** The first read of an owner's grant that finds no
340
+ record reads layout 2 once: the active generation's issuer-stamped client,
341
+ tokens, and discovery (sealed values open under their old keys), kept only
342
+ when every credential carries a stamp and the stamps agree with each other
343
+ and with discovery. It writes them as the grant record by compare-and-set,
344
+ then deletes every layout-2 key, cleanup lineage included. A disconnected
345
+ connector stays disconnected; a cancelled request migrates nothing. Grants
346
+ from before issuer binding (v0.8.1 and earlier) and pending consents are not
347
+ carried, so those connectors need Connect again. Rolling back past this
348
+ release finds no layout-2 grant and needs consent.
349
+
350
+ ## Starting, restarting, and disconnecting
351
+
352
+ Every grant write (registration, discovery, tokens, a refresh, a consent's
353
+ pointer, an invalidation) reads the record and replaces it by compare-and-set
354
+ against exactly what it read, so no write overwrites one it did not see.
355
+ Every flow (a connect attempt, an `api()` call or start, a callback) binds to
356
+ the epoch it began in. Once another epoch is live its reads fail, its writes
357
+ land nowhere, and it fails retryable `unavailable`: "authorization changed
358
+ while this request was in flight; try again". Restart and Disconnect replace
359
+ the epoch, and the grant with it, in one compare-and-set, so no older
360
+ namespace is left to clean up. They then delete each consent whose epoch is no
361
+ longer live; one left behind cannot complete and expires with its link.
362
+ `POST /ui/oauth/<id>` takes a `mode`:
363
+
364
+ | Request | What it does |
365
+ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
366
+ | `POST /ui/oauth/<id>` or `?mode=restart` | Issues a signed `/connect/<id>` link requesting a fresh epoch. No downstream authorization starts until the verified browser visits it. |
367
+ | `?mode=continue` | Issues a signed `/connect/<id>` link requesting continuation. At the browser visit, a recent pending consent can be reused; otherwise authorization begins in the current epoch. |
368
+ | `GET /connect/<id>?h=...` | Verifies the signed handoff and, when signed in, browser identity, connector visibility and management permission. With the UI mounted, hands off to `/connectors/<id>?h=...#auth` without consuming the link or starting OAuth; Clerk sign-in uses that shell. The Auth tab continues with `start=1`, which repeats verification, consumes the link and calls `startAuth`. Without the UI, the original browser visit starts consent. Explicit `POST /ui/oauth/<id>` actions return a signed link with `start=1` when the UI is mounted. |
369
+ | `DELETE /ui/oauth/<id>` | Disconnects and invalidates the cached catalog, even if the browser leaves. |
370
+
371
+ `authorize_connector` issues the same browser link, with `force` carried in the
372
+ signed handoff. Status reads never call `startAuth` and never expose the
373
+ provider's consent URL. A UI start answers `{ state: "auth_required",
374
+ authorizationUrl }`, where the URL belongs to connecta. Continue hands back the
375
+ latest consent only while it is unclaimed, in the live epoch, written within
376
+ ten minutes, and names the grant's client. The cached catalog stays only when
377
+ an unforced browser visit reuses that consent or finds the connection
378
+ healthy. Restart invalidates it even if the connection ends healthy. Continue
379
+ trusts the stored registration's `redirect_uris`; after changing `publicUrl`,
380
+ Restart registers for the current callback URL.
381
+
382
+ ### Consents and callbacks
383
+
384
+ A start stores its flow record, then the grant's pointer to it. A reset that
385
+ replaced the epoch in between fails the start and removes the record, so no
386
+ start hands out a consent its callback could not complete. The callback finds
387
+ its consent by the state's digest, one read whether or not it exists. The
388
+ exchange then:
389
+
390
+ 1. binds to the consent's epoch and its issuer, client ID, token endpoint, and
391
+ discovery digest, noting the client and tokens the grant holds. A supplied
392
+ RFC 9207 `iss` must equal the consent's issuer;
393
+ 2. after every read it depends on, claims the consent by compare-and-set from
394
+ the exact record found to a claimed one keeping neither URL nor verifier.
395
+ Of duplicate callbacks exactly one wins; the rest send nothing and get the
396
+ flat 400 for an already-used link;
397
+ 3. after the claim, opens the current grant and checks its issuer, client ID,
398
+ token endpoint, and discovery against the consent. It re-reads that exact
399
+ record after opening ciphertext and sends the code in the reaction to that
400
+ read. Changing issuers advances the epoch as well. A
401
+ reset published before the read fails the callback with nothing sent. One
402
+ published while the request is leaving cannot be ordered before the send
403
+ without a lock across requests; the grant's compare-and-set refuses its
404
+ tokens, which costs one more consent.
405
+
406
+ The claim is spent whatever the exchange's outcome. The SDK's one retry after
407
+ a refused code receives that refusal and sends nothing. A refused code
408
+ invalidates the client or tokens only as step 1 found them, never what another
409
+ flow wrote meanwhile. Consumption is recorded on that consent alone, so a
410
+ delayed duplicate never deletes or invalidates a newer consent Continue
411
+ published. An exchange no callback verified claims only the consent its
412
+ callback's `state` names: programmatic `finishAuth`, or a PKCE-less `api()`
413
+ exchange. Both built-in `finishAuth` adapters require `callbackParams` with a
414
+ nonempty `state`, even with PKCE disabled or after a separate `verifyState` call.
415
+ A missing state is refused before discovery, registration, or token dispatch.
416
+
417
+ Layout-2 migration commits `cleanupPending` in the new grant before deleting
418
+ historical keys. Every later grant read retries deletion, then clears the
419
+ marker by CAS only when deletion succeeds. Restart preserves this obligation.
420
+ Disconnect commits a tombstone with the marker and always removes historical
421
+ keys for the connector and owner, including when a modern grant already exists.
422
+ A crash during cleanup cannot restore the disconnected grant.
423
+
424
+ Disconnect first removes the grant and fences its epoch with CAS. When that
425
+ removed grant's saved authorization-server metadata advertises a revocation
426
+ endpoint, Connecta makes one RFC 7009 request using that same issuer and client.
427
+ It revokes the refresh token when present, otherwise the access token. It never
428
+ rediscovers an issuer or sends credentials to a replacement server on Disconnect.
429
+ The endpoint must use HTTPS, with HTTP allowed only for the existing loopback
430
+ development exception. Trusting a configured HTTP MCP origin does not permit
431
+ plaintext public revocation. The advertised-URL destination guard also applies,
432
+ and revocation never follows redirects. The request has its own twenty-second deadline; browser
433
+ cancellation cannot undo local removal. It reads no provider response text.
434
+
435
+ A revocation refusal, timeout, network failure, or unsafe advertised destination
436
+ leaves the local grant removed. The management route returns HTTP 200 with
437
+ `{ state: "auth_required", code: "oauth_revocation_failed" }` and logs that typed
438
+ code. Revoke the old grant in the provider's console if removal there matters.
439
+ With no advertised endpoint, Disconnect makes no request. Repeating Disconnect
440
+ never retries revocation, because the removed grant is no longer available.
441
+ Restart makes no revocation request. Static `api()` OAuth has no discovered
442
+ revocation endpoint and continues to remove authorization locally.
397
443
 
398
444
  ## Refresh failures
399
445
 
400
- A failed token refresh means one of two things, and connecta decides which
401
- from the token endpoint's answer, not the SDK's reading of it. A **dead grant**
402
- is any 4xx except 408, 425, and 429, or a 2xx carrying an OAuth `error` (GitHub
403
- answers `200 {"error":"bad_refresh_token"}`). It ends as `auth_required`, and
404
- the refused tokens are deleted on the spot, so no later request or isolate
405
- sends them again and the next `authorize_connector` goes straight to consent.
406
- An **outage** is a 5xx, 408, 425, 429, a network failure, or a 2xx that is not
407
- a token response. It ends as retryable `unavailable`, or `rate_limited` for a
408
- 429, with `retryAfterMs` when the server sent `Retry-After`. The grant is kept,
409
- and a passive call writes no consent URL. Every request joined on the same
410
- in-flight refresh gets the same verdict, even when the request that sent the
411
- refresh is cancelled after the answer arrives: the refused tokens are deleted
412
- before anyone waiting is released, so a request arriving meanwhile joins the
413
- refusal instead of sending the dead token again.
414
-
415
- The SDK needs this help. On a refresh failure it cannot parse, or one marked
416
- `server_error`, it falls through to starting authorization, which reports a
417
- healthy grant as needing consent. It rethrows every other OAuth error
418
- untouched, which reports a dead grant as a generic outage and keeps re-sending
419
- it. So the refresh coordinator hands the SDK an answer it classifies correctly,
420
- and the provider hooks it calls next finish the job. The pinned SDK behavior is
421
- spelled out beside `refreshResponseOutcome`. An explicit authorization during
422
- an outage still falls through to consent, since that is what it asked for.
446
+ A refresh token is spent when dispatch begins. Every refresh request passes
447
+ through `KvOAuthProvider.dispatchRefresh`. Before sending, the gate wins the
448
+ lease's `claimed` to `dispatched` CAS and a separate fingerprint CAS at
449
+ `oauth:refresh-spent:<sha256(refresh_token)>`. The latter must succeed before
450
+ any HTTP request leaves. Connector and owner storage namespaces partition both
451
+ records. Spent records have **no TTL**, contain no token, and are never deleted
452
+ by completion, failure, migration cleanup, Restart, Disconnect, or epoch sweeps.
453
+ Copy these durable records during storage migration.
454
+
455
+ Each fingerprint record names its dispatch epoch, holder, and resolution state:
456
+
457
+ | State | Send gate |
458
+ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
459
+ | `outstanding` | Dispatched without a definitive outcome yet. Refuse the fingerprint in every epoch. |
460
+ | `ambiguous` | The sent request's outcome is unknowable. Refuse the fingerprint in every epoch, including when re-consent returns it again. |
461
+ | `resolved` | The tokens were committed or a definitive failure was recorded. Refuse another send in the same epoch. A later epoch may send once only when its code exchange completed after the resolution was recorded. |
462
+
463
+ Resolution writes use CAS against the exact outstanding record and record a
464
+ `resolvedAt` timestamp with a resolved outcome. When accepting a code response,
465
+ the coordinator reads the returned refresh token's fingerprint record. Consent
466
+ stores the exact resolved record it observed with the grant's tokens. That
467
+ receipt proves resolution preceded consent completion without comparing
468
+ isolate clocks. A delayed SDK save cannot acquire a newer receipt. The send
469
+ CAS compares against that same resolved record and replaces it with the new
470
+ outstanding dispatch. An earlier consent cannot become eligible merely because
471
+ the pending refresh later resolves. Competing epochs cannot both replace the
472
+ same record, and a late answer cannot turn an ambiguous record into resolved.
473
+
474
+ An ambiguous token stays blocked even if re-consent returns identical bytes.
475
+ Recovery requires re-consent that yields a different refresh token, or
476
+ revocation at the provider. Restart never deletes spent records or moves old
477
+ credentials into a new epoch.
478
+
479
+ Credential-bearing token-endpoint requests never follow redirects, regardless
480
+ of `remoteMcp()`'s `redirects` setting. The send gate uses `redirect: "manual"`
481
+ and bypasses the resource redirect wrapper for refresh, authorization-code,
482
+ and client-credentials grants and token revocation. Static `api()` OAuth uses
483
+ the same gate and manual fetch. Any 3xx is a definitive failure, even if its
484
+ body contains tokens. Fetch has already sent the request body; a refresh
485
+ fingerprint is resolved as a definitive failure and the grant requires re-consent. A
486
+ redirected code exchange is refused and its SDK retry cannot resend the code.
487
+
488
+ | Outcome after dispatch | Result |
489
+ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
490
+ | Valid tokens durably committed | Mark the fingerprint resolved and release waiters with committed tokens. Within the epoch only a new fingerprint can be dispatched next. |
491
+ | Definitive provider failure | `auth_required`. Mark the fingerprint resolved and conditionally remove its grant tokens. Includes every 3xx without following it and complete failures such as 5xx, 408, 425, and 429. |
492
+ | Network or response-body loss, malformed or oversized success, deadline expiry, or process crash | `auth_required`. Mark the fingerprint ambiguous and conditionally remove its grant tokens. Identical-token re-consent cannot reopen it. |
493
+ | Valid rotation whose grant commit retries are exhausted | `auth_required`. Record the definitive commit failure as resolved and conditionally remove its grant tokens. Never return uncommitted tokens to the SDK. |
494
+ | Epoch changed during commit | Drop the response tokens and record the definitive failure as resolved. Restart, Disconnect, or issuer replacement determines the newer grant. A consent that completed before this resolution cannot reuse its fingerprint. |
495
+
496
+ Refreshes coalesce per owner and epoch within a runtime. Across isolates, a
497
+ shared-storage record at `oauth:refresh:<epoch>:<sha256(refresh_token)>` is
498
+ claimed by CAS before dispatch. Only an expired unsent claim can be taken
499
+ over. Its old holder loses the dispatch CAS. A dispatched record cannot be
500
+ reopened by a completed answer, a verdict, or a newcomer read.
501
+
502
+ The separate holder record at `oauth:refresh-active:<epoch>:<holder>` has a
503
+ storage-owned 120-second TTL. SQLite and D1 create and check expiry with the
504
+ database clock inside each statement. Custom shared stores must also use a
505
+ storage-owned clock. Cross-isolate clock comparisons never permit takeover of
506
+ a dispatched record. The refresh HTTP request and response body have a
507
+ 20-second deadline. Contenders wait at most 35 seconds with 10 ms to 250 ms
508
+ backoff. A waiter deadline returns retryable `unavailable`, keeps the grant,
509
+ and starts no consent. It never permits resending a spent token.
510
+
511
+ One preparation deadline covers initial epoch and grant reads, claim acquisition,
512
+ dispatch preparation, and local joiners waiting for a grant commit. Cancellation
513
+ before sending settles the local flight without awaiting cleanup storage. An
514
+ unsent claim can expire and be taken over. Late preparation never sends. If
515
+ preparation recorded the fingerprint as spent, it stays spent even if the send
516
+ was subsequently cancelled or a local destination guard refused it.
517
+
518
+ After dispatch, caller and SDK cancellation end only that caller's wait. The
519
+ exchange and grant commit continue under the HTTP deadline. The runtime passes
520
+ completion to Workers `waitUntil`; paths without that hook keep a background
521
+ promise. The holder re-reads the grant and retries the commit with its in-memory
522
+ response tokens up to 32 times after CAS contention or storage errors. Each
523
+ attempt checks the epoch, issuer, and current refresh token. A changed epoch
524
+ drops the response tokens. Another consent's credentials remain intact. A lost
525
+ commit answer can be recovered by reading the committed grant. Exhausting the
526
+ commit retries requires re-consent.
527
+
528
+ Lease release uses CAS after the commit or re-consent decision. Completion
529
+ records include a SHA-256 digest of the committed token response, excluding
530
+ the local issuer stamp. Waiters can adopt that committed response even when
531
+ it kept the refresh token or was byte-identical. A newcomer cannot clear the
532
+ completion record to redeem that fingerprint again. Verdict records contain
533
+ fixed typed facts, without tokens or downstream text.
534
+
535
+ If a dispatched holder crashes, shared-storage liveness expiry records
536
+ re-consent and conditionally removes its grant tokens. A late response cannot
537
+ restore them. Spent records already prohibit replay, including when refusal
538
+ recording or token cleanup fails. Restart and Disconnect sweep obsolete lease
539
+ records while retaining every spent record. Holder liveness remains until
540
+ completion or storage-owned TTL expiry, so reset cannot turn a live request
541
+ into an ambiguous one. The SDK receives
542
+ sanitized failure responses, and provider hooks preserve the re-consent verdict
543
+ for passive calls. A source-level guard pins both OAuth adapters to this single
544
+ send gate, manual token fetches, and a separate resource redirect path.
545
+ Connector status reports `auth_required`; agent calls report
546
+ `downstream_oauth_required` with the authorization recovery action. A 403
547
+ during a dispatched refresh also requires re-consent because its fingerprint
548
+ is already spent. Provider permission denials on other calls retain their
549
+ `provider_permission_denied` recovery.
550
+
551
+ Refresh and failed code-exchange answers are rebuilt from the OAuth `error`
552
+ code alone with fixed text. The SDK logs descriptions below the configured
553
+ logger; downstream descriptions and storage errors can quote tokens, client
554
+ secrets, or codes and must not reach that output.
555
+
556
+ ## What a server's errors may say
557
+
558
+ No authorization, token, registration, or discovery server's text reaches an
559
+ agent: it can echo the request or plant words for an agent. No server's text
560
+ reaches a log, status message, or activity row at all
561
+ ([records](./architecture.md#errors-and-records)). A failed
562
+ OAuth flow names its step, the host of that flow's own last request (each flow
563
+ keeps its own, so concurrent calls never borrow one), and for a refused
564
+ registration the status and a registered OAuth `error` code. Google's delegated
565
+ token endpoint gets the same treatment. At the MCP handshake, `tools/list`, and
566
+ `tools/call`, an error keeps its text only if it is the downstream's JSON-RPC
567
+ error answer, the endpoint's HTTP 4xx refusal, or the request's own abort reason
568
+ (checked first, by identity or as the SDK rewraps it). Anything else, a parser's,
569
+ validator's, runtime's, or non-4xx body's account included, becomes the step,
570
+ host, HTTP status if any, and error class. An `api()` handler's failure other
571
+ than a `ConnectorCallError` names its tool and class, and an argument mismatch
572
+ is told from the reviewed findings, never the validator's sentence. Every
573
+ withheld error keeps its original classification, and no connector error keeps
574
+ a runtime, stream, or parser error as `cause`.
575
+
576
+ The JSON-RPC and 4xx exception preserves diagnostics agents need to correct
577
+ their arguments. Core redacts credentials used by that call before these
578
+ messages, nested causes/data, or tool results reach an agent or guest program.
579
+ The memory-only set covers credential-slot values, static auth headers,
580
+ outbound bearer tokens, and their JSON-escaped, URL-encoded, base64 and
581
+ base64url forms. Final outgoing requests register Authorization,
582
+ Proxy-Authorization, Cookie, and headers or query parameters whose names
583
+ contain `key`, `token`, `secret`, `auth`, `signature`, or `session`. Custom
584
+ `api()` handlers use `ctx.fetch` for this tracking; `ctx.oauth.fetch` and
585
+ maintained-provider transports track the final request too. Credential-slot
586
+ reads cover values sent in headers, queries, or bodies. Echoed sensitive
587
+ header lines are also withheld.
588
+
589
+ Only credentials of at least eight characters enter the redaction and structural
590
+ matchers. Credentials shorter than 8 characters are not redacted from echoes;
591
+ use longer secrets. Matching short secrets can corrupt JSON-RPC fields or refuse
592
+ legitimate OAuth metadata. A configured static secret or Basic password below
593
+ this floor produces one operator warning per connector with the typed code
594
+ `short_secret_not_redacted` and no credential value. Remote connectors warn at
595
+ construction; static `api()` OAuth warns on its first provider operation.
596
+ Connecta's own messages never quote credential values, regardless of length.
597
+ One matcher is cached until the secret set changes, and an empty set skips matching. Redaction runs after
598
+ JSON unwrapping or joining text blocks and on final serialized text and every
599
+ structured string, before result paging, emits, and program outputs/errors/logs. See
600
+ [the agent boundary](./architecture.md#errors-and-records).
423
601
 
424
602
  ## URLs a downstream advertises
425
603
 
426
- A `remoteMcp()` OAuth connector learns most of the URLs it fetches from the
427
- downstream itself: the `resource_metadata` in its 401 challenge, the
428
- `authorization_servers` in that metadata, and the token and registration
429
- endpoints the authorization server publishes. Connecta fetches every one of
430
- them server-side, so a compromised downstream could otherwise aim a Node or
431
- Docker host at its own network — cloud metadata at `169.254.169.254`, an admin
432
- panel on the LAN. Config is the security model, so the rule splits on it:
604
+ `remoteMcp()` learns server-side fetch URLs from a 401's `resource_metadata`,
605
+ its `authorization_servers`, and advertised token and registration endpoints.
606
+ To prevent a downstream from targeting the host's private network:
433
607
 
434
- - A URL on the connector's configured origin is trusted. The operator wrote it
435
- down.
608
+ - A URL on the connector's configured origin is trusted; the operator wrote it.
436
609
  - Any other URL must be `https` and must not name a private host: `localhost`
437
610
  and `*.localhost`, `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`,
438
611
  `100.64/10`, `0/8`, `::`, `::1`, `fc00::/7`, `fe80::/10`, or an IPv4-mapped
@@ -442,39 +615,75 @@ panel on the LAN. Config is the security model, so the rule splits on it:
442
615
  working. LAN and link-local addresses stay refused.
443
616
 
444
617
  A refused URL is never requested. Discovery, registration, and code exchange
445
- fail with a non-retryable `connector_call_failed` that names the host and
446
- nothing else from the URL. A refused refresh never reaches the token endpoint,
447
- so there is no verdict to act on: the grant is kept, and the SDK falls through
448
- to consent as it does for any refresh it could not complete. Redirects cannot
449
- route around the rule, because the redirect policy only follows same-origin
450
- hops.
451
-
452
- The check is syntactic. It reads the host after the WHATWG URL parser has
453
- folded `2130706433` and `0x7f.1` into `127.0.0.1`, and it never resolves a
454
- name, because the Workers-safe core has no DNS. A public name that resolves to
455
- a private address, or rebinds to one after the check, is out of scope; a host
456
- that must stop that runs connecta behind an egress policy that does.
618
+ fail with a non-retryable `connector_call_failed` naming the host and nothing
619
+ else from the URL. A refused refresh never reaches the token endpoint. If the
620
+ send gate already recorded its fingerprint, that fingerprint remains blocked
621
+ and the grant requires re-consent. Redirects cannot route around the
622
+ rule; token requests never follow them, and resource redirects follow only
623
+ same-origin hops.
624
+
625
+ The check is syntactic: it reads the host after the WHATWG URL parser folds
626
+ `2130706433` and `0x7f.1` into `127.0.0.1`, and never resolves a name — the
627
+ Workers-safe core has no DNS. A public name resolving or rebinding to a private
628
+ address is out of scope; a host that must stop that needs an egress policy.
629
+
630
+ Downstream advertisements choose consent destinations, never destinations for
631
+ existing grants. Tokens and registered clients bind to their issuing server;
632
+ cached discovery sends refresh there despite changed advertisements. Fresh
633
+ discovery naming another issuer replaces the grant's body (see [one server per
634
+ grant](#one-server-per-grant)), so it receives no existing credentials and
635
+ registers and consents anew. The SDK
636
+ also refuses [GHSA-6qxp-vccf-f47h](https://github.com/advisories/GHSA-6qxp-vccf-f47h)
637
+ since client 2.2.0. New consent still follows the downstream's URL; the human
638
+ must read it before approving.
639
+
640
+ Grants from before issuer binding (v0.8.1 and earlier) have no stamp and are
641
+ not migrated (see [state at rest](#downstream-oauth-state-at-rest)).
642
+
643
+ ### One server per grant
644
+
645
+ A grant record names one authorization server, its `issuer`. Saving
646
+ discovery, a client, or tokens for another server replaces the body, and an
647
+ issuer-aware read hands a value only to the server that issued it. The SDK
648
+ discovers afresh only without cached discovery, after a forced restart's
649
+ carried client for example; a different server found there is handed nothing
650
+ and registers anew. Nothing is decided at flow entry and no hook retires
651
+ anything, so SDK hook order cannot mix servers or cross a reset.
457
652
 
458
653
  ## Management permissions
459
654
 
460
- Visibility alone grants no authentication-management permission. Two independent
461
- resolvers take `Readonly<AuthenticatedIdentity>`, return `"all"`, `"none"`, or
462
- declared connector ids, and both default to `"none"`:
463
- `credentialAdministration` for shared credentials and shared OAuth grants, and
464
- `personalConnection` for a human's own grants on personal connectors. Each action
465
- needs visibility *and* the relevant permission; both resolvers run only for an
466
- interactive identity; personal actions additionally need a stable namespaced
467
- principal and always use that principal's partition. Resolver exceptions and
468
- unknown ids fail closed, and permissions come from authenticated identity, never
469
- from caller input.
655
+ Visibility grants no authentication-management permission. Two resolvers take
656
+ `Readonly<AuthenticatedIdentity>` and return `"all"`, `"none"` (the default),
657
+ or connector ids: `credentialAdministration` for shared credentials and OAuth
658
+ grants, `personalConnection` for a human's own grants on personal connectors.
659
+ Each action needs visibility _and_ the permission; both run only for an
660
+ interactive identity, and personal actions also need a stable namespaced
661
+ principal, whose partition they always use. Exceptions and unknown ids fail
662
+ closed; permissions come from authenticated identity, never caller input.
470
663
 
471
664
  `identity.activityAccess` takes `Readonly<IdentityReference>` — `id` and
472
- `namespace` — and controls reading global activity. Undeclared, it admits every
665
+ `namespace` — and controls reading activity. When configured, an authenticated
666
+ principal may pass it without an interactive session. This includes a `cta_`
667
+ token's stored principal and a non-interactive API principal; subject-only
668
+ callers and anonymous requests cannot qualify. The optional activity `readGate`
669
+ also applies. Reads remain GET-only and filter connector/tool grants, personal
670
+ ownership and recorded pool access before any actor-label lookup. Personal rows
671
+ use the admitted principal in the existing typed actor id and namespace fields;
672
+ rows without a provable owner are withheld. Non-interactive
673
+ reads return typed actors without directory labels. Old rows without a pool
674
+ remain subject to connector, tool and owner checks. Undeclared, it admits every
473
675
  interactive human, the one default here that is open, because a single-operator
474
676
  deployment would otherwise be locked out of its own event stream. Team
475
677
  deployments should set it. There is no general administrator role and no
476
678
  implicit token-management authority. `identity.accessTokenManagement` is a separate boolean permission, false by default, evaluated only for interactive humans. Lifecycle routes also require a stable principal.
477
679
 
680
+ Personal activity rows must carry the checked `principal` actor basis as well as
681
+ a matching principal ID and namespace. Legacy subject actors cannot prove
682
+ personal ownership and are withheld. Activity pagination advances past hidden
683
+ boundary rows so their timestamp and event ID never appear in a returned cursor.
684
+ Each read belongs to the request fiber. A scan exceeding 1,000 additional
685
+ boundary reads returns 503 without a cursor; cancellation starts no further read.
686
+
478
687
  ```ts
479
688
  createConnecta({
480
689
  auth: cloudflareAccessAuth(),
@@ -490,42 +699,36 @@ createConnecta({
490
699
  personalConnection: () => ["personal_linear"],
491
700
  activityAccess: ({ id }) => id === "owner-id",
492
701
  },
493
- connectors,
494
- executor,
702
+ connectors, executor,
495
703
  });
496
704
  ```
497
705
 
498
706
  ## Cloudflare Access on Workers
499
707
 
500
708
  [`cloudflareAccessAuth()`](https://developers.cloudflare.com/workers/configuration/cloudflare-access/)
501
- is the Worker-specific path. `ethos.md` records it as **provisional**: Managed
502
- OAuth and the Clerk migration still want production evidence
503
- ([#506](https://github.com/zackbart/connecta/issues/506)).
709
+ is the supported Worker-specific path under
710
+ [#703](https://github.com/zackbart/connecta/issues/703). Node uses Clerk;
711
+ Access trusts the identity validated at the Worker edge.
504
712
 
505
713
  ```ts
506
- import { cloudflareAccessAuth } from
507
- "@zackbart/connecta/auth/cloudflare-access";
508
-
714
+ import { cloudflareAccessAuth } from "@zackbart/connecta/auth/cloudflare-access";
509
715
  createConnecta({ auth: cloudflareAccessAuth(), connectors, executor });
510
716
  ```
511
717
 
512
- The adapter trusts only `ctx.access`, which Cloudflare creates after Access has
513
- authenticated a request that directly invokes the Worker, and it reads identity
514
- through `ctx.access.getIdentity()`. A human yields `user_uuid` or `email` as both
515
- user and subject. A *service* identity yields `service_token_id`, or failing that
516
- `common_name`, so distinct service tokens normally get distinct attribution;
517
- either identity kind with no usable id is a 403. Only when Access returns no
518
- identity at all does the adapter fall back to the Access application audience as
519
- the subject — that, and only that, is the case where tokens on one application
520
- share attribution. It never reads `Cf-Access-Jwt-Assertion`, downloads signing
521
- keys, or accepts a JWT from the caller, and a missing context or throwing lookup
522
- fails closed. It is therefore deliberately not a Node or `cloudflared` origin
523
- adapter and does not survive a Service Binding hop; those shapes need their own
524
- trust boundary.
525
-
526
- Access decides admission and identity; connecta configuration decides connector
527
- access and management permissions. An Access service identity, having no human
528
- principal, cannot mutate connection auth at all.
718
+ The adapter trusts only `ctx.access`, which Cloudflare attaches after validating
719
+ the token against the Worker-level Access application's AUD. A nonempty trusted
720
+ `ctx.access.aud` is required; a caller cannot supply this context through HTTP.
721
+ `ctx.access.getIdentity()` supplies `user_uuid`, with `email` as fallback, in the
722
+ stable `cloudflare-access` principal namespace. Missing identity, service
723
+ identity, or unusable user ids refuse. Access service credentials can admit the
724
+ request at the edge, but connecta requires a `cta_` token for machine identity.
725
+
726
+ It never reads `Cf-Access-Jwt-Assertion`, fetches signing keys, or accepts a
727
+ caller's JWT. Missing context or a throwing lookup fails closed on both
728
+ runtimes. It does not support Node, a `cloudflared` origin, or a Service Binding
729
+ hop. [Decision 0003](https://github.com/zackbart/connecta/blob/main/decisions/0003-inbound-auth.md) records that boundary
730
+ and replaces #506's provisional verdict with supported Worker Access.
731
+ Connecta configuration still decides connector and management permissions.
529
732
 
530
733
  Protect the Worker with a Worker-level Access application whose destination is
531
734
  `{ "type": "worker", "worker_id": "<the Worker script tag>" }`. A traditional
@@ -543,101 +746,402 @@ https://chatgpt.com/connector_platform_oauth_redirect
543
746
  https://chatgpt.com/connector/oauth/*
544
747
  ```
545
748
 
546
- Cloudflare exposes that list as
547
- `oauth_configuration.dynamic_client_registration.allowed_uris`, on the Managed
548
- OAuth settings rather than the Access policy that picks admitted identities.
549
- Claude uses the first value; ChatGPT uses its stable callback or a callback-id
550
- path covered by the third. For any other client add that exact URI or the
551
- narrowest wildcard covering it, never the client's whole origin. Missing entries
552
- let discovery succeed and registration fail later, which looks like a broken MCP
553
- server rather than a console setting.
749
+ That list is `oauth_configuration.dynamic_client_registration.allowed_uris`, on
750
+ the Managed OAuth settings, not the Access policy that picks identities. Claude
751
+ uses the first; ChatGPT its stable callback or a callback-id path the third
752
+ covers. For another client add its exact URI or the narrowest wildcard covering
753
+ it, never its whole origin. A missing entry lets discovery succeed and
754
+ registration fail later, which looks like a broken MCP server, not a setting.
554
755
 
555
756
  Do not add a bypass for the discovery routes; a fully automated client uses a
556
757
  [service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
557
758
  through `CF-Access-Client-Id` and `CF-Access-Client-Secret` instead. Worker-level
558
759
  Access runs before every connecta route, so `/health`, operator pages, downstream
559
760
  OAuth callbacks, and `/mcp` all require Access unless a more-specific policy says
560
- otherwise, and a static connecta bearer is not a standalone edge credential
761
+ otherwise, and a `cta_` token is not a standalone edge credential
561
762
  because Cloudflare rejects the request before connecta sees it. Custom public
562
763
  webhooks live outside connecta and need their own Access routing policy. The
563
764
  [Worker example](../examples/worker/) carries the whole deployment shape.
564
765
 
766
+ ## Clerk OAuth tokens and operator sessions
767
+
768
+ On `/mcp` and `/mcp/<pool>`, `clerkAuth` requires resource-bound Clerk OAuth
769
+ tokens by default, both JWT and opaque. Verified `aud` or `resource` must
770
+ contain the endpoint's exact canonical URL from protected-resource metadata.
771
+ If both claims are present, both must match. Trailing slashes, case, queries,
772
+ and fragments are not normalized. `/mcp` and `/mcp/support` are distinct
773
+ resources. Set `publicUrl` behind an internal origin.
774
+
775
+ ```ts
776
+ clerkAuth({ publishableKey, secretKey,
777
+ publicUrl: "https://connecta.example.com" });
778
+ ```
779
+
780
+ ### Enable resource audiences in Clerk
781
+
782
+ Enable `aud_claim_enabled: true` in Clerk's instance OAuth application
783
+ settings. The [Backend API contract](https://github.com/clerk/openapi-specs/blob/f10fb179f42fc591f9d6f0221c089dd19483fd69/bapi/2026-05-12.yml)
784
+ defines `GET` and `PATCH /v1/instance/oauth_application_settings` on
785
+ `https://api.clerk.com`. Patch the setting and read it back to confirm.
786
+ `oauth_jwt_access_tokens` separately selects JWT or opaque format; both work.
787
+
788
+ For `/mcp`, `/.well-known/oauth-protected-resource` advertises
789
+ `resource: "https://connecta.example.com/mcp"` and the Clerk Frontend API
790
+ origin in `authorization_servers`. Pool metadata lives at
791
+ `/.well-known/oauth-protected-resource/mcp/<pool>` and advertises that pool's
792
+ exact URL. Clients fetch authorization-server metadata directly from that Clerk origin.
793
+ Connecta serves no authorization-server metadata proxy.
794
+
795
+ MCP hosts send that URL as the RFC 8707 `resource` parameter in standard OAuth
796
+ authorization and token requests. Clerk's [Frontend API contract](https://github.com/clerk/openapi-specs/blob/f10fb179f42fc591f9d6f0221c089dd19483fd69/fapi/2026-05-12.yml)
797
+ supports it at authorization and token exchange. Exchange and refresh retain
798
+ the grant's resource when omitted; if supplied, it must match. Enable the
799
+ registration methods your hosts use, such as DCR or [Client ID Metadata Documents](https://clerk.com/docs/guides/configure/auth-strategies/oauth/client-id-metadata-documents).
800
+ Claude, ChatGPT, Claude Code, Codex, and Cursor should use their standard MCP
801
+ OAuth flow. Bound tokens require no connecta client-ID list.
802
+
803
+ When migrating, enable audience issuance, reconnect for a new resource-bound
804
+ grant, and verify:
805
+
806
+ 1. The endpoint's protected-resource metadata advertises the exact public MCP
807
+ URL as `resource`, including any pool suffix.
808
+ 2. The host sends that `resource` at authorization and exchange. Inspect the
809
+ new token locally: JWT claims or Clerk's opaque-token verification response
810
+ from `POST /v1/oauth_applications/access_tokens/verify` must contain that URL
811
+ in `aud`. Keep tokens out of logs and reports.
812
+ 3. With `allowedOAuthClientIds` omitted or `[]`, MCP initialization succeeds at
813
+ that URL and a different endpoint's token receives `401`. Refresh preserves
814
+ the audience and operator sign-in works. Repeat for each host and pool.
815
+
816
+ Clerk's SDK verifies both formats. Connecta reads the authenticated JWT's
817
+ claims. SDK 3.12.0 drops opaque `aud`, so connecta makes one additional Backend
818
+ API verification request and reads the raw response. Its subject and client
819
+ must agree with the SDK, and it must report an unrevoked, unexpired token.
820
+ The extra call follows request cancellation, times out after ten seconds,
821
+ caches no token or audience, and denies admission on failure.
822
+
823
+ ### Explicit fallback for unbound tokens
824
+
825
+ `allowedOAuthClientIds: ["your-connecta-client-id"]` admits verified tokens
826
+ with no audience/resource claim from exact client IDs dedicated to this
827
+ deployment. Never share these clients with another resource server. A present
828
+ malformed or mismatched binding is rejected even for an allowlisted client.
829
+ The list does not restrict correctly bound tokens. It applies to every pool;
830
+ identity and pool grants still narrow access. Profile scopes do not identify a
831
+ resource, and `scopes` remains metadata, not an admission rule.
832
+
833
+ Each new DCR registration gets a new client ID and needs an operator update
834
+ for this fallback. Hosts may cache registrations, but the list is impractical
835
+ for standard onboarding. A CIMD client identifies the host, not the deployment
836
+ resource. Prefer resource audiences with `allowedOAuthClientIds` omitted or `[]`.
837
+
838
+ Clerk session tokens authenticate operator routes and browser OAuth callbacks
839
+ only, with the deployment-origin `azp` check. MCP rejections retain the `401`
840
+ Bearer challenge and endpoint `resource_metadata` URL required by
841
+ [MCP authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling).
842
+ Authentication logs contain fixed codes such as `oauth_binding_mismatch`,
843
+ `oauth_client_not_allowed`, and `oauth_verification_failed`, never tokens,
844
+ client IDs, claimed audiences, or upstream error text.
845
+
846
+ Clerk 3.12 has no per-client fetch or diagnostic hook. Connecta uses a
847
+ generated copy of that reviewed SDK with an instance-owned fetch: JWKS,
848
+ backend API, opaque verification, and handshake responses read bytes through
849
+ `byteReadResponse`, so workerd cannot print an upstream Content-Type during a
850
+ native text read. SDK diagnostics use the fixed `Clerk authentication failed`
851
+ event and checked `logFailure` records. Its authentication and resource
852
+ serialization logic stays in the SDK; no global fetch, Response, or console is
853
+ patched. Telemetry event and log methods are inert on this client, including
854
+ when telemetry debug output is enabled. The optional `@clerk/backend` peer
855
+ accepts `^3.12.0` for the JWT decoder; it does not select the bundled SDK's
856
+ version. `gate` receives Connecta's `ClerkGateClient`, which declares
857
+ `users.getUser` and the user identity, name, and email fields used by Connecta.
858
+ These declarations do not import the consumer's Clerk types.
859
+
860
+ [`scripts/build-clerk-sdk.mjs`](https://github.com/zackbart/connecta/blob/main/scripts/build-clerk-sdk.mjs) verifies the
861
+ upstream version and source hash, applies the transport and diagnostic hooks,
862
+ and regenerates the adapter with its license notices. `check:clerk-sdk` checks
863
+ freshness in both verification loops. Updating the locked Clerk SDK requires a
864
+ deliberate adapter update and the real-SDK Node and Workers auth tests.
865
+
565
866
  ## Clerk configuration is checked at construction
566
867
 
567
- `clerkAuth` reads its Frontend API origin out of `publishableKey`, so a key that
568
- is not `pk_test_`/`pk_live_` followed by the base64-encoded domain cannot produce
569
- one. That throws where `allowedDomains` throws, when `clerkAuth` is called, naming
570
- the option and never quoting the rejected value back — the usual way to land here
571
- is pasting the *secret* key into the publishable slot. A deployment that builds per
572
- request, as the Workers shape does, sees that same error on its first request
868
+ `clerkAuth` reads its Frontend API origin out of `publishableKey`, so a key
869
+ that is not `pk_test_`/`pk_live_` and a base64-encoded domain throws when
870
+ `clerkAuth` is called, as `allowedDomains` does, naming the option without
871
+ quoting the value — usually the _secret_ key pasted into the publishable slot.
872
+ A per-request deployment, as on Workers, sees that error on its first request
573
873
  instead of a base64 stack on every route.
574
874
 
575
875
  ## Human authentication management
576
876
 
577
- An operator's OAuth Connect or Restart request gives the start and its handoff
578
- 30 seconds. The request's abort signal and that deadline reach downstream OAuth
579
- fetches, including metadata discovery and dynamic registration. If the deadline
580
- expires, the route returns `504` with `OAuth authorization start timed out` after
581
- an already-started reset finishes. Storage has no cancellation contract: its
582
- generation write must drain before a response, or it could publish an old epoch
583
- over a newer flow. Catalog invalidation and connector scope close also finish
584
- outside the deadline. Those storage and cleanup waits can extend the click past
585
- 30 seconds; the bound is on downstream work, where a hung authorization server
586
- was the failure. The OAuth provider checks its signal before writing and removes
587
- a cancelled value write that finishes late. An operator Disconnect keeps its
588
- existing commit-through behavior even if the browser leaves.
589
-
590
- Restart retains a dynamically registered client only when its stored issuer and
591
- the connector's URL, redirect URI, client metadata, auth scope, transport settings,
592
- and owner partition still match. It drops the grant and one-shot flow state,
593
- selects the authorization server again, and re-seals the retained client under
594
- the replacement epoch key. A different issuer registers a new client. Disconnect
595
- and issuer-mismatch recovery discard it. The SDK has no client-validation
596
- exchange while constructing a fresh consent URL; a provider that revokes a
597
- retained client may refuse it only at consent or callback, after this request.
598
-
599
- Credential and OAuth mutation require an admitted interactive human, connector
600
- visibility, the appropriate shared or personal permission, and an exact
601
- same-origin `Origin` for browser requests. A configured MCP bearer never becomes
602
- a browser management credential.
603
-
604
- `authorize_connector` splits along what it would change. For a connector with a
605
- static credential slot it mutates nothing, so visibility is enough: with
606
- `ui: operatorUi()` and a vault it returns a secret-free `operator_config` handoff
607
- naming the credential fields and the operator URL, and without either the recovery
608
- is `unavailable`, since connecta does not hand back a link to a missing page. Only
609
- the downstream-OAuth branch consults the management permissions, and an identity
610
- lacking them gets `unavailable` there.
611
-
612
- Core owns the OAuth callback and verifies state and principal ownership
613
- independently of the optional browser application. A browser returning from
614
- downstream consent normally carries no MCP `Authorization` header, so an
615
- interactive bearer provider's 401 does not reject the callback. The verified state
616
- and its saved principal handoff select the owner; a browser identity, when
617
- present, must match that owner and may then manage the connector, while an
618
- interactive provider's explicit 403 still refuses the flow. See
619
- [meta-tools](./meta-tools.md#authorization-recovery) for the recovery shapes a
620
- caller actually receives.
621
-
622
- ## URL-based downstream OAuth clients
623
-
624
- `remoteMcp` accepts `auth: { type: "oauth", clientMetadataUrl, scope }`.
625
- `clientMetadataUrl` names a public HTTPS OAuth client metadata document with a
626
- non-root path. It must not contain credentials or a fragment. The document
627
- must include its own URL as `client_id`, the deployment's exact
628
- `/oauth/callback/<connector-id>` in `redirect_uris`, authorization-code and
629
- refresh-token grants, and `token_endpoint_auth_method: "none"`. Hosting that
630
- public document belongs to the deployment; Connecta does not expose a public
631
- route through inbound authentication.
632
-
633
- The SDK uses the URL as the client ID only when the authorization server
634
- advertises `client_id_metadata_document_supported`. Otherwise it keeps the
635
- existing dynamic registration flow. State validation, PKCE, issuer binding,
636
- encrypted token storage, refresh, and disconnect follow the same code paths.
637
- The metadata URL and scopes participate in the saved-client configuration
638
- binding, so restarting after either changes cannot reuse an old registration.
639
-
640
- `scope` supplies space-separated default OAuth scopes through client metadata.
641
- A downstream challenge or protected-resource scope declaration takes precedence,
642
- and the SDK adds `offline_access` when advertised for refresh-token grants.
643
- Omitting both settings preserves the existing discovery and registration flow.
877
+ Auth URL elicitation uses the SDK's `createRequestStateCodec`, backed by a
878
+ separate HKDF-derived HMAC-SHA256 key held inside `encryptedCredentialVault`.
879
+ The ten-minute state is authenticated, not encrypted. Its secret-free payload
880
+ binds the admitted principal, canonical endpoint including pool, connector,
881
+ meta-tool, address, SHA-256 digest of arguments or code, round, expiry, and
882
+ the opaque browser-link nonces issued in that flow. Entered reads also bind
883
+ their addresses and classification digests.
884
+ Arguments and program source never enter the payload. The absolute expiry
885
+ survives every round. Rotation invalidates outstanding states.
886
+ Each state admits one retry through a connector-scoped storage CAS, including
887
+ decline and cancel. Concurrent or repeated consumption is refused before
888
+ dispatch, so retries cannot fork into sibling forced connection links.
889
+ The host records each entered call's classification, catalog freshness and
890
+ classification digest in private invocation state, along with auth failure origins. Writes may elicit only
891
+ when Connecta's own credential resolution fails before invoking the connector:
892
+ a missing grant, a missing credential slot, or consent required before refresh.
893
+ Auth failures inside handlers or transports cannot elicit for writes, including
894
+ raw fetches, custom connector errors, and refresh failures inside a handler.
895
+ Programs also stop automatic recovery once any write-classified call enters
896
+ its handler, even if it sends nothing or fails. Each accepted retry checks
897
+ these facts for its own round. Later auth failures
898
+ carry `reconciliationRequired: true` and manual connection guidance because the
899
+ call may have partially run. Post-entry recovery requires every entered call
900
+ to have a read classification from a catalog accepted within its TTL. Stale
901
+ fallbacks, including a failed listing followed by cached annotations, cannot
902
+ authorize a re-run. Normal calls retain their existing stale-fallback behavior.
903
+ Before an accepted retry restarts any call, it rechecks every entered read.
904
+ A write classification, changed classification digest, or stale catalog returns
905
+ `auth_replay_refused` with reconciliation guidance, including in trusted programs.
906
+ Pre-invocation credential-resolution eligibility is unchanged.
907
+ Custom vaults may implement the host-only `requestStateKey` method; without it, the
908
+ deployment retains the ordinary handoff. It must return a stable,
909
+ purpose-specific deployment key of at least 32 bytes. Only the MCP boundary
910
+ reads this key; connector contexts and guest APIs cannot reach the vault.
911
+ Explicit authorization retries require a completed browser start (including
912
+ its reset and consent binding), valid stored credential fields where declared,
913
+ and a healthy passive connector status where available before completing.
914
+ An in-progress start cannot complete against an old grant. A forced flow switches to
915
+ Continue after that visit, so another prompt cannot restart pending consent.
916
+ Failed starts remain single-use but do not block a later successful attempt.
917
+ Before checking completion, unspent links in the retry flow are atomically
918
+ retired so an older forced link cannot begin a reset during the status check.
919
+ A Continue link issued during a pending forced start is bound to that
920
+ predecessor and cannot be consumed unless the predecessor starts successfully.
921
+ If it fails, the next prompt requests a fresh restart instead of reusing the old grant.
922
+
923
+ The `/connect` link issued by the elicitation boundary encrypts its browser
924
+ handoff with the vault's `seal`/`open` methods before signing it. The URL carries
925
+ neither principal identifiers nor credentials and authenticates no browser.
926
+ The same principal and management checks apply to OAuth and credential-slot
927
+ links. A credential-slot visit consumes its link and redirects to the
928
+ same-origin operator UI, where credential mutation requires its own identity
929
+ and Origin checks. Vaults without sealing keep ordinary handoffs and cannot
930
+ serve URL elicitation. See [authorization recovery](./meta-tools.md#authorization-recovery)
931
+ for capability gating, retry termination, and dispatch restrictions.
932
+
933
+ A verified `/connect` visit gives downstream OAuth work and its state handoff 30 seconds. The request signal
934
+ and deadline reach discovery, registration, and other downstream fetches; expiry returns `504 OAuth
935
+ authorization start timed out`. A restart's grant write, catalog invalidation, and scope close drain before
936
+ responding because storage has no cancellation contract. They can extend that wait. The provider checks
937
+ cancellation before writing and removes late cancelled writes. Disconnect commits even if the browser leaves.
938
+
939
+ Restart carries a dynamically registered client only when its stored issuer, connector URL, redirect URI,
940
+ client metadata, auth scope, transport settings, and owner partition still match. It drops the grant and flow
941
+ state, selects the server again, and re-seals the client under the new epoch. A different issuer registers
942
+ anew; Disconnect and issuer-mismatch recovery discard it. URL-based clients and clients with expired secrets
943
+ are never carried.
944
+
945
+ A carried client needs a completed grant before another carry. Building a consent URL cannot detect a purged
946
+ registration, and RFC 6749 forbids redirecting an unknown client to the callback. Restarting again without an
947
+ intervening grant therefore registers anew. A start whose refresh returns `invalid_client` drops the client
948
+ and registers in that start; a callback exchange with that error drops the client so Continue cannot return
949
+ its URL.
950
+
951
+ Credential and OAuth mutation require interactive identity, connector visibility, management permission, and
952
+ an exact same-origin `Origin` for browser mutations. A static credential `authorize_connector` call needs
953
+ visibility only: with `ui: operatorUi()` and a vault it returns a secret-free `operator_config` handoff;
954
+ without either it returns `unavailable`. The OAuth branch also checks management permission and returns
955
+ `unavailable` when refused.
956
+
957
+ Core owns `/connect/<connector>` and the callback without the optional UI. Both require Clerk sessions or
958
+ Cloudflare Access users; MCP OAuth tokens, `cta_` tokens, and Access service identities
959
+ cannot authenticate them. Without an interactive provider, connection fails at runtime with `An interactive
960
+ provider (Clerk or Cloudflare Access) is required to connect OAuth connectors.` Machine-only deployments can
961
+ still serve configured credentials. Both routes accept only GET, otherwise returning 405 with `Allow: GET`;
962
+ `form_post` is unsupported. Clerk's session-only policy applies regardless of method.
963
+
964
+ The fifteen-minute signed handoff carries connector ownership, initiating principal, deployment origin, nonce,
965
+ and restart mode. Configure `vault: encryptedCredentialVault(storage, key)`, which derives its HMAC key with
966
+ HKDF. Custom vaults need `signOAuthHandoff` and `verifyOAuthHandoff`; without a signing vault, links cannot be
967
+ issued or accepted. A link grants no identity. After identity and permissions pass, its nonce is consumed
968
+ before OAuth starts, even while consent is pending. Failed starts need new links; nonce markers expire with
969
+ their links.
970
+
971
+ Clerk sign-in returns to the same link. Expired session cookies use Clerk's handshake to refresh before
972
+ identity checks or code exchange; responses preserve session cookies. Access supplies trusted Worker identity
973
+ at the edge. Protect `/connect/*` and `/oauth/callback/*` with the MCP endpoint's Access application.
974
+
975
+ For both connector scopes, the browser must match the initiating principal and retain `personalConnection` or
976
+ shared `credentialAdministration` permission. Connecta saves that principal against downstream state. The
977
+ callback checks state, identity, permission, and RFC 9207 `iss` before consuming
978
+ the handoff, interpreting an error, or exchanging a code. A supplied issuer must
979
+ exactly match the consent. A server advertising issuer-response support also
980
+ requires `iss`; missing or mismatched values produce the generic refusal. Custom
981
+ connectors receiving `iss` need `verifyCallbackIssuer`. Verified error callbacks
982
+ consume their handoff and CAS-terminate the consent once, discarding its PKCE
983
+ verifier before displaying fixed copy for a known reason. Continue starts a new
984
+ consent; later callbacks with the old state are refused. Custom OAuth connectors
985
+ need `consumeAuthError` to accept error callbacks, sharing `finishAuth`'s atomic
986
+ consent claim. Reissue
987
+ pending consent links after upgrading; callbacks without a saved initiating user cannot complete.
988
+
989
+ `compareAndSet` atomically claims link nonces, callback handoffs, and consents, with one concurrent winner,
990
+ so each completes once.
991
+ [Meta-tools](./meta-tools.md#authorization-recovery) describes recovery.
992
+
993
+ ## Downstream OAuth client registration
994
+
995
+ `remoteMcp(id, { auth: { type: "oauth", scope } })` defaults to a self-hosted
996
+ Client ID Metadata Document when the configured `publicUrl` is a public HTTPS
997
+ origin. Connecta serves `GET /oauth/client-metadata/<id>` without inbound auth,
998
+ with `application/json` and a five-minute public cache lifetime. Only configured
999
+ self-hosted OAuth connectors have a document; unknown, non-OAuth, external-CIMD,
1000
+ and static-client connectors return 404. Other methods return 405.
1001
+
1002
+ The document contains its own URL as `client_id`, `serverInfo.name` as
1003
+ `client_name`, the canonical `publicUrl` origin's callback in `redirect_uris`, the
1004
+ `authorization_code` and `refresh_token` grants, `response_types: ["code"]`,
1005
+ `application_type: "web"`, `token_endpoint_auth_method: "none"`, and configured
1006
+ `scope` when present. It contains no secrets. Alternate request hosts never add
1007
+ callbacks or change the client ID. The document, DCR submission, and saved-client
1008
+ configuration binding use one metadata builder.
1009
+
1010
+ | Configuration and authorization server | Registration path |
1011
+ | ------------------------------------------------------------ | --------------------------------------------- |
1012
+ | Pre-registered `auth.client` | `static`, restricted to its configured issuer |
1013
+ | Explicit `clientMetadataUrl`, server advertises CIMD support | `cimd`, using the external URL |
1014
+ | Public HTTPS `publicUrl`, server advertises CIMD support | `cimd`, using Connecta's document |
1015
+ | Server does not advertise CIMD support | `dcr` fallback |
1016
+ | Unset, HTTP, loopback, or private `publicUrl` | `dcr`, without a self-hosted document |
1017
+
1018
+ `startAuth()` and operator connector status expose `registrationPath` after a
1019
+ client has been selected. The selected mechanism is stored alongside the client,
1020
+ including across a restart that preserves DCR registration. Older grants without
1021
+ that field omit the path until a new client is selected. Reads neither register
1022
+ a client nor begin consent; static client information is persisted during explicit
1023
+ consent, not passive status.
1024
+ DCR fallback follows the server's advertised capabilities; a rejected CIMD
1025
+ consent is not silently retried under another client identity.
1026
+
1027
+ `clientMetadataUrl` remains an optional external HTTPS document on a non-root
1028
+ path, without credentials or a fragment. Its `client_id` must equal its URL,
1029
+ and its metadata must include the deployment's exact callback and public-client
1030
+ authentication. Changing from an external document to the self-hosted URL needs
1031
+ one Disconnect and reconnect because tokens belong to the old client ID.
1032
+ `basecamp()` now defaults to the self-hosted document and accepts an external
1033
+ URL override. Basecamp restricts DCR for HTTPS callbacks, so it needs a public
1034
+ HTTPS deployment or an external document.
1035
+
1036
+ For a pre-registered client, use:
1037
+
1038
+ ```ts
1039
+ remoteMcp("service", {
1040
+ url: "https://mcp.example/mcp",
1041
+ auth: {
1042
+ type: "oauth",
1043
+ client: {
1044
+ issuer: "https://auth.example",
1045
+ clientId: env.SERVICE_CLIENT_ID,
1046
+ clientSecret: env.SERVICE_CLIENT_SECRET,
1047
+ tokenEndpointAuthMethod: "client_secret_basic",
1048
+ },
1049
+ },
1050
+ });
1051
+ ```
1052
+
1053
+ `client` and `clientMetadataUrl` are mutually exclusive. The client must name
1054
+ its exact HTTPS issuer. A different discovered issuer is refused before
1055
+ registration or credential dispatch. Authentication defaults to
1056
+ `client_secret_basic` with a secret and `none` without one; `client_secret_post`
1057
+ is also supported. Empty credentials and mismatched methods fail construction.
1058
+ Client secrets stay in deployment configuration. Storage holds only the client
1059
+ ID and its public binding, alongside the encrypted grant. Restart discards
1060
+ stored static-client state and reselects the configured identity.
1061
+
1062
+ Configured `scope` is a fallback; a downstream challenge or protected-resource
1063
+ declaration takes precedence, and the SDK adds `offline_access` when advertised.
1064
+
1065
+ ## Downstream OAuth on `api()`
1066
+
1067
+ `api()` takes a static authorization-code configuration for APIs without MCP
1068
+ or OAuth discovery, such as Church Community Builder's manually issued clients:
1069
+
1070
+ ```ts
1071
+ api("church", {
1072
+ authScope: "personal",
1073
+ oauth: {
1074
+ authorizationEndpoint: "https://login.example.com/oauth/authorize",
1075
+ tokenEndpoint: "https://api.example.com/oauth/token",
1076
+ clientId: env.CHURCH_CLIENT_ID,
1077
+ clientSecret: env.CHURCH_CLIENT_SECRET,
1078
+ scope: "people:read",
1079
+ apiOrigins: ["https://api.example.com"],
1080
+ },
1081
+ tools: [{
1082
+ name: "get_person",
1083
+ description: "Read one person",
1084
+ annotations: { readOnlyHint: true },
1085
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
1086
+ handler: async ({ id }, ctx) => (await ctx.oauth!.fetch(
1087
+ `https://api.example.com/people/${encodeURIComponent(id)}`)).json(),
1088
+ }],
1089
+ });
1090
+ ```
1091
+
1092
+ No discovery or registration occurs; all URLs are configuration, so the
1093
+ [advertised-URL rule](#urls-a-downstream-advertises) does not apply.
1094
+
1095
+ | Option or binding | Contract |
1096
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1097
+ | Endpoints | Checked at construction: HTTPS, HTTP only on loopback, no credentials or fragment. |
1098
+ | `pkce` | S256 by default; `false` omits challenge and verifier. |
1099
+ | `authorizationParams` | Adds provider parameters; cannot restate the grant's own. |
1100
+ | `tokenRequestHeaders` | Adds code-exchange and every refresh's headers; cannot set `Authorization`, `Content-Type`, `Content-Length`, `Cookie`, or `Host`. |
1101
+ | `tokenEndpointAuthMethod` | Defaults to `client_secret_basic` with a secret, `none` without; mismatched pairings refuse construction. |
1102
+ | Issuer/resource | No advertised issuer means no required RFC 9207 `iss` or sent RFC 8707 `resource`. The grant binds to the token endpoint; after it changes, old tokens are never sent and the next consent replaces them. |
1103
+ | Client | One deployment-config identity, never written or sealed to storage; no owner vault slot. Store leaks contain no client secret; Disconnect deletes none and Restart carries none. Read from environment or Worker secrets; empty strings refuse construction without quoting values. |
1104
+
1105
+ Handlers never see the grant. `ctx.oauth.fetch(url, init)` sends the calling
1106
+ owner's access token as `Authorization: Bearer` with these rules:
1107
+
1108
+ - Only `apiOrigins` receive tokens; refuse other origins before sending, including
1109
+ untrusted response URLs. Return redirects unfollowed; handlers cannot set `Authorization`.
1110
+ - On 401, recover exactly once using an already rotated token or one coalesced
1111
+ refresh through `remoteMcp()`'s coordinator. Persist rotation even when its
1112
+ owner is cancelled after the answer. Replay once; stream bodies are refused.
1113
+ - No grant, a second 401, or a [dead refresh](#refresh-failures) means
1114
+ `downstream_oauth_required`, directing `authorize_connector`. Refresh waiter deadlines
1115
+ return retryable `unavailable`. Latch second 401s for the request scope,
1116
+ preventing repeated refreshes by later program calls.
1117
+ - Handlers can name no storage, sealing, or owner partition; the registry owns them.
1118
+ - The answer's `.text()` and `.json()`, clones' too, decode its bytes as UTF-8, so
1119
+ workerd never quotes a downstream's Content-Type in its own log (INV-6).
1120
+
1121
+ The grant record and consents, epoch fencing, vault sealing, shared/personal ownership,
1122
+ callback state/principal checks, `authorize_connector`, Connect, Restart, and
1123
+ Disconnect match `remoteMcp()`. Status calls report a stored grant for the
1124
+ configured token endpoint healthy without downstream probes and never start
1125
+ authorization. `oauth` and
1126
+ `credential` are exclusive per connector, giving `auth_required` one recovery.
1127
+ For providers offering both, deployment config chooses; handlers check `ctx.oauth`.
1128
+
1129
+ ## Request-local downstream Bearer tokens
1130
+
1131
+ `remoteMcp({ auth: { type: "request", token: async (ctx) => … } })` resolves
1132
+ its token before connecting and before reusing a client. The callback receives
1133
+ the operation's cancellation context, never an agent-selected credential owner.
1134
+ Only shared ownership is accepted. Tokens remain inside the request's hardened
1135
+ transport, are not stored or described, and a changed token replaces the client.
1136
+ This mode refuses cleartext non-loopback origins. Optional `auth.headers` carry
1137
+ static protocol/catalog controls and cannot set Authorization.
1138
+
1139
+ The GitHub provider validates each tool's owner/repo and scope access before
1140
+ resolving an installation token. It creates one token-bound MCP client for that
1141
+ operation and closes it before returning. Completed installation/token caches
1142
+ contain bounded runtime values only, partitioned by key fingerprint, owner
1143
+ installation, repositories and permissions. No request promise or transport
1144
+ is retained across requests, and rejected writes are never replayed. An omitted
1145
+ `app.privateKey` uses the encrypted connector vault's `privateKey` field;
1146
+ `describe()` includes neither the key nor the minted tokens. The maintained
1147
+ provider skill explains App permissions and installation prerequisites.