@zackbart/connecta 0.24.3 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/AGENTS.md +58 -30
  2. package/CHANGELOG.md +317 -1
  3. package/README.md +5 -6
  4. package/dist/advertised-schema.js +53 -0
  5. package/dist/auth/downstream-oauth.js +490 -106
  6. package/dist/branding.d.ts +31 -2
  7. package/dist/branding.js +116 -8
  8. package/dist/call-admission.js +139 -113
  9. package/dist/catalog-service.js +141 -105
  10. package/dist/catalog.js +258 -33
  11. package/dist/concurrency.js +24 -23
  12. package/dist/connector-scope.js +6 -37
  13. package/dist/connectors/api.d.ts +3 -3
  14. package/dist/connectors/api.js +10 -2
  15. package/dist/connectors/guarded-fetch.js +34 -4
  16. package/dist/connectors/remote-mcp.d.ts +6 -5
  17. package/dist/connectors/remote-mcp.js +325 -245
  18. package/dist/credential-contract.d.ts +9 -0
  19. package/dist/credential-rules.js +2 -2
  20. package/dist/credentials.d.ts +6 -1
  21. package/dist/credentials.js +45 -3
  22. package/dist/errors.d.ts +11 -3
  23. package/dist/errors.js +2 -1
  24. package/dist/execute.js +363 -257
  25. package/dist/executor-admission.js +67 -50
  26. package/dist/executors/quickjs-runtime.js +4 -2
  27. package/dist/executors/quickjs.js +251 -210
  28. package/dist/fix-prompt.js +37 -0
  29. package/dist/index.d.ts +11 -2
  30. package/dist/index.js +63 -21
  31. package/dist/invocation.js +324 -285
  32. package/dist/meta-tools.js +304 -114
  33. package/dist/oauth-callback-outcome.js +94 -0
  34. package/dist/oauth-sealing.js +29 -0
  35. package/dist/operator-ui/fix-prompts.js +87 -0
  36. package/dist/operator-ui/generated.js +2 -2
  37. package/dist/operator-ui/model.d.ts +46 -1
  38. package/dist/operator-ui/setup-commands.js +53 -0
  39. package/dist/operator-ui/view.js +109 -2
  40. package/dist/program-source.js +205 -0
  41. package/dist/providers/cloudflare.d.ts +14 -2
  42. package/dist/providers/cloudflare.js +107 -16
  43. package/dist/providers/linear.d.ts +26 -4
  44. package/dist/providers/linear.js +19 -4
  45. package/dist/providers/mixpanel.d.ts +16 -3
  46. package/dist/providers/mixpanel.js +13 -2
  47. package/dist/providers/notion.d.ts +8 -1
  48. package/dist/providers/notion.js +85 -12
  49. package/dist/providers/revenuecat.d.ts +30 -4
  50. package/dist/providers/revenuecat.js +70 -5
  51. package/dist/providers/stripe.d.ts +10 -3
  52. package/dist/providers/stripe.js +30 -4
  53. package/dist/providers/vercel.js +12 -2
  54. package/dist/registry.d.ts +13 -5
  55. package/dist/registry.js +317 -258
  56. package/dist/routes/activity.js +120 -137
  57. package/dist/routes/credentials.js +174 -163
  58. package/dist/routes/mcp.js +129 -70
  59. package/dist/routes/oauth-management.js +60 -71
  60. package/dist/routes/oauth.js +44 -16
  61. package/dist/routes/operator.js +82 -0
  62. package/dist/routes/shared.d.ts +2 -0
  63. package/dist/routes/ui-data.js +210 -0
  64. package/dist/routes/ui.js +64 -32
  65. package/dist/runtime/admission.js +43 -0
  66. package/dist/runtime/call-admission.js +67 -0
  67. package/dist/runtime/connector-scope.js +72 -0
  68. package/dist/runtime/run.js +174 -0
  69. package/dist/runtime/services.js +130 -0
  70. package/dist/runtime/storage.js +66 -0
  71. package/dist/server.js +111 -89
  72. package/dist/skills.js +1 -1
  73. package/dist/storage/file.js +39 -2
  74. package/dist/storage/memory.js +28 -16
  75. package/dist/timeout.js +17 -47
  76. package/dist/types.d.ts +64 -7
  77. package/dist/ui.d.ts +16 -2
  78. package/dist/ui.js +68 -176
  79. package/dist/url-safety.js +157 -0
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/documentation/architecture.md +368 -182
  83. package/documentation/auth.md +277 -176
  84. package/documentation/code-mode.md +479 -329
  85. package/documentation/meta-tools.md +494 -417
  86. package/ethos.md +44 -47
  87. package/examples/worker/AGENTS.md +2 -1
  88. package/examples/worker/README.md +62 -14
  89. package/examples/worker/src/d1-storage.ts +116 -0
  90. package/examples/worker/src/index.ts +18 -21
  91. package/examples/worker/wrangler.jsonc +16 -4
  92. package/package.json +11 -4
  93. package/templates/node/.env.example +3 -3
  94. package/templates/node/AGENTS.md +5 -4
  95. package/templates/node/README.md +2 -1
  96. package/templates/node/package.json +1 -1
  97. package/templates/node/src/index.ts +23 -22
  98. package/dist/auth/downstream-oauth.d.ts +0 -200
  99. package/dist/catalog-fingerprint.d.ts +0 -19
  100. package/dist/catalog-limits.d.ts +0 -9
  101. package/dist/catalog-service.d.ts +0 -263
  102. package/dist/catalog.d.ts +0 -87
  103. package/dist/concurrency.d.ts +0 -3
  104. package/dist/connectors/guarded-fetch.d.ts +0 -92
  105. package/dist/credential-rules.d.ts +0 -85
  106. package/dist/execute.d.ts +0 -154
  107. package/dist/executor-result.d.ts +0 -10
  108. package/dist/executors/quickjs-child.d.ts +0 -1
  109. package/dist/executors/quickjs-protocol.d.ts +0 -67
  110. package/dist/favicon.d.ts +0 -1
  111. package/dist/identity.d.ts +0 -4
  112. package/dist/invocation.d.ts +0 -74
  113. package/dist/mcp-result.d.ts +0 -8
  114. package/dist/meta-tools.d.ts +0 -129
  115. package/dist/operator-ui/generated.d.ts +0 -2
  116. package/dist/operator-ui/view.d.ts +0 -151
  117. package/dist/result-shapes.d.ts +0 -13
  118. package/dist/retired-toolkits.d.ts +0 -2
  119. package/dist/routes/activity.d.ts +0 -2
  120. package/dist/routes/credentials.d.ts +0 -2
  121. package/dist/routes/mcp.d.ts +0 -10
  122. package/dist/routes/oauth-management.d.ts +0 -2
  123. package/dist/routes/oauth.d.ts +0 -2
  124. package/dist/routes/ui.d.ts +0 -2
  125. package/dist/server.d.ts +0 -9
  126. package/dist/skills.d.ts +0 -44
  127. package/dist/timeout.d.ts +0 -21
  128. package/dist/tool-safety.d.ts +0 -9
  129. package/documentation/call-admission.md +0 -158
  130. package/documentation/cloudflare.md +0 -471
  131. package/documentation/connector-guides.md +0 -176
  132. package/documentation/connectors.md +0 -431
  133. package/documentation/linear.md +0 -193
  134. package/documentation/mixpanel.md +0 -160
  135. package/documentation/notion.md +0 -308
  136. package/documentation/operations.md +0 -359
  137. package/documentation/operator-ui.md +0 -135
  138. package/documentation/optional-modules-upgrade.md +0 -243
  139. package/documentation/provider-conventions.md +0 -729
  140. package/documentation/request-admission.md +0 -204
  141. package/documentation/revenuecat.md +0 -305
  142. package/documentation/storage-and-credentials.md +0 -254
  143. package/documentation/stripe.md +0 -262
  144. package/documentation/upgrading.md +0 -768
  145. package/documentation/vercel.md +0 -241
