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