@@ -1,66 +1,87 @@
1
1
  # Inbound auth
2
2
 
3
- Inbound auth decides who may reach the MCP endpoint. Import configured bearer
4
- support from `@zackbart/connecta/auth/bearer`, Clerk from `/auth/clerk`, or
5
- Cloudflare Access from `/auth/cloudflare-access`. Providers may be combined;
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
6
  static bearers are checked first, then other providers in configuration order.
7
- Connecta no longer issues `cta_` tokens or serves token-management routes.
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 no tokens of its own and serves no token-management routes.
8
11
 
9
12
  The bearer adapter challenges with `WWW-Authenticate: Bearer` and deliberately
10
- omits `resource_metadata`. Its credential is configured out of band; it has no
11
- OAuth authorization server or registration endpoint to advertise. Interactive
12
- adapters or the edge own OAuth discovery. Every open deployment with at least
13
- one connector warns at construction, including API connectors with static auth
14
- headers. Credential and OAuth connectors add explicit wording about those grants.
15
-
16
- MCP browser origins pass the [Origin check](./request-admission.md#origin-before-admission)
17
- before admission or auth. This is independent of an identity's tool grants.
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.
18
+
19
+ ## Origins
20
+
21
+ `allowedOrigins?: readonly string[] | "*"` bounds which browsers may speak to
22
+ the MCP endpoint. A disallowed `Origin` on `/mcp` or any `/mcp/<pool>` gets a
23
+ fixed 403 before HTTPS redirects, admission, auth, and CORS preflight — the
24
+ check cannot depend on anything a caller has yet proved. The default admits the
25
+ `publicUrl` origin plus HTTP(S) loopback at any port; a supplied list replaces
26
+ that default rather than extending it, and `"*"` waives the check. Requests with
27
+ no `Origin` pass, since a non-browser client is not the threat here, and an
28
+ entry that is not an exact HTTP(S) origin throws at construction instead of
29
+ silently never matching. A permitted origin still has to authenticate.
18
30
 
19
31
  ## Principals, visibility, and operators
20
32
 
21
- The actor identifies the caller in activity. The subject owns transient results
22
- such as `get_result` pages. The principal is the human owner of personal
23
- connector auth. An interactive Clerk or Access user supplies all three. A
24
- Cloudflare service identity has an actor and subject but no principal.
25
-
26
- A subject or user id always selects a result-stash partition, even when the
27
- provider omits `activityActorNamespace`. An explicit namespace remains the
28
- partition namespace; without one, Connecta uses `connecta:auth:<provider kind>`.
29
- Subject ids must be distinct within that namespace. This fallback grants no
30
- personal-auth ownership and changes no activity attribution. Open deployments
31
- and providers that return no identity share one result partition. A provider
32
- that supplies only an explicit principal uses that principal as its subject.
33
-
34
- `identity.connectorAccess` returns `"all"` or a list of grants. A grant is a
35
- declared connector id, which opens every tool on it, or a `connector.tool`
36
- address, which opens that tool alone. Grants are additive, so a bare id beside
37
- addresses for the same connector means the whole connector. It governs
38
- discovery and use, and defaults to all connectors.
39
-
40
- Tool grants are enforced in the scoped registry view, below the catalog
41
- service. Since 0.24.2, `search_tools`, `describe_tools`, both call tools, a
42
- program's `connecta.search`, `connecta.describe`, and `connecta.call`, and the
43
- connection UI read that filtered tool list. Connector-level discovery, guides,
44
- and `authorize_connector` retain a connector when any tool on it is granted.
45
- In particular, a `docs.read` grant permits the `docs` authorization handoff,
46
- subject to the separate auth-management permissions below. Without any grant
47
- on `docs`, `authorize_connector` returns the same "Unknown connector" refusal
48
- as an absent connector. An ungranted tool fails exactly like one the connector
49
- never had: `unknown_tool`, with no hint that it exists. That is the whole security
50
- claim, and it lives in one place on purpose. There is no separate endpoint per
51
- tool set; an identity that should see a narrower slice is a branch in this
52
- resolver, and a bot that needs its own slice is its own bearer subject.
33
+ One authorization yields three roles. The **actor** identifies the caller in
34
+ activity, the **subject** owns transient results such as `get_result` pages, and
35
+ the **principal** is the human owner of personal connector auth. An interactive
36
+ Clerk or Access user supplies all three; a Cloudflare service identity has an
37
+ actor and subject but no principal.
38
+
39
+ The principal is whichever comes first: an explicit `principal: { namespace, id }`
40
+ returned by `authorize`, accepted whenever it validates, or else one derived from
41
+ `userId` plus the provider's `activityActorNamespace`. A provider that returns its
42
+ own principal therefore needs no namespace declared; one relying on the derived
43
+ path does.
44
+
45
+ A subject or user id always selects a result-stash partition, even with no
46
+ `activityActorNamespace` declared — the namespace is then
47
+ `connecta:auth:<provider kind>`, and subject ids must be distinct within it. That
48
+ fallback grants no personal-auth ownership and changes no activity attribution. A
49
+ provider supplying only an explicit principal uses it as the subject too. Open
50
+ deployments and providers that return no identity share one result partition.
51
+
52
+ `identity.connectorAccess` returns `"all"` — the default — or a list of grants:
53
+ a declared connector id opens every tool on it, a `connector.tool` address opens
54
+ that tool alone, and grants are additive, so a bare id beside addresses for the
55
+ same connector means the whole connector. It governs discovery and use alike.
56
+
57
+ Tool grants are enforced in the scoped registry view, below the catalog service,
58
+ so `search_tools`, both call tools, a program's `connecta.search`,
59
+ `connecta.describe`, and `connecta.call`, and the connection UI all read the same
60
+ filtered list. Connector-level discovery, guides, and `authorize_connector` keep
61
+ a connector when any tool on it is granted, so a `docs.read` grant permits the
62
+ `docs` authorization handoff. Without any grant on `docs`, `authorize_connector`
63
+ returns the same "Unknown connector" refusal as an absent connector, and an
64
+ ungranted tool fails exactly like one the connector never had: `unknown_tool`,
65
+ with no hint that it exists. That is the whole security claim, and it lives in
66
+ one place on purpose.
67
+
68
+ There is no *caller-selected* tool set. A narrower slice is a branch in this
69
+ resolver or a config-declared pool; a bot that needs its own slice is its own
70
+ bearer subject. What a request may never do is name its own scope.
53
71
 
54
72
  ## Pools
55
73
 
56
74
  A pool is a named slice of the deployment served at its own endpoint,
57
- `/mcp/<pool>`, for the case where one identity needs different capability
58
- sets on different clients: a support agent that sees three Notion tools and
59
- Linear, a calendar bot that sees one tool, both over the same credentials and
60
- catalog cache.
75
+ `/mcp/<pool>`, for when one identity needs different capability sets on different
76
+ clients: a support agent that sees three Notion tools and Linear, a calendar bot
77
+ that sees one tool, both over the same credentials and catalog cache.
61
78
 
62
79
  ```ts
63
80
  createConnecta({
81
+ auth: [
82
+ bearerToken(botSecret, { subjectId: "calendar-bot" }),
83
+ clerkAuth({ publishableKey, secretKey }),
84
+ ],
64
85
  pools: {
65
86
  support: {
66
87
  tools: ["linear", "notion.search_pages", "notion.fetch_page"],
@@ -77,58 +98,156 @@ createConnecta({
77
98
  });
78
99
  ```
79
100
 
80
- The rules, each of which is a test:
101
+ Pools are meaningless without configured `auth`: an open deployment builds one
102
+ anonymous, non-interactive identity, so grants like these evaluate false and
103
+ every pool path 404s. The rules, each of which is a test:
81
104
 
82
105
  - **A pool narrows; it never widens.** The view on `/mcp/<pool>` is the pool
83
106
  intersected with the identity's own `connectorAccess`. Plain `/mcp` is
84
- unchanged. The security boundary is still the resolver; the pool decides
85
- which part of it a given client sees.
107
+ unchanged. The security boundary is still the resolver; the pool decides which
108
+ part of it a given client sees.
86
109
  - **Grant defaults to deny.** A pool with no `grant` serves nobody. Only a
87
- literal `true` admits; any other return, a throw, and an undeclared pool
88
- name produce one 404 identical in status, body, and headers, so a
89
- credential does not enumerate the other pools by response content. Timing
90
- is explicitly not hidden: a declared name awaits its grant, while an
91
- undeclared name returns without that lookup. We accept this pool-name
92
- oracle because names grant no access, a fixed delay cannot hide unbounded
93
- grant I/O, and invoking grants for unknown names would add avoidable work
94
- while holding an admission permit. Keep grants pure and fast; do not treat
95
- pool names as secrets. The operator log carries the refusal reason.
96
- - **Structural mistakes throw at construction.** A malformed name, an
97
- unknown connector, an empty pool, and a `connector.tool` address an
98
- `api()` connector's static catalog lacks all refuse to boot. Remote
99
- catalogs load lazily, so their addresses are checked at load and stay
100
- unreachable until they match.
110
+ literal `true` admits; any other return, a throw, and an undeclared pool name
111
+ produce one 404 identical in status, body, and headers, reached only after auth
112
+ succeeds — so pool names are not anonymously enumerable, and a valid credential
113
+ cannot tell the three cases apart by response content. Timing is not hidden: a
114
+ declared name awaits its grant while an undeclared name returns without that
115
+ lookup. We accept that oracle because names grant no access and a fixed delay
116
+ could not hide unbounded grant I/O anyway. Keep grants pure and fast; don't
117
+ treat pool names as secrets. The operator log carries the refusal reason.
118
+ - **Structural mistakes throw at construction.** A malformed name, an unknown
119
+ option, an unknown connector, an empty pool, and a `connector.tool` address an
120
+ `api()` connector's static catalog lacks all refuse to boot. Remote catalogs
121
+ load lazily, so their addresses are checked at load instead.
101
122
  - **OAuth discovery follows the path.** On Clerk, the 401 challenge for
102
- `/mcp/<pool>` names `/.well-known/oauth-protected-resource/mcp/<pool>`,
103
- whose `resource` is the pool URL, so RFC 9728 clients see a match.
104
- Cloudflare Managed OAuth is application-level and needs nothing.
105
-
106
- A `connector.tool` address the live catalog does not contain is unreachable
107
- and warned once while its address remains in a 1,024-entry FIFO. An evicted
108
- address may warn again; caller-derived grant text cannot grow retained warning
109
- state without bound. Remote catalogs load lazily, so construction
110
- cannot check it, and a catalog that drifts later can never widen a grant
111
- because there is no wildcard: every tool grant is an exact name.
112
-
113
- Visibility alone grants no authentication-management permission. Two
114
- independent resolvers return `"all"`, `"none"`, or declared connector ids:
115
-
116
- - `credentialAdministration` allows an interactive human to manage shared
117
- credentials and shared OAuth grants.
118
- - `personalConnection` allows an interactive human to manage their own
119
- credentials and OAuth grants on personal connectors.
120
-
121
- Both default to `"none"`. Each action requires visibility and the relevant
122
- permission. Personal actions also require a stable namespaced principal and
123
- always use that principal's partition. Resolver exceptions and unknown ids
124
- fail closed. Permissions come from authenticated identity, never caller input.
125
-
126
- The management resolvers receive `Readonly<AuthenticatedIdentity>`.
127
- `identity.activityAccess` receives `Readonly<IdentityReference>` with `id` and
128
- `namespace`, and controls reading global activity. Its default admits
129
- interactive humans, so team deployments should set it explicitly if the event
130
- stream should be restricted. It replaces `operatorAccess`; there is no general
131
- administrator role or token-management authority.
123
+ `/mcp/<pool>` names `/.well-known/oauth-protected-resource/mcp/<pool>`, whose
124
+ `resource` is the pool URL, so RFC 9728 clients see a match. Cloudflare
125
+ Managed OAuth is application-level and needs nothing.
126
+
127
+ An address the live catalog does not contain is unreachable and warned once
128
+ while it sits in a 1,024-entry FIFO, so caller-derived grant text cannot grow
129
+ retained warning state without bound. Later catalog drift can never widen a
130
+ grant: there is no wildcard, and every tool grant is an exact name.
131
+
132
+ ## Shared and personal auth
133
+
134
+ Connector auth defaults to `authScope: "shared"` — its credential, OAuth state,
135
+ tokens, catalog cache, and connector storage belong to the deployment. Set
136
+ `authScope: "personal"` when every human needs a separate downstream account:
137
+
138
+ ```ts
139
+ remoteMcp("linear", { url: "https://mcp.linear.app/mcp", authScope: "personal",
140
+ auth: { type: "oauth" } });
141
+ ```
142
+
143
+ A personal connector is absent — not refused — from any request without a
144
+ stable namespaced principal. For a principal that can see one, connecta
145
+ partitions connector storage, vault records, catalog caches, OAuth generations,
146
+ and observed result shapes under an opaque SHA-256 identity key. Keep namespaces
147
+ and principal ids stable across upgrades; changing either selects different
148
+ partitions. Literal `auth: { type: "headers" }` cannot be personal, because its
149
+ secret lives in deployment code; `remoteMcp()` refuses that combination at
150
+ construction.
151
+
152
+ ## Downstream OAuth state at rest
153
+
154
+ With a vault configured, a `remoteMcp()` OAuth connector's tokens, dynamically
155
+ registered client (secret included), and PKCE verifier are sealed with the
156
+ vault's AES-GCM key before they reach storage. The additional authenticated data
157
+ names the connector, the owner partition, and the physical key, which carries
158
+ the authorization epoch, so ciphertext copied to another connector, principal,
159
+ or epoch does not open. Anything that fails to open — a tampered value, a
160
+ rotated key — reads as absent: the connector reports `auth_required` and logs a
161
+ warning. The flow bookkeeping stays plaintext: `state`, the pending URL,
162
+ discovery metadata, and the generation. The callback route reads `state`
163
+ directly, and none of it authenticates anything by itself.
164
+
165
+ Plaintext left by an older release is read, then sealed where it lies through
166
+ the same generation fence as any write, so an upgrade keeps the grant. Sealing
167
+ is one-way. An older release reads sealed state as unusable, so rolling back
168
+ means authorizing again. A vault without the optional `seal`/`open` members
169
+ keeps these values plaintext and draws a startup warning. Without any vault,
170
+ nothing changes: the state is plaintext, as it always was.
171
+
172
+ ## Refresh failures
173
+
174
+ A failed token refresh means one of two things, and connecta decides which
175
+ from the token endpoint's answer, not the SDK's reading of it. A **dead grant**
176
+ is any 4xx except 408, 425, and 429, or a 2xx carrying an OAuth `error` (GitHub
177
+ answers `200 {"error":"bad_refresh_token"}`). It ends as `auth_required`, and
178
+ the refused tokens are deleted on the spot, so no later request or isolate
179
+ sends them again and the next `authorize_connector` goes straight to consent.
180
+ An **outage** is a 5xx, 408, 425, 429, a network failure, or a 2xx that is not
181
+ a token response. It ends as retryable `unavailable`, or `rate_limited` for a
182
+ 429, with `retryAfterMs` when the server sent `Retry-After`. The grant is kept,
183
+ and a passive call writes no consent URL. Every request joined on the same
184
+ in-flight refresh gets the same verdict, even when the request that sent the
185
+ refresh is cancelled after the answer arrives: the refused tokens are deleted
186
+ before anyone waiting is released, so a request arriving meanwhile joins the
187
+ refusal instead of sending the dead token again.
188
+
189
+ The SDK needs this help. On a refresh failure it cannot parse, or one marked
190
+ `server_error`, it falls through to starting authorization, which reports a
191
+ healthy grant as needing consent. It rethrows every other OAuth error
192
+ untouched, which reports a dead grant as a generic outage and keeps re-sending
193
+ it. So the refresh coordinator hands the SDK an answer it classifies correctly,
194
+ and the provider hooks it calls next finish the job. The pinned SDK behavior is
195
+ spelled out beside `refreshResponseOutcome`. An explicit authorization during
196
+ an outage still falls through to consent, since that is what it asked for.
197
+
198
+ ## URLs a downstream advertises
199
+
200
+ A `remoteMcp()` OAuth connector learns most of the URLs it fetches from the
201
+ downstream itself: the `resource_metadata` in its 401 challenge, the
202
+ `authorization_servers` in that metadata, and the token and registration
203
+ endpoints the authorization server publishes. Connecta fetches every one of
204
+ them server-side, so a compromised downstream could otherwise aim a Node or
205
+ Docker host at its own network — cloud metadata at `169.254.169.254`, an admin
206
+ panel on the LAN. Config is the security model, so the rule splits on it:
207
+
208
+ - A URL on the connector's configured origin is trusted. The operator wrote it
209
+ down.
210
+ - Any other URL must be `https` and must not name a private host: `localhost`
211
+ and `*.localhost`, `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`,
212
+ `100.64/10`, `0/8`, `::`, `::1`, `fc00::/7`, `fe80::/10`, or an IPv4-mapped
213
+ IPv6 form of any of those.
214
+ - A connector configured on a loopback host may also learn loopback URLs, over
215
+ `http` too, so a local MCP server with a local authorization server keeps
216
+ working. LAN and link-local addresses stay refused.
217
+
218
+ A refused URL is never requested. Discovery, registration, and code exchange
219
+ fail with a non-retryable `connector_call_failed` that names the host and
220
+ nothing else from the URL. A refused refresh never reaches the token endpoint,
221
+ so there is no verdict to act on: the grant is kept, and the SDK falls through
222
+ to consent as it does for any refresh it could not complete. Redirects cannot
223
+ route around the rule, because the redirect policy only follows same-origin
224
+ hops.
225
+
226
+ The check is syntactic. It reads the host after the WHATWG URL parser has
227
+ folded `2130706433` and `0x7f.1` into `127.0.0.1`, and it never resolves a
228
+ name, because the Workers-safe core has no DNS. A public name that resolves to
229
+ a private address, or rebinds to one after the check, is out of scope; a host
230
+ that must stop that runs connecta behind an egress policy that does.
231
+
232
+ ## Management permissions
233
+
234
+ Visibility alone grants no authentication-management permission. Two independent
235
+ resolvers take `Readonly<AuthenticatedIdentity>`, return `"all"`, `"none"`, or
236
+ declared connector ids, and both default to `"none"`:
237
+ `credentialAdministration` for shared credentials and shared OAuth grants, and
238
+ `personalConnection` for a human's own grants on personal connectors. Each action
239
+ needs visibility *and* the relevant permission; both resolvers run only for an
240
+ interactive identity; personal actions additionally need a stable namespaced
241
+ principal and always use that principal's partition. Resolver exceptions and
242
+ unknown ids fail closed, and permissions come from authenticated identity, never
243
+ from caller input.
244
+
245
+ `identity.activityAccess` takes `Readonly<IdentityReference>` — `id` and
246
+ `namespace` — and controls reading global activity. Undeclared, it admits every
247
+ interactive human, the one default here that is open, because a single-operator
248
+ deployment would otherwise be locked out of its own event stream. Team
249
+ deployments should set it. There is no general administrator role and no
250
+ token-management authority.
132
251
 
133
252
  ```ts
134
253
  createConnecta({
@@ -150,56 +269,47 @@ createConnecta({
150
269
  });
151
270
  ```
152
271
 
153
- Built-in Clerk and Access providers supply identity namespaces. A custom
154
- interactive provider must set `activityActorNamespace` before its users can
155
- own personal auth. Keep the namespace and principal ids stable across upgrades;
156
- changing them selects different personal storage partitions.
157
-
158
272
  ## Cloudflare Access on Workers
159
273
 
160
274
  [`cloudflareAccessAuth()`](https://developers.cloudflare.com/workers/configuration/cloudflare-access/)
161
- is the Worker-specific path:
275
+ is the Worker-specific path. `ethos.md` records it as **provisional**: Managed
276
+ OAuth and the Clerk migration still want production evidence
277
+ ([#506](https://github.com/zackbart/connecta/issues/506)).
162
278
 
163
279
  ```ts
164
280
  import { cloudflareAccessAuth } from
165
281
  "@zackbart/connecta/auth/cloudflare-access";
166
282
 
167
- createConnecta({
168
- auth: cloudflareAccessAuth(),
169
- connectors,
170
- executor,
171
- });
283
+ createConnecta({ auth: cloudflareAccessAuth(), connectors, executor });
172
284
  ```
173
285
 
174
286
  The adapter trusts only `ctx.access`, which Cloudflare creates after Access has
175
- authenticated a request that directly invokes the Worker. It calls
176
- `ctx.access.getIdentity()` for a human. Cloudflare returns no user identity for
177
- a service token and strips the service-token headers before invoking the
178
- Worker, so after `ctx.access` proves admission the adapter uses the Access
179
- application audience as the automation activity subject. Service tokens on the
180
- same Access application therefore share attribution. It never reads
181
- `Cf-Access-Jwt-Assertion`, downloads signing keys, or accepts a JWT from the
182
- caller. A missing context or an identity lookup that throws fails closed. This
183
- also means it is deliberately not a Node or `cloudflared` origin adapter, and
184
- it does not survive a Service Binding hop: those shapes need their own explicit
287
+ authenticated a request that directly invokes the Worker, and it reads identity
288
+ through `ctx.access.getIdentity()`. A human yields `user_uuid` or `email` as both
289
+ user and subject. A *service* identity yields `service_token_id`, or failing that
290
+ `common_name`, so distinct service tokens normally get distinct attribution;
291
+ either identity kind with no usable id is a 403. Only when Access returns no
292
+ identity at all does the adapter fall back to the Access application audience as
293
+ the subject — that, and only that, is the case where tokens on one application
294
+ share attribution. It never reads `Cf-Access-Jwt-Assertion`, downloads signing
295
+ keys, or accepts a JWT from the caller, and a missing context or throwing lookup
296
+ fails closed. It is therefore deliberately not a Node or `cloudflared` origin
297
+ adapter and does not survive a Service Binding hop; those shapes need their own
185
298
  trust boundary.
186
299
 
187
- A human identity gets a code-derived MCP view. Managing connection auth requires
188
- an explicit `credentialAdministration` or `personalConnection` grant. An Access
189
- service identity has no human principal and cannot mutate connection auth.
190
- Access decides admission and identity; Connecta configuration selects connector
191
- access and these narrower permissions.
300
+ Access decides admission and identity; connecta configuration decides connector
301
+ access and management permissions. An Access service identity, having no human
302
+ principal, cannot mutate connection auth at all.
192
303
 
193
304
  Protect the Worker with a Worker-level Access application whose destination is
194
305
  `{ "type": "worker", "worker_id": "<the Worker script tag>" }`. A traditional
195
306
  hostname-level application blocks the URL but does not attach `ctx.access` to
196
307
  the Worker. Enable [**Managed OAuth**](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/)
197
- on that Worker-level application for interactive MCP clients.
198
- Cloudflare then owns the unauthenticated challenge and `/.well-known/`
199
- metadata, issues opaque RFC 8707 tokens, and resolves them into the same trusted
200
- Worker identity. Managed OAuth allows no hosted client callback by default, so
201
- enable Dynamic Client Registration and add all three values to **Allowed
202
- redirect URIs**:
308
+ on that Worker-level application for interactive MCP clients. Cloudflare then
309
+ owns the unauthenticated challenge and `/.well-known/` metadata, issues opaque
310
+ RFC 8707 tokens, and resolves them into the same trusted Worker identity.
311
+ Managed OAuth allows no hosted client callback by default, so enable Dynamic
312
+ Client Registration and add all three values to **Allowed redirect URIs**:
203
313
 
204
314
  ```text
205
315
  https://claude.ai/api/mcp/auth_callback
@@ -208,43 +318,33 @@ https://chatgpt.com/connector/oauth/*
208
318
  ```
209
319
 
210
320
  Cloudflare exposes that list as
211
- `oauth_configuration.dynamic_client_registration.allowed_uris`. It belongs to
212
- the Access application's Managed OAuth settings, not the Access policy that
213
- selects admitted identities. Claude uses the fixed first value. ChatGPT may use
214
- its stable callback or a callback-id path covered by the third value. If a
215
- client registers a different redirect, add that exact URI or the narrowest path
216
- wildcard that covers it; do not allow the client's whole origin. Without these
217
- entries discovery succeeds and client registration fails later, which makes a
218
- missing allowlist look like a broken MCP server.
219
-
220
- Do not add a bypass for the discovery routes. A fully
221
- automated client instead uses a [Cloudflare Access service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
222
- through the
223
- `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers.
224
-
225
- Worker-level Access runs before every connecta route. Consequently:
226
-
227
- - `/health`, operator pages, downstream OAuth callbacks, and `/mcp` all require Access unless a more-specific hostname/path
228
- policy says otherwise;
229
- - a static Connecta bearer is not a standalone edge credential, because Cloudflare rejects them before connecta sees them; and
230
- - custom public webhooks belong to the deployment outside Connecta and need
231
- their own Access routing policy. Keep Connecta's OAuth discovery paths
232
- protected when Managed OAuth is enabled.
233
-
234
- The [Worker example](../examples/worker/) carries the complete deployment shape
235
- and the [upgrade guide](./upgrading.md#0200--0212) gives the reversible Clerk
236
- migration.
321
+ `oauth_configuration.dynamic_client_registration.allowed_uris`, on the Managed
322
+ OAuth settings rather than the Access policy that picks admitted identities.
323
+ Claude uses the first value; ChatGPT uses its stable callback or a callback-id
324
+ path covered by the third. For any other client add that exact URI or the
325
+ narrowest wildcard covering it, never the client's whole origin. Missing entries
326
+ let discovery succeed and registration fail later, which looks like a broken MCP
327
+ server rather than a console setting.
328
+
329
+ Do not add a bypass for the discovery routes; a fully automated client uses a
330
+ [service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
331
+ through `CF-Access-Client-Id` and `CF-Access-Client-Secret` instead. Worker-level
332
+ Access runs before every connecta route, so `/health`, operator pages, downstream
333
+ OAuth callbacks, and `/mcp` all require Access unless a more-specific policy says
334
+ otherwise, and a static connecta bearer is not a standalone edge credential
335
+ because Cloudflare rejects the request before connecta sees it. Custom public
336
+ webhooks live outside connecta and need their own Access routing policy. The
337
+ [Worker example](../examples/worker/) carries the whole deployment shape.
237
338
 
238
339
  ## Clerk configuration is checked at construction
239
340
 
240
341
  `clerkAuth` reads its Frontend API origin out of `publishableKey`, so a key that
241
- is not `pk_test_`/`pk_live_` followed by the base64-encoded domain cannot
242
- produce one. That throws where `allowedDomains` throws — when `clerkAuth` is
243
- called — with a message naming the option, never quoting the rejected value
244
- back: the usual way to land here is pasting the *secret* key into the
245
- publishable slot, and a startup error is a log line. A deployment that builds
246
- per request, as the Workers shape does, sees the same error on its first
247
- request instead of a base64 stack on every route.
342
+ is not `pk_test_`/`pk_live_` followed by the base64-encoded domain cannot produce
343
+ one. That throws where `allowedDomains` throws, when `clerkAuth` is called, naming
344
+ the option and never quoting the rejected value back — the usual way to land here
345
+ is pasting the *secret* key into the publishable slot. A deployment that builds per
346
+ request, as the Workers shape does, sees that same error on its first request
347
+ instead of a base64 stack on every route.
248
348
 
249
349
  ## Human authentication management
250
350
 
@@ -253,19 +353,20 @@ visibility, the appropriate shared or personal permission, and an exact
253
353
  same-origin `Origin` for browser requests. A configured MCP bearer never becomes
254
354
  a browser management credential.
255
355
 
256
- With `ui: operatorUi()` and a vault, static credential recovery can return a
257
- secret-free handoff to the connection UI. Without the UI, that recovery is
258
- `unavailable`; Connecta does not return a link to a missing page. An authorized
259
- interactive MCP caller can still start downstream OAuth through
260
- `authorize_connector` without the UI. Core owns the callback and verifies state
261
- and principal ownership independently of the optional browser application.
262
- A browser returning from downstream consent normally carries no MCP
263
- Authorization header, so an interactive bearer provider's 401 does not reject
264
- the callback. The verified state and its saved principal handoff select the
265
- owner; a browser identity, when present, must match that owner and may manage
266
- the connector. An interactive provider's explicit 403 still refuses the flow.
267
-
268
- See [meta-tools](./meta-tools.md#authorization-recovery) and
269
- [storage and credentials](./storage-and-credentials.md). The
270
- [upgrade guide](./upgrading.md#0240-optional-modules) covers moving clients
271
- off removed Connecta-issued tokens before changing deployment configuration.
356
+ `authorize_connector` splits along what it would change. For a connector with a
357
+ static credential slot it mutates nothing, so visibility is enough: with
358
+ `ui: operatorUi()` and a vault it returns a secret-free `operator_config` handoff
359
+ naming the credential fields and the operator URL, and without either the recovery
360
+ is `unavailable`, since connecta does not hand back a link to a missing page. Only
361
+ the downstream-OAuth branch consults the management permissions, and an identity
362
+ lacking them gets `unavailable` there.
363
+
364
+ Core owns the OAuth callback and verifies state and principal ownership
365
+ independently of the optional browser application. A browser returning from
366
+ downstream consent normally carries no MCP `Authorization` header, so an
367
+ interactive bearer provider's 401 does not reject the callback. The verified state
368
+ and its saved principal handoff select the owner; a browser identity, when
369
+ present, must match that owner and may then manage the connector, while an
370
+ interactive provider's explicit 403 still refuses the flow. See
371
+ [meta-tools](./meta-tools.md#authorization-recovery) for the recovery shapes a
372
+ caller actually receives.