@zackbart/connecta 0.32.0 → 0.34.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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +95 -116
  3. package/SECURITY.md +1 -1
  4. package/bin/connecta.mjs +24 -6
  5. package/bin/version.mjs +1 -1
  6. package/dist/access-tokens.d.ts +167 -23
  7. package/dist/access-tokens.js +676 -140
  8. package/dist/activity.d.ts +3 -0
  9. package/dist/agent-identity.js +47 -0
  10. package/dist/auth/cloudflare-access.js +13 -5
  11. package/dist/branding.d.ts +7 -0
  12. package/dist/branding.js +76 -9
  13. package/dist/call-admission-context.js +15 -0
  14. package/dist/call-admission.js +30 -43
  15. package/dist/call-recovery.js +6 -2
  16. package/dist/catalog-service.js +15 -1
  17. package/dist/config-value-sources.js +16 -9
  18. package/dist/config.d.ts +48 -75
  19. package/dist/config.js +144 -36
  20. package/dist/connector-access.d.ts +4 -34
  21. package/dist/connector-access.js +15 -99
  22. package/dist/connector-caller.d.ts +7 -1
  23. package/dist/connector-caller.js +4 -2
  24. package/dist/connectors/api-connector.d.ts +27 -4
  25. package/dist/connectors/api-connector.js +2 -0
  26. package/dist/connectors/remote-mcp.js +2 -0
  27. package/dist/describe-config.d.ts +31 -7
  28. package/dist/describe-config.js +8 -17
  29. package/dist/execute.js +1 -11
  30. package/dist/fix-prompt.js +2 -2
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +5 -3
  34. package/dist/membership.d.ts +53 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/module-contracts.d.ts +23 -6
  37. package/dist/oauth-callback-outcome.js +2 -2
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -4
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/config-summary.js +562 -0
  43. package/dist/operator-ui/connection-map.js +375 -0
  44. package/dist/operator-ui/contract.d.ts +149 -28
  45. package/dist/operator-ui/endpoints.js +221 -0
  46. package/dist/operator-ui/fix-prompts.js +5 -5
  47. package/dist/operator-ui/generated.js +5 -4
  48. package/dist/operator-ui/member-page.js +97 -0
  49. package/dist/operator-ui/model.d.ts +14 -6
  50. package/dist/operator-ui/overview.js +134 -0
  51. package/dist/operator-ui/view.d.ts +107 -37
  52. package/dist/operator-ui/view.js +163 -86
  53. package/dist/page-styles.js +5 -3
  54. package/dist/provider.d.ts +61 -8
  55. package/dist/provider.js +46 -7
  56. package/dist/providers/_shared/google/workspace.d.ts +56 -17
  57. package/dist/providers/_shared/google/workspace.js +34 -8
  58. package/dist/providers/_shared/rest/tools.js +2 -2
  59. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  60. package/dist/providers/_shared/rest/value-safety.js +699 -7
  61. package/dist/providers/ccb/index.js +2 -2
  62. package/dist/providers/cloudflare/index.js +3 -3
  63. package/dist/providers/cloudflare/rest.js +25 -3
  64. package/dist/providers/cloudflare/skill.generated.js +1 -1
  65. package/dist/providers/cloudflare/value-safety.js +32 -0
  66. package/dist/providers/docs/index.js +4 -4
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.js +4 -4
  69. package/dist/providers/drive/skill.generated.js +2 -2
  70. package/dist/providers/forms/index.js +4 -4
  71. package/dist/providers/forms/skill.generated.js +2 -2
  72. package/dist/providers/gmail/index.js +11 -9
  73. package/dist/providers/gmail/skill.generated.js +2 -2
  74. package/dist/providers/mixpanel/index.js +1 -1
  75. package/dist/providers/mixpanel/skill.generated.js +3 -3
  76. package/dist/providers/notion/index.d.ts +7 -0
  77. package/dist/providers/notion/index.js +100 -11
  78. package/dist/providers/notion/rest.js +45 -5
  79. package/dist/providers/notion/skill.generated.js +2 -2
  80. package/dist/providers/notion/value-safety.js +30 -5
  81. package/dist/providers/overflow/index.js +2 -2
  82. package/dist/providers/overflow/skill.generated.js +2 -2
  83. package/dist/providers/planning-center/index.js +2 -2
  84. package/dist/providers/planning-center/skill.generated.js +4 -4
  85. package/dist/providers/revenuecat/skill.generated.js +2 -2
  86. package/dist/providers/sheets/index.d.ts +3 -0
  87. package/dist/providers/sheets/index.js +7 -4
  88. package/dist/providers/sheets/skill.generated.js +2 -2
  89. package/dist/providers/slides/index.js +4 -4
  90. package/dist/providers/slides/skill.generated.js +2 -2
  91. package/dist/providers/stripe/rest.js +9 -2
  92. package/dist/providers/stripe/skill.generated.js +1 -1
  93. package/dist/providers/stripe/value-safety.js +22 -0
  94. package/dist/providers/tithely/index.js +1 -1
  95. package/dist/providers/vercel/index.js +1 -1
  96. package/dist/providers/vercel/rest.js +20 -2
  97. package/dist/providers/vercel/skill.generated.js +2 -2
  98. package/dist/providers/vercel/value-safety.js +22 -0
  99. package/dist/registry.d.ts +2 -3
  100. package/dist/registry.js +23 -9
  101. package/dist/routes/access-tokens.js +82 -75
  102. package/dist/routes/activity-disclosure.js +17 -18
  103. package/dist/routes/activity.js +34 -54
  104. package/dist/routes/actor-labels.js +33 -4
  105. package/dist/routes/agent-path.js +43 -0
  106. package/dist/routes/connect.js +16 -12
  107. package/dist/routes/credentials.js +16 -40
  108. package/dist/routes/mcp.js +161 -104
  109. package/dist/routes/oauth-management.js +11 -5
  110. package/dist/routes/oauth.js +5 -11
  111. package/dist/routes/operator.js +24 -23
  112. package/dist/routes/shared.d.ts +60 -20
  113. package/dist/routes/shared.js +187 -96
  114. package/dist/routes/ui-config.js +437 -97
  115. package/dist/routes/ui-data.js +2 -2
  116. package/dist/routes/ui.js +9 -16
  117. package/dist/runtime/call-admission.js +9 -0
  118. package/dist/server.js +5 -0
  119. package/dist/skills.js +10 -0
  120. package/dist/storage/keys.js +9 -1
  121. package/dist/storage/sql.js +5 -1
  122. package/dist/types.d.ts +29 -7
  123. package/dist/ui.d.ts +1 -1
  124. package/dist/ui.js +23 -16
  125. package/dist/usage-guide.js +1 -1
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/dist/workspace-address.js +9 -0
  129. package/dist/workspace-subject.js +23 -0
  130. package/documentation/README.md +47 -23
  131. package/documentation/architecture.md +371 -61
  132. package/documentation/auth.md +897 -249
  133. package/documentation/code-mode.md +37 -16
  134. package/documentation/deploying.md +126 -25
  135. package/documentation/integrating.md +97 -23
  136. package/documentation/meta-tools.md +50 -31
  137. package/documentation/migration-0.33.md +352 -0
  138. package/documentation/migration-0.34.md +67 -0
  139. package/documentation/operating.md +3 -1
  140. package/documentation/operator-tests.md +147 -34
  141. package/documentation/operator-ui.md +519 -64
  142. package/documentation/provider-auth-migration.md +37 -23
  143. package/documentation/provider-migration-0.29.md +2 -2
  144. package/examples/worker/AGENTS.md +37 -9
  145. package/examples/worker/README.md +127 -61
  146. package/examples/worker/src/connecta.config.ts +40 -23
  147. package/examples/worker/wrangler.jsonc +4 -1
  148. package/package.json +19 -18
  149. package/templates/node/.env.example +3 -0
  150. package/templates/node/AGENTS.md +33 -6
  151. package/templates/node/Dockerfile +2 -2
  152. package/templates/node/README.md +80 -52
  153. package/templates/node/docker-compose.yml +3 -2
  154. package/templates/node/package.json +4 -3
  155. package/templates/node/src/adopt-token.ts +23 -0
  156. package/templates/node/src/connecta.config.ts +28 -12
  157. package/templates/node/src/rotate-token.ts +17 -0
  158. package/templates/node/src/provision-token.ts +0 -17
@@ -3,13 +3,14 @@
3
3
  ## Task routes
4
4
 
5
5
  [Agent index](./README.md) distinguishes endpoint operation from deployment
6
- and package maintenance. This guide owns identity, grants, and downstream
6
+ and package maintenance. This guide owns identity, membership, and downstream
7
7
  credential contracts; use [deployment setup](./deploying.md) for runnable steps.
8
8
 
9
9
  | Task | Sections |
10
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) |
11
+ | Connect a machine or migrate an old client | [Agent tokens](#agent-tokens-and-upgrading-from-earlier-tokens), [agent endpoints](#agent-endpoints), [static bearer migration](#migrating-static-bearer-clients) |
12
12
  | Configure caller visibility, read-only endpoints, or auth ownership | [Principals and operators](#principals-visibility-and-operators), [pools](#pools), [shared and personal auth](#shared-and-personal-auth) |
13
+ | Lay out an agent's pools | [Organizing an agent's pools](#organizing-an-agents-pools) |
13
14
  | 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
15
  | Enable credential or connection controls | [Management permissions](#management-permissions), [start/restart/disconnect](#starting-restarting-and-disconnecting) |
15
16
  | 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) |
@@ -21,6 +22,155 @@ defines refusal precedence.
21
22
  [Source and tests](#source-and-tests) points to the implementation. Migration
22
23
  sections describe upgrade procedures, not additional auth modes to enable.
23
24
 
25
+ ## Add a person
26
+
27
+ People authenticate with Clerk on Node or Cloudflare Access on Workers.
28
+ Finish [Clerk setup](#clerk-oauth-tokens-and-operator-sessions) or
29
+ [Access setup](#cloudflare-access-on-workers) if the deployment has no human
30
+ provider yet. A machine-only deployment cannot admit people or manage shared
31
+ auth through the operator UI.
32
+
33
+ 1. Admit the person in the identity provider's sign-in policy. Obtain their
34
+ stable principal id there: Clerk's user id, or Access's `user_uuid` (email
35
+ only when no `user_uuid` is supplied). A display name or an email for a
36
+ Clerk user is not their principal id.
37
+ 2. Choose their role in `src/connecta.config.ts`. Add an administrator to
38
+ `maintainers`; they can reach plain `/mcp`, manage shared credentials and
39
+ agent tokens, and read activity. For a person needing selected tools, add
40
+ them to the intended pool's `members` instead. Maintainers need explicit
41
+ pool membership too if they will use `/mcp/<pool>`.
42
+ 3. Use a bare id with exactly one interactive provider, or `{ namespace, id }`
43
+ with several (Clerk's Frontend API URL or `cloudflare-access` as namespace).
44
+ Keep these stable: personal credentials are partitioned by principal.
45
+ 4. Typecheck and restart/redeploy. Sign in as the person and verify their
46
+ intended endpoint and tools. A pool-only member at plain `/mcp` gets 404.
47
+ With the UI enabled, the [member page](./operator-ui.md#use-the-member-page)
48
+ shows their pool URLs and client setup; `/ui/api/config` returns 200 with
49
+ `role: "member"`. It does not grant the maintainer pages, shared credential
50
+ controls, activity, or token management. For their own downstream login, use `authScope: "personal"`; they connect
51
+ only their own account on connectors included in their pools.
52
+
53
+ For example, with one Clerk provider, Alice administers the deployment and
54
+ Bob uses only the team pool. Replace these illustrative Clerk ids with the
55
+ ids from that deployment's Clerk instance:
56
+
57
+ ```ts
58
+ maintainers: ["user_alice"],
59
+ pools: {
60
+ team: { tools: ["crm", "tickets"], members: ["user_alice", "user_bob"] },
61
+ },
62
+ ```
63
+
64
+ In the Node template and Worker example, `CONNECTA_MAINTAINERS` is a
65
+ comma-separated list of these ids; update that environment variable when
66
+ using their roster wiring. Pool membership remains code. To remove a person,
67
+ remove their memberships and maintainer entry, restart/redeploy, and remove
68
+ their sign-in admission as appropriate. Never add another person to an
69
+ agent's `<agent>-<user>` pool, and add a person to an agent's tier only if
70
+ they are cleared for it; follow [the pool layout](#organizing-an-agents-pools).
71
+
72
+ ## Connect a shared credential
73
+
74
+ Shared auth belongs to the deployment. Every admitted caller of that
75
+ connector uses the same downstream account. Only a signed-in interactive
76
+ maintainer can change it; an agent token cannot perform the setup.
77
+
78
+ 1. Configure the connector in `connectors` with `authScope: "shared"`
79
+ (the default) and either OAuth or a credential slot. Include it only in
80
+ the intended pools. For a user's account used by an agent, give it its
81
+ own id such as `notes_alice` and expose it only in `assistant-alice`.
82
+ 2. Enable the encrypted vault over the deployment's existing storage before
83
+ saving credentials or OAuth tokens. Keep its base64 32-byte key in secret
84
+ storage and preserve it across restarts. Node's template reads
85
+ `CONNECTA_CREDENTIAL_KEY`; Workers read `CREDENTIAL_ENCRYPTION_KEY`.
86
+ See the platform README's credential-vault setup. Enable `operatorUi()`
87
+ for credential-slot editing and human auth with a maintainer roster.
88
+ 3. Typecheck and restart/redeploy. Sign in as a maintainer, open the
89
+ connector's page, and use its Connection tab. For a credential slot, enter
90
+ and save the downstream token. For OAuth, connect and finish the browser
91
+ consent as the intended downstream account. An MCP maintainer can also
92
+ call `authorize_connector` for that id and follow its signed browser link;
93
+ that link grants no extra authority.
94
+ 4. Check connected status, then make a bounded read from the intended pool.
95
+ Check the downstream account, not just successful authentication. For
96
+ `notes_alice`, the maintainer must authorize Alice's intended account.
97
+
98
+ For example, this connector expects a Bearer token entered in the UI; the
99
+ secret never belongs in the configuration:
100
+
101
+ ```ts
102
+ remoteMcp("crm", {
103
+ url: "https://crm.example.com/mcp",
104
+ authScope: "shared",
105
+ auth: { type: "credential", credential: { label: "CRM API token" } },
106
+ });
107
+ // OAuth alternative: auth: { type: "oauth" }
108
+ ```
109
+
110
+ To rotate a saved token, save its replacement in the same slot; subsequent
111
+ calls use it. Revoke the retired token at the downstream service. For OAuth,
112
+ use Reconnect (or Connect account) to authorize afresh, Continue requested
113
+ authorization to resume a pending consent link, and Disconnect to invalidate the local grant and catalog. To remove a credential,
114
+ use the connector's credential delete control. Clearing a slot with no saved
115
+ value returns 409 with `problem: "credential_required"`; it does not revoke
116
+ anything downstream. An unreadable stored value can still be cleared for
117
+ recovery. These changes do not change
118
+ pool membership. Environment-backed header auth has no editable vault slot:
119
+ rotate that secret in the deployment's secret store and restart/redeploy.
120
+
121
+ ## Issue, rotate, or revoke an agent token
122
+
123
+ Each declared agent has exactly one token slot. Keep `accessTokens(storage)`
124
+ on the same database as the server. Declare the agent and its pools in code
125
+ before issuing a token, for example:
126
+
127
+ ```ts
128
+ agents: ["assistant"],
129
+ accessTokens: accessTokens(storage),
130
+ pools: { assistant: { tools: ["crm", "tickets"], members: ["agent:assistant"] } },
131
+ ```
132
+
133
+ Typecheck and restart/redeploy, then use either procedure:
134
+
135
+ - **Signed-in maintainer:** with human auth and `operatorUi()` enabled, open
136
+ the Tokens page, select `assistant`, and issue, rotate, or revoke
137
+ its token. Save an issued secret privately immediately; it appears once.
138
+ The UI reads the slot revision and sends it with the operation. If the slot
139
+ changed, reload and decide again.
140
+ - **Trusted local provisioning:** the Node template's
141
+ `npm run --silent rotate-token -- assistant` both issues and rotates.
142
+ Run it with the same environment and database path as the server; local
143
+ commands do not load `.env`. Store stdout privately. For revocation, add a
144
+ small trusted script such as the following to `src/revoke-token.ts` and
145
+ run `npx tsx src/revoke-token.ts` with that same environment. Workers use
146
+ the maintainer UI or trusted tooling bound to the same D1 database;
147
+ a local SQLite script cannot manage a Worker's slots.
148
+
149
+ ```ts
150
+ import { AccessTokenManager } from "@zackbart/connecta/auth/access-tokens";
151
+ import config from "./connecta.config.js";
152
+
153
+ const { storage, agents } = config(process.env);
154
+ const manager = new AccessTokenManager(storage!, { agents: agents ?? [] });
155
+ const slot = (await manager.list()).find((entry) => entry.name === "assistant");
156
+ if (!slot) throw new Error("Declare assistant before managing its token");
157
+ await manager.revoke("assistant", { expect: slot.revision });
158
+ ```
159
+
160
+ Set the client URL to `https://connecta.example.com/agent/assistant` and
161
+ `Authorization: Bearer <saved-cta-token>`. Its one token also works on every
162
+ personal pool listing `agent:assistant`; the client picks the pool per turn.
163
+ Behind Access, configure the [agent bypass](./deploying.md#agent-endpoints-behind-cloudflare-access).
164
+ Run [doctor](./deploying.md#verify-and-diagnose) with the new token. After a
165
+ rotation, replace the client's saved secret immediately: the old token stops
166
+ admitting on its next request. After revocation, confirm the old token gets 401. Already admitted requests are not recalled. Never put tokens in source,
167
+ activity, or committed output, and never use `/mcp` with a `cta_` token.
168
+
169
+ If a rotation reports `outcome_unknown`, inspect the slot; an issued secret
170
+ that was not delivered cannot be recovered. Rotate again deliberately for a
171
+ replacement. For older tokens, follow [adoption](#agent-tokens-and-upgrading-from-earlier-tokens)
172
+ instead of assuming they still admit after an upgrade.
173
+
24
174
  ## Credential recognition and admission
25
175
 
26
176
  Inbound auth decides who may reach the MCP endpoint. People use `clerkAuth`
@@ -52,84 +202,238 @@ The `cta_` syntax is reserved even without the optional token verifier: it
52
202
  refuses rather than becoming an ambient human or an open anonymous request.
53
203
  Async recognition hooks fail at construction; unexpected rejected promises
54
204
  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
205
+ Agents reach pools only at [agent endpoints](#agent-endpoints). Every human
206
+ route (`/mcp`, `/mcp/<pool>`, operator pages and APIs, `/connect`, OAuth
207
+ starts and callbacks) refuses a `cta_` token before any recognition hook,
208
+ token storage read, cookie, session, or Access context is consulted: 401 with
209
+ the endpoint's challenge on MCP endpoints and operator data routes, and 403 on
210
+ routes that require an interactive person. The built-in `cta_` adapter is
211
+ never a candidate there, so a refused `cta_` token cannot fall back to an
212
+ ambient identity. Human routes then ask only human providers. Replace custom `final` and
57
213
  `finalRefusals` handling with recognition; handshake redirects and admission
58
214
  policy refusals are provider verdicts too. Recognition grants no identity.
59
215
 
60
- `authorize` returns `{ ok: true, userId?, subjectId?, principal? }` or a refusal
216
+ `authorize` returns `{ ok: true, userId?, subjectId?, principal?, agent? }` or a refusal
61
217
  carrying a `Response`. On 401, routing and challenge selection use the first
62
218
  provider that actually answers the endpoint's protected-resource metadata
63
219
  request. Its `challenge(request, baseUrl)` supplies `WWW-Authenticate`, including
64
220
  `scope`; an unrelated metadata hook does not own resource discovery. Machine-only
65
221
  endpoints challenge with `Bearer` and advertise no authorization server. With
66
- Clerk, even a refused `cta_` token receives Clerk's resource metadata challenge.
222
+ Clerk, a `cta_` token refused at `/mcp` or `/mcp/<pool>` still receives Clerk's
223
+ resource metadata challenge.
67
224
  An open deployment with connectors warns at construction.
68
225
 
69
226
  Clerk denial logs carry checked fixed reason codes. Provider user IDs, email
70
227
  addresses, and email domains stay out of denial logs, activity, and status,
71
228
  including email lookup failures and admission policy refusals.
72
229
 
73
- ## Managed client tokens and upgrading from v0.23
230
+ ## Agent tokens and upgrading from earlier tokens
74
231
 
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.
232
+ Machine clients are **agents**, declared by name in code. `agents: string[]`
233
+ lists them: each name matches `^[a-z0-9_-]+$`, at most 64 characters, and is
234
+ unique; anything else, or agents without `accessTokens`, throws at
235
+ construction. `describeConfig()` lists the names and never token state.
236
+ `accessTokens` from `@zackbart/connecta/auth/access-tokens` installs the `cta_`
237
+ inbound adapter and, beside `ui`, the Tokens page and `/ui/access-tokens`
238
+ routes. Omitted, none of it loads or is served. It takes no options;
239
+ `accessTokens(storage, { maxActive })` throws, since each declared agent has
240
+ exactly one token slot.
78
241
 
79
242
  ```ts
80
243
  import { accessTokens } from "@zackbart/connecta/auth/access-tokens";
81
244
  createConnecta({
82
245
  storage,
246
+ agents: ["assistant", "ci"],
83
247
  accessTokens: accessTokens(storage),
84
248
  auth: clerkAuth({ publishableKey, secretKey }),
85
249
  ui: operatorUi(),
86
- identity: {
87
- connectorAccess, // Keep the existing principal and token-id grant rules.
88
- accessTokenManagement: ({ principal }) =>
89
- principal?.namespace === "clerk:your-existing-namespace" &&
90
- principal.id === "your-operator-id",
91
- },
250
+ maintainers: ["your-operator-id"],
251
+ pools: { ci: { tools: ["github"], members: ["agent:ci"] } },
92
252
  connectors, executor,
93
253
  readOnly: false, // required: true refuses every write call
94
254
  });
95
255
  ```
96
256
 
97
- From v0.23, replace `accessTokens: true` or its options object with
98
- `accessTokens(storage)` on **the same storage and key namespace**, keeping the
99
- `access-token:v1:record:*` and `access-token:v1:lookup:*` records: unrevoked
100
- `cta_…` secrets keep working with no recovery, rewrite, or client rotation. Keep
101
- the identity namespaces and connector/pool grant callbacks too; storage
102
- compatibility translates no configuration and invents no grants. `access_token`
103
- actor ids, the `connecta:access-tokens:v1` activity namespace, friendly names,
104
- and stored principals carry over; a token without a principal stays unbound.
105
- Every request evaluates current identity, tool, and endpoint grants, and a token
106
- never becomes an interactive operator. Names are labels, never permissions. A
107
- UI-issued token belongs to the issuing human's principal, and any explicitly
108
- permitted token manager can list, rename, or revoke deployment tokens. Machine tokens manage neither tokens nor connection credentials; operators
109
- need an interactive provider.
110
-
111
- Issuance claims capacity with `compareAndSet`, because counting records before
112
- writing admits too many concurrent creates; every supported store provides
113
- it. Active
114
- capacity defaults to 100, configurable with
115
- `accessTokens(storage, { maxActive: 200 })` up to 1,000, counted by a durable
116
- reservation before a secret is written. A failure before lookup publication
117
- releases it; an uncertain lookup write keeps its metadata and capacity so an
118
- operator can revoke it, even if creation returned no secret. Creation is never
119
- retried automatically, and once new-version issuance starts, old-version
120
- instances must create no tokens: they ignore reservations.
121
-
122
- Secrets carry 256 random bits; only SHA-256 digests persist. Creation returns
123
- the secret once, list and rename never. Revocation removes the lookup before
124
- updating metadata and authorization caches no token, so revocation takes
125
- effect on the next authorization (D1 and SQLite are strongly consistent);
126
- admitted requests are not recalled. Management writes
127
- require an exact same-origin Origin; responses are private and non-cacheable.
257
+ **Slots.** Each declared agent has one slot: `none`, `issued`, or `revoked`.
258
+ Nothing outside code adds, renames, or rebinds an agent. An interactive
259
+ maintainer issues the first token, rotates it, or revokes it; `AccessTokenManager` exposes
260
+ the same `rotate(name)` and `revoke(name)` to trusted provisioning, and the
261
+ Node template wraps rotation as `npm run --silent rotate-token -- <agent>`.
262
+ Secrets carry 256 random bits and are returned once, on issue or rotate; only
263
+ SHA-256 digests persist. Listing returns `{ name, state, issuedAt?, revokedAt?,
264
+ revision }` per declared agent, never a secret, digest, or prefix.
265
+
266
+ **Revisions and `expect`.** A slot's `revision` is opaque: 22 base64url
267
+ characters, the first 128 bits of a domain-separated SHA-256 of the agent name
268
+ and the raw stored slot (`none` hashes the absent slot). Every commit stores a
269
+ new slot value, since an issued slot names a fresh record id and a revoked one
270
+ carries a random `revocationId` generated once per revoking call, so the
271
+ revision changes on every issue, rotate, revoke, and adopt and on nothing
272
+ else. The stored slot holds a record id or revocation id and times, never a
273
+ secret or digest, and those ids leave only hashed. The token
274
+ routes return it on every slot: `GET /ui/access-tokens` lists it, and issue,
275
+ rotate, and revoke return the new slot's. Operator contract v2's
276
+ `OperatorAgent.token.revision` carries the same value.
277
+
278
+ The token routes require the revision the caller decided from:
279
+
280
+ | Route | `expect` |
281
+ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
282
+ | `POST /ui/access-tokens/<agent>` (issue, rotate) | JSON body `{ "expect": "<revision>" }`, `Content-Type: application/json` |
283
+ | `DELETE /ui/access-tokens/<agent>` (revoke) | the same body, or `?expect=<revision>` for a client that cannot send a body with DELETE; not both |
284
+
285
+ A missing or malformed `expect` is 400, as is a JSON body that does not parse
286
+ to an object; a body without `Content-Type: application/json` is 415, and one
287
+ over 1,024 characters is 413. They are checked after authorization and before
288
+ the operation. If the slot's current revision differs, the route answers 409
289
+ `{ "error": "…", "code": "slot_changed" }` before minting a secret or writing
290
+ anything; reload the list and decide again. The check is part of the commit,
291
+ not only the read: the slot's compare-and-set compares against exactly the raw
292
+ slot whose revision matched, so a change that lands between the read and the
293
+ commit refuses with `slot_changed` too, and this call publishes nothing. A
294
+ revocation under `expect` never revokes a token issued after its read. A call
295
+ decides that its own write landed only by an id no other call can produce: a
296
+ rotation or adoption by the fresh record id its slot names, a revocation by its
297
+ `revocationId`. Under `expect`, a compare-and-set that answers "not committed"
298
+ is `slot_changed` at once, so of two conditional revocations of one slot,
299
+ even within one millisecond, exactly one succeeds; an unanswered one is read
300
+ back and succeeds only if the slot carries its own `revocationId`. Issuing
301
+ from a `none`-era revision over a slot that now exists, or rotating over
302
+ someone else's fresh rotation, therefore cannot happen. `aborted` (499) and
303
+ `outcome_unknown` (503) are unchanged. `AccessTokenManager.rotate(name,
304
+ issuedBy, { expect })`, `revoke(name, { expect })`, and `adopt(tokenId, name,
305
+ { expect })` take the same optional precondition and refuse with an
306
+ `AccessTokenError` whose `code` is `slot_changed`; without `expect` they behave
307
+ as before, which is what the template scripts use.
308
+
309
+ **Admission.** A `cta_` token admits only while its record is bound to an agent
310
+ currently declared in code and that agent's slot names it. Every other case
311
+ refuses with the same 401 as an unknown token: an undeclared agent, a revoked or
312
+ rotated-out token, a token bound to a human principal, and an unbound token from
313
+ an earlier release. Removing an agent from code stops its token on the next
314
+ request without any storage write. An admitted agent's identity is:
315
+
316
+ ```ts
317
+ {
318
+ actor: { kind: "access_token", id: "<name>", namespace: "connecta:access-tokens:v1" },
319
+ subject: { namespace: "connecta:access-tokens:v1", id: "agent:<name>" },
320
+ agent: "<name>",
321
+ interactive: false,
322
+ }
323
+ ```
324
+
325
+ `identity.agent` is set only by the built-in adapter; a result from any other
326
+ inbound provider that carries `agent` is refused like an unknown token. An
327
+ agent has no principal, so personal connectors are absent for it and it never
328
+ reads activity. Its subject partitions result handles per
329
+ agent. Activity attributes its calls to the agent name. Pools list an agent as
330
+ `"agent:<name>"`, and the agent reaches each such pool at `/agent/<pool>`
331
+ only. An agent is never a maintainer, and every human endpoint refuses its
332
+ token. An agent never becomes an interactive operator: machine
333
+ tokens manage neither tokens nor connection credentials.
334
+
335
+ **The slot is the only authority.** A token admits iff its digest's lookup
336
+ names a record, that record carries a currently declared `agent`, and that
337
+ agent's slot is `issued` naming exactly that record. Nothing else counts: a
338
+ lookup or record the slot does not name is inert garbage. Every mutation
339
+ commits by one `compareAndSet` on the slot and only moves forward; no security
340
+ outcome depends on cleanup or on undoing a write.
341
+
342
+ - **Rotate** writes the new record and its lookup (inert), checks the
343
+ request's signal, then compare-and-sets the slot from what it read to
344
+ `issued` with the new record. That is the commit: from it the new secret
345
+ admits and the previous one refuses, so after `rotate` returns the old secret
346
+ fails on its next request. Of concurrent rotations exactly one commits and
347
+ returns a secret; the others publish nothing and fail with `slot_changed`
348
+ under `expect` (always, on the route) or a conflict without it. An unanswered commit is read back, never assumed or replayed.
349
+ If the request has ended once the commit is decided, the secret cannot be
350
+ delivered, so the slot moves forward to `revoked` by compare-and-set; if that
351
+ cannot be settled, the call fails with `outcome_unknown` (503 on the
352
+ route), never success. After an `outcome_unknown` rotation the operator UI
353
+ may show the slot as `issued` although no secret was returned; that secret
354
+ cannot be recovered, so rotate again to get a replacement.
355
+ - **Revoke** reads the slot, checks the signal, and compare-and-sets it to
356
+ `revoked`. An abort before that commit changes nothing. Without `expect`, a
357
+ slot that changed before the commit is revoked as it now stands; with it,
358
+ the revocation refuses with `slot_changed`.
359
+ - **Nothing is deleted on the request path.** Rotate, revoke, and adopt never
360
+ delete or compare-and-set away a lookup or record, except the record and
361
+ lookup the same call just wrote while they are known to be uncommitted, each
362
+ removed by one conditional delete. Previous, revoked, and superseded records
363
+ and lookups stay behind, inert, because no slot names them. Storage therefore
364
+ grows by one small record (and, for rotation, one lookup) per rotation or
365
+ adoption, bounded by the number of agents times their rotations. An operator
366
+ maintenance sweep for this inert data is follow-up work.
367
+ - **One operation deadline.** One wall-clock deadline is set when rotate,
368
+ revoke, or adopt starts: 2 seconds, or the request's own `deadline` option if
369
+ sooner. It bounds every storage await in the operation: the adoption claim
370
+ and its read-back, record and lookup writes before the commit, the commit's
371
+ own acknowledgement, and everything after it (reading back an unanswered
372
+ commit, revoking an undeliverable rotation, completing an adoption's lookup
373
+ step). Each storage call is raced against it and none starts after it. A
374
+ timeout before the commit reports `aborted` if the caller has left, since
375
+ nothing was published, and `outcome_unknown` otherwise; a pending claim
376
+ acknowledgement is always `outcome_unknown`, and the claim, if it landed,
377
+ leaves the token resumable by the same agent. An acknowledgement still
378
+ pending at the deadline is an unanswered commit with no time left to read it
379
+ back, and any success decided after the deadline is reported as
380
+ `outcome_unknown` instead. Records written before an uncertain commit stay.
381
+ An operation still in flight at the deadline is abandoned rather than
382
+ cancelled, which is safe: it can only be a read; a write of the call's own
383
+ record or lookup, inert until a slot names it; the claim, inert to admission;
384
+ the commit itself, whose outcome is then reported as unknown and whose slot
385
+ alone decides what admits; the revoke compare-and-set from `issued` naming
386
+ one record to `revoked`, which can only stop a token and fails if the slot
387
+ changed; or the adoption compare-and-set of a lookup from the legacy record to
388
+ the new one, which fails if anyone else moved it. If an abandoned revoke never lands, the
389
+ undelivered rotation secret may keep admitting, but it never left the server:
390
+ a liveness problem reported as `outcome_unknown`, never a credential exposure.
391
+
392
+ The routes pass the request's `AbortSignal` to `rotate` and `revoke`
393
+ (`{ signal, expect }`); `adopt` has no route and runs only from trusted
394
+ provisioning, which may pass its own `{ signal }`; a signal already aborted performs no storage call at all. Authorization caches no token, and D1 and SQLite are strongly
395
+ consistent; admitted requests are not recalled. Management writes require an
396
+ exact same-origin Origin; responses are private and non-cacheable.
397
+
398
+ **Upgrading.** Keep `accessTokens(storage)` on **the same storage and key
399
+ namespace**. The `access-token:v1:record:*` and `access-token:v1:lookup:*`
400
+ records stay readable, and slots are added beside them as
401
+ `access-token:v1:agent:<name>`. A stored token stops admitting until an agent
402
+ adopts it. Trusted provisioning runs `AccessTokenManager.adopt(tokenId,
403
+ agentName)`, which never changes the legacy record. It first sets a write-once
404
+ claim, `access-token:v1:adopted:<legacy id>`, from absent to the agent's name;
405
+ the claim is never deleted, so once any adoption has started the legacy token
406
+ is consumed for that agent forever, even after the agent's token is revoked or
407
+ rotated away, and a retired secret can never be adopted again. The claim is
408
+ inert to admission: an adoption aborted after it leaves the token adoptable
409
+ only by the same agent. It then writes a new record
410
+ carrying the agent and the legacy digest, checks the signal, commits by
411
+ compare-and-setting the agent's slot from none to `issued` with that record,
412
+ then moves the digest's lookup from the legacy record to the new one by
413
+ compare-and-set, from which the machine's existing `cta_` secret admits as the
414
+ agent. Adopt delivers no secret, so once committed it completes even if the
415
+ request ended. Running the same adoption again, when the slot already names a
416
+ record with this digest, completes the lookup step and succeeds; there is no
417
+ release step. If the lookup step cannot be confirmed the call says so and asks
418
+ for a rerun. Adoption refuses a human-bound, revoked, or already adopted token,
419
+ a token claimed by another agent, a token another agent's slot already carries,
420
+ and an agent whose slot names anything else. Of two agents racing for one
421
+ token, the claim admits one; the other is refused before its commit.
422
+ `unboundTokens()` lists the legacy records adopt accepts: unbound, unrevoked,
423
+ never claimed, still owning their digest's lookup, and carried by no declared
424
+ agent's slot. The Node template wraps both as
425
+ `npm run adopt-token`.
426
+ Human-bound tokens, which earlier UI issuance tied to the issuing operator,
427
+ cannot be adopted and stay refused; issue the agent a new token instead. Rows
428
+ recorded before the upgrade keep their token-record actor ids. Grant callbacks
429
+ that matched token record ids with `actor.id` match agent names through
430
+ `identity.agent` instead.
128
431
 
129
432
  ## Origins
130
433
 
131
434
  `allowedOrigins?: readonly string[] | "*"` bounds which browsers may speak to
132
- the MCP endpoint. A disallowed `Origin` on `/mcp` or any `/mcp/<pool>` gets a
435
+ the MCP endpoint. A disallowed `Origin` on `/mcp`, any `/mcp/<pool>`, or any
436
+ path in the agent namespace gets a
133
437
  fixed 403 before HTTPS redirects, admission, auth, and CORS preflight — the
134
438
  check cannot depend on anything a caller has yet proved. The default admits the
135
439
  `publicUrl` origin plus HTTP(S) loopback at any port; a supplied list replaces
@@ -143,7 +447,7 @@ silently never matching. A permitted origin still has to authenticate.
143
447
  One authorization yields three roles: the **actor** identifies the caller in
144
448
  activity, the **subject** owns transient results such as `connecta.result` pages,
145
449
  and the **principal** is the human owner of personal connector auth. An
146
- interactive Clerk or Access user supplies all three; Access service identities are refused; machines present `cta_` tokens. The principal is an explicit
450
+ interactive Clerk or Access user supplies all three; Access service identities are refused; machines are agents presenting `cta_` tokens, with an actor and a subject but no principal. The principal is an explicit
147
451
  `principal: { namespace, id }` from `authorize`, accepted whenever it
148
452
  validates, or else one derived from `userId` and the provider's
149
453
  `activityActorNamespace`, so only the derived path needs a namespace.
@@ -160,129 +464,151 @@ Its `subject` option, a deployment-config function from the admitted identity
160
464
  to a downstream account (a Clerk principal id to a Workspace address, say) or
161
465
  one fixed address, is applied once per call and only to a request an inbound
162
466
  provider authenticated: any `ok`, a subject-less custom credential included, never an open
163
- deployment's anonymous admission. Otherwise, or on `undefined`, the call fails
164
- `auth_required` before any request leaves. Arguments, headers, and programs
467
+ deployment's anonymous admission. An agent never reaches the function; it acts
468
+ as its pool's `owner` ([Pool owners](#pool-owners-for-google-workspace)).
469
+ Without an owner, a mapping-subject connector is absent for agents, and a call
470
+ by address fails `unknown_address`. The provider-level fallback is
471
+ `auth_required` when no inbound provider authenticated the caller or the
472
+ mapping returns `undefined`; it also rejects an ownerless agent context that
473
+ bypasses the scoped registry. These refusals happen before any request leaves. Arguments, headers, and programs
165
474
  never reach it; a deployment's own connectors still cannot read the caller.
166
475
 
167
- The deployment's top-level `grant` decides who may use plain `/mcp`; named
168
- pools use their own grants. These endpoint decisions receive the admitted
169
- identity below and never widen its connector access.
170
-
171
- `identity.connectorAccess` returns `"all"` (the default) or grants, for
172
- discovery and use alike: a connector id opens every tool on it, a
173
- `connector.tool` address that tool alone, and
174
- `{ tool: "connector.tool", requireReadOnly: true }` that tool only while the
175
- loaded catalog says it is explicitly read-only, never connector-wide. Grants
176
- are additive, so a bare id or unrestricted address beside a guarded one wins.
177
-
178
- Tool grants are enforced in the scoped registry view, below the catalog service,
179
- so `search_tools`, `describe_tools`, both call tools, a program's
180
- `connecta.search`, `connecta.describe`, and `connecta.call`, and the connection
181
- UI read one filtered list. Connector-level discovery, guides, and
182
- `authorize_connector` keep a connector when any of its tools is granted —
183
- `docs.read` permits the `docs` handoff. Without one it is absent: discovery
184
- omits it, and `authorize_connector` returns the same "Unknown connector" refusal
185
- as for a connector that does not exist. An ungranted tool fails exactly like one
476
+ Access is read from one place, deployment code. Top-level
477
+ `maintainers` lists the people who reach plain `/mcp`, which serves the whole
478
+ catalog with the deployment's `readOnly`, and the operator routes. Each pool's
479
+ `members` lists who reaches that pool, people at `/mcp/<pool>` and agents at
480
+ `/agent/<pool>`, which serve exactly that pool's `tools`. A maintainer opens no pool implicitly; list them as a member to use
481
+ one. Nothing intersects these lists with anything else, and a request never
482
+ names its own scope.
483
+
484
+ A member reference is one of:
485
+
486
+ - a bare principal id, `"user_2abc"`, resolved in the namespace of the
487
+ deployment's one interactive provider (its `activityActorNamespace`: Clerk's
488
+ Frontend API URL, or `"cloudflare-access"`);
489
+ - `{ namespace, id }`, required when a deployment has zero or several
490
+ interactive providers, or when the id contains `:`;
491
+ - `"agent:<name>"`, naming an agent declared in `agents` (pool `members` only).
492
+
493
+ A person matches when the admitted principal equals the reference exactly; an
494
+ agent matches by `identity.agent`, which only the built-in token adapter sets.
495
+ Construction throws on a bare id without exactly one interactive
496
+ provider, a bare id containing `:`, an undeclared agent, a repeated reference,
497
+ an empty or missing pool `members`, an agent in `maintainers`, `maintainers`
498
+ missing or empty when a provider that admits people is configured (any
499
+ provider other than the built-in agent-token adapter), and a non-empty
500
+ `maintainers` on a deployment with no such provider. With agents alone
501
+ (`accessTokens` as the only inbound auth), `maintainers` stays empty, nobody
502
+ can be a maintainer, and plain `/mcp` and every operator API admit nobody; the
503
+ agents reach only `/agent/<pool>` for the pools that list them. With no inbound auth at all, the
504
+ deployment is open (see [pools](#pools)). `describeConfig()` lists
505
+ `maintainers` as `{ namespace, id }` and each pool's resolved `members` and
506
+ trimmed `description` (`null` when unset).
507
+
508
+ Pool tools are enforced in the scoped registry view, below the catalog service,
509
+ so `search_tools`, both call tools, a program's
510
+ `connecta.search`, `connecta.describe`, and `connecta.call` read one filtered
511
+ list. Connector-level discovery, guides, and `authorize_connector` keep a
512
+ connector when any of its tools is in the pool — `docs.read` permits the
513
+ `docs` handoff. Without one it is absent: discovery omits it, and
514
+ `authorize_connector` returns the same "Unknown connector" refusal as for a
515
+ connector that does not exist. A tool outside the pool fails exactly like one
186
516
  the connector never had: `unknown_tool`, with no hint that it exists. That is
187
- the whole security claim, in one place on purpose. There is no _caller-selected_
188
- tool set: a narrower slice is a branch in this resolver or a pool, and a bot
189
- needing its own is its own stored token subject. A request may never name its own
190
- scope.
517
+ the whole security claim, in one place on purpose.
191
518
 
192
- ### Team read sets
519
+ `identity.connectorAccess`, including guarded
520
+ `{ tool, requireReadOnly: true }` grants, the top-level `grant`, and pool
521
+ `grant` are removed; each fails construction with a message naming its
522
+ replacement. A guarded read grant becomes a pool with `readOnly: true`.
193
523
 
194
- Keep team membership in deployment code and review exact addresses:
524
+ ### Team pools
525
+
526
+ Keep membership in deployment code; static lists can be built with ordinary
527
+ code at construction:
195
528
 
196
529
  ```ts
197
- const engineering = new Set(["engineer-id"]);
198
- const marketing = new Set(["marketer-id"]);
199
- const engineeringGrants = ["issues", { tool: "campaigns.list", requireReadOnly: true }] as const;
200
- const marketingGrants = ["campaigns", { tool: "issues.search", requireReadOnly: true }] as const;
530
+ const engineering = ["engineer-id"];
531
+ const marketing = ["marketer-id"];
201
532
  createConnecta({
202
533
  auth: cloudflareAccessAuth(),
203
- identity: {
204
- connectorAccess: ({ principal }) => {
205
- const id = principal?.id;
206
- if (!id) return [];
207
- return [...new Set([
208
- ...(engineering.has(id) ? engineeringGrants : []),
209
- ...(marketing.has(id) ? marketingGrants : []),
210
- ])];
211
- },
534
+ maintainers: ["owner-id"],
535
+ pools: {
536
+ engineering: { tools: ["issues", "campaigns.list"], members: engineering },
537
+ marketing: { tools: ["campaigns", "issues.search"], members: marketing },
538
+ reporting: { tools: ["issues", "campaigns"], readOnly: true, members: [...engineering, ...marketing] },
212
539
  },
213
540
  connectors: [issues, campaigns, billing], executor,
214
541
  readOnly: false, // required: true refuses every write call
215
542
  });
216
543
  ```
217
544
 
218
- Engineering sees all `issues` tools and only `campaigns.list`; Marketing sees all
219
- `campaigns` tools and only `issues.search`. Neither sees `billing`; both memberships
220
- grant both connectors in full. Unknown or unprincipalled identities get `[]`.
221
- Use a stable authenticated principal or subject, never request headers or tool
222
- arguments. A token's stored principal is vouched for by inbound auth. Pools narrow their endpoint, not plain `/mcp`.
223
-
224
- | Catalog change | Guarded exact grant | Unrestricted exact string grant |
225
- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
226
- | New name | Excluded until reviewed and added | Excluded until reviewed and added |
227
- | Removed or renamed name | Unreachable; warned when the scoped view reads the catalog | Same |
228
- | Stored write verdict after overrides and provider review | Removed from discovery and every invocation path, including `call_destructive_tool` and programs on a writable endpoint | Still granted; `call_tool` refuses writes and write paths take over |
229
- | Schema change alone | Does not revoke, unless it breaks a reviewed digest: then it is a write, as above | Same as above |
230
-
231
- The example assumes both exact tools were reviewed as read-only; names imply no safety.
232
- Review schemas, annotations, and downstream behavior
545
+ Engineering's endpoint serves every `issues` tool and only `campaigns.list`;
546
+ Marketing's every `campaigns` tool and only `issues.search`. Neither pool
547
+ serves `billing`; only the owner's plain `/mcp` does. A person in both pools
548
+ uses two endpoints, never a union.
549
+
550
+ A team pool can also be one of an agent's tiers: the same pool lists the
551
+ cleared people and the agent, and each reaches it at their own address
552
+ ([people in a tier](#organizing-an-agents-pools)).
553
+
554
+ | Catalog change | Exact `connector.tool` in a pool |
555
+ | ----------------------- | ----------------------------------------------------------------------- |
556
+ | New name | Excluded until reviewed and added |
557
+ | Removed or renamed name | Unreachable; warned when the scoped view reads the catalog |
558
+ | Stored write verdict | Still in the pool; `call_tool` refuses writes and write paths take over |
559
+ | Schema change alone | Does not revoke, unless it breaks a reviewed digest: then it is a write |
560
+
561
+ Names imply no safety. Review schemas, annotations, and downstream behavior
233
562
  before changing lists or upgrading providers. Remote catalogs can drift without
234
- upgrades; fresh cached facts are classified against the current review on every read; downstreams
235
- can change behavior while keeping read-only annotations. For `api()`, the author
236
- owns declarations and handlers. Connecta enforces loaded declarations, not
237
- absence of side effects. Restricted downstream credentials do not replace grants.
563
+ upgrades; fresh cached facts are classified against the current review on
564
+ every read; downstreams can change behavior while keeping read-only
565
+ annotations. For `api()`, the author owns declarations and handlers. Connecta
566
+ enforces loaded declarations, not absence of side effects. Restricted
567
+ downstream credentials do not replace pools; a `readOnly` pool refuses every
568
+ write whatever the catalog says.
238
569
 
239
- `[]` hides all connectors from discovery and the connection UI. Address-only or
240
- disqualified guarded grants can leave a connector visible with its tool unreachable.
241
570
  Failed remote loads are errors, not empty catalogs. A transient first-page tool
242
571
  listing failure may serve complete facts in the same auth partition for five
243
572
  minutes after expiry. Failed pagination, permission and auth failures do not
244
- use this fallback. The expired deadline cannot authorize auth recovery. Personal OAuth ownership and credential
245
- administration are separate from visibility; writes follow the endpoint's `readOnly` setting and client approval. `test/identity-scope.test.ts` exercises these boundaries.
573
+ use this fallback. The expired deadline cannot authorize auth recovery.
574
+ Personal OAuth ownership and credential administration are separate from
575
+ endpoint access; writes follow the endpoint's `readOnly` setting and client
576
+ approval.
246
577
 
247
578
  ## Migrating static bearer clients
248
579
 
249
580
  The `/auth/bearer` export, `bearerToken`, and its `assertedPrincipal` header
250
581
  mode are removed. Existing static secrets cannot be converted into `cta_`
251
- secrets. Use a separate stored token for each machine or human owner:
582
+ secrets. Declare each machine as an agent instead:
252
583
 
253
584
  1. Configure the existing D1 or SQLite store as `storage`, and install
254
585
  `accessTokens(storage)`. Keep its existing keys and namespaces.
255
- 2. Sign in through Clerk or Worker Access. Grant the intended token manager
256
- `identity.accessTokenManagement`, then create a token on Access tokens.
257
- It is bound to that human's principal. For a machine without a human owner,
258
- provision `new AccessTokenManager(storage).create("machine-name", "deployment-provisioning")` from
259
- trusted deployment code. The Node template provides `npm run provision-token -- machine-name`.
260
- 3. Update `identity.connectorAccess` and pool grants to the returned token
261
- metadata id with `actor.kind === "access_token"`. Names and `tokenPrefix`
262
- are display labels. Keep existing human principal namespaces unchanged.
263
- 4. Save the once-returned secret in the client's secret store and send
264
- `Authorization: Bearer cta_…`. Behind Worker Access, also satisfy the edge
265
- with Access service credentials; an Access-only machine no longer admits.
586
+ 2. Add the machine's name to `agents`. Issue its token from the Tokens
587
+ page as a signed-in maintainer, or from trusted
588
+ deployment code with
589
+ `new AccessTokenManager(storage, { agents }).rotate("machine-name", "deployment-provisioning")`.
590
+ The Node template provides `npm run --silent rotate-token -- machine-name`.
591
+ 3. List `"agent:machine-name"` in the `members` of each pool it uses. Agents
592
+ are never maintainers, so a machine that used plain `/mcp` moves to a pool
593
+ holding its tools.
594
+ 4. Save the once-returned secret in the client's secret store and point the
595
+ client at `/agent/<pool>` with `Authorization: Bearer cta_…`. Behind Worker
596
+ Access, add the [agent bypass application](./deploying.md#agent-endpoints-behind-cloudflare-access)
597
+ so that token is the only credential; an Access-only machine no longer admits.
266
598
  5. Verify MCP initialization and the intended grants. Confirm the old static
267
599
  secret refuses, then remove its environment secret and the old import.
268
- Remove `X-Connecta-Principal`; a token's stored principal cannot vary by
269
- request. Provision one human-bound token per represented user instead.
270
- To retain an old asserted user's personal state, trusted provisioning must
271
- call `manager.create(name, { namespace: existingNamespace, id: existingId })`
272
- with the exact existing namespace and canonical id, after applying the old
273
- admission policy. A Clerk UI token uses the Clerk principal instead and
274
- cannot inherit a different asserted namespace's partitions.
275
-
276
- Revocation acts on the next request. Rotate by creating a replacement,
277
- updating the client, verifying it, and revoking the old token. Keep token text
278
- out of logs and reports.
600
+ Remove `X-Connecta-Principal`. An agent has no principal, so a client that
601
+ acted for a human with personal connectors has no token replacement; that
602
+ human signs in instead.
603
+
604
+ Rotation and revocation act on the next request. Keep token text out of logs
605
+ and reports.
279
606
 
280
607
  ## Pools
281
608
 
282
- A pool is a named slice of the deployment served at its own endpoint,
283
- `/mcp/<pool>`, for when one identity needs different capability sets on different
284
- clients: a support agent that sees three Notion tools and Linear, a calendar bot
285
- that sees one tool, both over the same credentials. Private remote catalog
609
+ A pool is a named slice of the deployment served to its members: people at
610
+ `/mcp/<pool>` and agents at `/agent/<pool>`. A support team sees three Notion
611
+ tools and Linear, a calendar bot sees one tool, both over the same credentials. Private remote catalog
286
612
  cache entries remain partitioned by the admitted pool.
287
613
 
288
614
  ```ts
@@ -291,56 +617,97 @@ createConnecta({
291
617
  accessTokens: accessTokens(storage),
292
618
  auth: clerkAuth({ publishableKey, secretKey }),
293
619
  readOnly: false, // required; pools without their own readOnly inherit it
294
- grant: ({ actor }) => actor.kind !== "access_token", // machines use their pools
620
+ maintainers: ["user_owner"],
621
+ agents: ["calendar-bot"],
295
622
  pools: {
296
623
  support: {
297
624
  tools: ["linear", "notion.search_pages", "notion.fetch_page"],
298
- grant: ({ principal }) => supportTeam.has(principal?.id ?? ""),
299
- },
300
- calendar_bot: {
301
- tools: ["calendar.create_event"],
302
- grant: ({ actor }) => actor.kind === "access_token" && actor.id === calendarTokenId,
303
- },
304
- reporting: {
305
- tools: ["linear", "notion"],
306
- readOnly: true,
307
- grant: ({ principal }) => analysts.has(principal?.id ?? ""),
625
+ members: supportTeam,
626
+ description: "Support tools: Linear and three Notion reads. Ask a maintainer for anything else.",
308
627
  },
628
+ calendar_bot: { tools: ["calendar.create_event"], members: ["agent:calendar-bot"] },
629
+ reporting: { tools: ["linear", "notion"], readOnly: true, members: analysts },
309
630
  },
310
- identity: { connectorAccess },
311
631
  connectors, executor,
312
632
  });
313
633
  ```
314
634
 
315
635
  Without configured `auth`, an open deployment has one anonymous, non-interactive
316
- identity; grants like these return false and every pool path 404s.
317
-
318
- The optional top-level `grant: (identity) => boolean | Promise<boolean>`
319
- controls plain `/mcp`. Omitted, it admits everyone who passes auth, including
320
- anonymous callers on an open deployment. Configured, only literal `true`
321
- admits; other returns and throws deny. A refusal has the same 404 status,
322
- body bytes, and headers as a refused or undeclared pool, after auth. The
323
- operator log records `default_grant_denied` or `default_grant_threw`, never
324
- the callback's error text. Invalid config values throw at construction.
325
- Named pools use only their own grants, so a caller refused at `/mcp` may
326
- still use a pool that grants it.
636
+ identity. It keeps plain `/mcp` with the whole catalog, as the construction
637
+ warning says, every pool path answers the 404 because nobody can be a member,
638
+ and every agent endpoint answers the machine-only 401 because no agent can be
639
+ admitted.
640
+
641
+ | Rule | Contract |
642
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
643
+ | Scope | `/mcp/<pool>` and `/agent/<pool>` serve exactly the pool's `tools` (connector ids or exact `connector.tool` addresses), with the pool's `readOnly`, to its member people and member agents respectively. Plain `/mcp` serves the whole catalog to maintainers. Neither intersects with or widens anything. |
644
+ | Refusal | A non-maintainer at `/mcp`, a non-member, and an undeclared pool get an identical 404 status, body, and headers, after auth. The operator log records `not_maintainer`, `not_pool_member`, or `pool_not_declared`. Names are not anonymously enumerable; valid credentials cannot distinguish these cases by content. |
645
+ | Agents | An agent member reaches its pools only at `/agent/<pool>`, with its `cta_` token. Human endpoints refuse `cta_`; see [agent endpoints](#agent-endpoints). |
646
+ | Description | Optional `description`: plain text telling the model what this endpoint is for. `/mcp/<pool>` and `/agent/<pool>` append it to the MCP `initialize` instructions after `This endpoint: `; plain `/mcp` and a pool without one serve the shared instructions unchanged. It grants, hides, and refuses nothing, and changes no tool or tool description. |
647
+ | Construction | Malformed names, unknown options or connectors, empty pools, empty or missing `members`, unresolvable member references, a non-boolean `readOnly`, an `owner` that is not a Workspace email address, a `description` that is not 1 to 500 characters of one-line text after trimming, a retired `trust` or `grant`, and addresses absent from an `api()` static catalog throw. Remote catalogs are checked at lazy load. |
648
+ | 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. |
649
+ | Drift | No wildcards; pool tools are exact names. Missing live tools are unreachable and warned once while in a bounded 1,024-entry FIFO. |
650
+
651
+ Pool descriptions are trimmed before validation. The remaining text must be
652
+ 1 to 500 characters and contain no C0 or C1 control characters (including tabs,
653
+ newlines, and NEL U+0085), LINE SEPARATOR (U+2028), or PARAGRAPH SEPARATOR
654
+ (U+2029).
655
+
656
+ ### Agent endpoints
657
+
658
+ `/agent/<pool>` is where agents use a pool. It exists for deployments behind
659
+ Cloudflare Access, which [bypasses `/agent/*`](./deploying.md#agent-endpoints-behind-cloudflare-access)
660
+ at the edge so an agent sends one credential, and it behaves the same on Node.
661
+
662
+ - **Path.** Only a raw request pathname byte-equal to `/agent/` plus a
663
+ well-formed pool name (`[a-z0-9_-]+`) is an agent endpoint. Connecta routes
664
+ the agent namespace before every other route: any path that starts with
665
+ `/agent` once escapes are decoded, case is folded, and slashes are
666
+ collapsed (`/agent`, `/agent/`, `/agent/x/`, `/agent/x/y`, `/AGENT/x`,
667
+ `/%61gent/x`, `//agent/x`, `/agent/%2f..`, `/agentx`) answers the pool 404
668
+ before any admission or auth, so a path Access bypasses never reaches a
669
+ human route. The query string is not part of the match.
670
+ - **Auth.** Only the built-in `cta_` adapter is consulted. Clerk and Access
671
+ adapters never run; `ctx.access`, which Access attaches even to a bypassed
672
+ path, is never read or passed on; cookies, Clerk handshake parameters,
673
+ `Cf-Access-Jwt-Assertion`, and Access service headers are ignored. A
674
+ missing, malformed, unknown, revoked, or non-`cta_` credential gets 401 with
675
+ `WWW-Authenticate: Bearer` and no `resource_metadata`.
676
+ - **Membership.** An admitted agent must be listed in that pool's `members`.
677
+ A non-member agent, a pool that lists no agents, and an undeclared but
678
+ well-formed pool name get the identical pool 404 after auth, logged as
679
+ `not_pool_member` or `pool_not_declared`. Unauthenticated callers get the
680
+ same 401 for declared and undeclared names, so pool names are not
681
+ anonymously enumerable.
682
+ - **Discovery.** Agent endpoints publish no protected-resource metadata:
683
+ `/.well-known/oauth-protected-resource/agent/<pool>` is the ordinary 404,
684
+ declared or not. Agents hold a provisioned token and never run OAuth
685
+ discovery, so there is no authorization server to name and no resource URL
686
+ worth advertising.
687
+ - **Handles.** Result handles are bound to `/agent/<pool>`, so one minted
688
+ there pages nowhere else, including `/mcp/<pool>` and another agent pool.
327
689
 
328
690
  Protected-resource metadata stays public at both default aliases,
329
691
  `/.well-known/oauth-protected-resource` and
330
692
  `/.well-known/oauth-protected-resource/mcp`, just as at named-pool aliases.
331
- Discovery does not authenticate callers or evaluate endpoint grants, so fresh
332
- OAuth clients can discover how to authenticate. The grant applies after auth
333
- to MCP requests and result/page handles. Handles remain bound
334
- to their originating endpoint; paging rechecks the current endpoint grant,
335
- identity, and connector access. A granting pool cannot read a `/mcp` handle,
336
- and `/mcp` cannot read that pool's handle.
337
-
338
- `describeConfig().hasGrant` and the operator UI report whether plain `/mcp`
339
- has a configured grant without exposing callback source. `describeConfig()`
340
- does not evaluate it. Operator catalog, config, activity, and connector auth
341
- routes require the same default endpoint grant after their own authentication.
342
- A refused caller gets a private JSON 404; the UI provides no per-pool view.
343
- The public HTML shell carries no catalog data.
693
+ Discovery does not authenticate callers or evaluate membership, so fresh
694
+ OAuth clients can discover how to authenticate. Admission applies after auth
695
+ to MCP requests and result/page handles. Handles remain bound to their
696
+ originating endpoint; paging re-admits the caller at that endpoint. A pool
697
+ cannot read a `/mcp` handle, and `/mcp` cannot read a pool's handle.
698
+
699
+ Operator catalog (`/ui/data`, `/ui/connectors/<id>`), activity, shared
700
+ connector auth, and access-token routes require a maintainer after their own
701
+ authentication. Anyone else gets a private JSON 404 `{ "error": "not found" }`.
702
+ `/ui/api/config` serves a maintainer the whole operator contract and a person
703
+ listed in a pool's `members` only their own view: their pools without owner or
704
+ members, and the connectors in them with the tools those pools grant, their
705
+ own connection on personal connectors, and what they may do about it. It
706
+ carries no shared credential state, agents, other members, owners, or
707
+ activity. Anyone else gets the same 404, and an agent token the machine-only
708
+ 401 every human route gives it; see the
709
+ [operator contract](./operator-ui.md#operator-data-contract). The public HTML
710
+ shell carries no catalog data.
344
711
 
345
712
  Each endpoint is writable or read-only. The deployment's `readOnly` sets the
346
713
  plain `/mcp` endpoint and is required: a deployment that omits it fails at
@@ -355,19 +722,267 @@ through `call_destructive_tool`, still run.
355
722
  Guides are documentation, not discovery: provider `SKILL.md` and usage guides,
356
723
  surfaced through `queryAnalysis.guideSummary`, `skills`, `connecta.skill`, and
357
724
  skill `resources/read`, are the same on every endpoint and may name tools a
358
- read-only endpoint refuses.
359
- Resource reads, grants, and `connectorAccess`, including guarded
360
- `requireReadOnly` grants, are the same on both. The removed `trust` option fails
361
- construction with a message naming `readOnly`.
362
-
363
- | Rule | Contract |
364
- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
365
- | Scope | `/mcp/<pool>` intersects pool tools with the identity's `connectorAccess`, never widening it. Plain `/mcp` serves the identity's `connectorAccess` after its optional top-level `grant` admits it. |
366
- | Grant | Pool grants default 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. |
367
- | 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. |
368
- | Construction | Malformed names, unknown options or connectors, empty pools, a non-boolean `readOnly`, a retired `trust`, and addresses absent from an `api()` static catalog throw. Remote catalogs are checked at lazy load. |
369
- | 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. |
370
- | Drift | No wildcards; tool grants are exact names. Missing live tools are unreachable and warned once while in a bounded 1,024-entry FIFO. |
725
+ read-only endpoint refuses. Resource reads and pool tools are the same on
726
+ both. The removed `trust` option fails construction with a message naming
727
+ `readOnly`.
728
+
729
+ ### Pool owners for Google Workspace
730
+
731
+ An agent has no Workspace account. On a Workspace connector (`gmail()`,
732
+ `drive()`, `sheets()`, `slides()`, `forms()`, `docs()`) whose `subject` is a
733
+ mapping function, the caller decides whose account a call opens:
734
+
735
+ | Caller | Acts as |
736
+ | ----------------------------- | ------------------------------------------------------------------------------- |
737
+ | A person, in any pool | Their own account, through the `subject` mapping. A pool `owner` never applies. |
738
+ | An agent in a pool | The pool's `owner`, resolved before the mapping, which never sees the agent. |
739
+ | An agent, pool has no `owner` | Nothing. The connector is absent for the agent, as a personal connector is. |
740
+
741
+ In a pool without an `owner`, a mapping-subject Workspace connector is absent
742
+ for agents, just as an `authScope: "personal"` connector is: `search_tools`,
743
+ `connecta.search`, `connecta.describe`, `skills`, and connector discovery omit
744
+ it, `authorize_connector` answers "Unknown connector", and a call by address
745
+ fails `unknown_address`, the same as for a connector that does not exist.
746
+ People in the same pool still see it and act as themselves. This is what lets
747
+ one pool be a team pool for people, each acting as themselves on Workspace, and
748
+ also list an agent that reaches only the pool's shared connectors.
749
+
750
+ `owner` is a Workspace email address, checked at construction and listed in
751
+ `describeConfig()` (`null` when unset). Over HTTP, `/ui/api/config` serves it
752
+ only to an authenticated maintainer; anyone else, a pool member and an open
753
+ deployment's anonymous caller included, reads `null`, the same as an unset
754
+ owner. Core copies it from the pool the
755
+ request was admitted through; no argument, header, or program can choose or
756
+ change it, and the `auth_required` message never names an address. One
757
+ `gmail()` then serves a pool per person:
758
+
759
+ ```ts
760
+ const team = [
761
+ { handle: "alice", id: "user_alice", email: "alice@example.com" },
762
+ { handle: "bob", id: "user_bob", email: "bob@example.com" },
763
+ ];
764
+ createConnecta({
765
+ auth: clerkAuth({ publishableKey, secretKey }),
766
+ storage,
767
+ accessTokens: accessTokens(storage),
768
+ readOnly: false,
769
+ maintainers: ["user_alice"],
770
+ agents: ["assistant"],
771
+ pools: Object.fromEntries(
772
+ team.map((person) => [
773
+ `assistant-${person.handle}`,
774
+ { tools: ["gmail", "drive"], owner: person.email, members: [person.id, "agent:assistant"] },
775
+ ]),
776
+ ),
777
+ connectors: [
778
+ gmail("gmail", { purpose, serviceAccount, subject: (who) => emailOf(who.principal?.id) }),
779
+ drive("drive", { purpose, serviceAccount, subject: (who) => emailOf(who.principal?.id) }),
780
+ ],
781
+ executor,
782
+ });
783
+ ```
784
+
785
+ The agent harness, not the model, picks the pool for each turn, which is what
786
+ keeps one person's mailbox from another's. Adding a person to someone else's
787
+ pool opens nothing of the owner's Workspace account to them, since people act
788
+ as themselves through the mapping. That holds only for Workspace mappings:
789
+ every other connector in the pool, including a shared-auth connector holding
790
+ the owner's own account, runs the same for every member
791
+ ([members of a personal pool](#organizing-an-agents-pools)).
792
+
793
+ A fixed `subject` address is the explicit shared-account exception: every
794
+ caller, person or agent, acts as it in every pool that lists the connector,
795
+ and pool owners do not apply. Keep fixed-subject connectors out of pools whose
796
+ members must not share that account.
797
+
798
+ ### Organizing an agent's pools
799
+
800
+ Use this layout for every agent's pools. It gives a chat no more than
801
+ everyone in it is cleared for, and gives each person's own accounts to the
802
+ agent only when that person is the one talking, all over one connection. This
803
+ section holds the complete rules.
804
+
805
+ **The pools**
806
+
807
+ 1. **One connection per agent.** Each agent has exactly one Connecta
808
+ connection, and its client (the harness, never the model) chooses the
809
+ pool's URL for each turn. Never mount a second Connecta connection beside
810
+ it: the model would get two sets of meta-tools, and `execute_code` cannot
811
+ join results across connections. The agent's one `cta_` token works on
812
+ every pool that lists it.
813
+ 2. **Tiers.** An agent's tiers are named pools ordered from least to most
814
+ capable. Recommended names are `<agent>` for the lowest tier and
815
+ `<agent>-<tier>` for higher tiers (`assistant-team`, `assistant-leads`).
816
+ A tier may be any named pool, including an existing team pool such as
817
+ `team` or `leadership`, as long as its human members are all cleared for
818
+ it (rule 10). An agent with one tier uses it as the lowest. Tiers never
819
+ hold per-user connectors and never set an `owner`.
820
+ 3. **Personal pool `<agent>-<user>`.** `/agent/<agent>-<user>` serves pool
821
+ `<agent>-<user>`: the highest tier that user is cleared for, plus that
822
+ user's own connectors. Use it only in a one-on-one with that user.
823
+ `<user>` is a short, stable handle you choose (`alice`), never a provider
824
+ id, principal id, or address. When using the recommended `<agent>-<tier>`
825
+ names, no user handle may equal a tier suffix: their pool names would
826
+ collide. With any naming scheme, keep personal pool names distinct from
827
+ tier pool names. Pool names match `[a-z0-9_-]+`.
828
+ 4. **Nest by spreading tool lists.** Define each tier's `tools` once, as a
829
+ constant that spreads the tier below it, and spread a user's highest tier
830
+ into their personal pool's `tools`. A change to a tier then reaches every
831
+ pool above it. Spread tool lists, not whole pools: `members`, `readOnly`,
832
+ and `owner` belong to each pool, and a spread pool would copy them.
833
+ 5. **`owner` is the user's Workspace address.** Set `owner` on
834
+ `<agent>-<user>` to that user's Google Workspace email address; it is
835
+ checked as an address at construction, and it makes Workspace connectors
836
+ act as that user for the agent ([pool owners](#pool-owners-for-google-workspace)).
837
+ Tiers have no owner, so a Workspace connector with a mapping `subject`
838
+ is absent for the agent there.
839
+ 6. **A user's own account is its own connector id.** Agents have no
840
+ principal, so `authScope: "personal"` connectors never appear for them.
841
+ Give each user's account its own connector id with shared auth, which a
842
+ maintainer connects once (`notes_alice`, `notes_bob`), and list it only in
843
+ that user's pool. Google Workspace is the exception: one connector whose
844
+ `subject` is a mapping function, with each personal pool's `owner`
845
+ choosing the account.
846
+ 7. **Where each connector goes.** A shared connector (the deployment's
847
+ account, including a fixed-subject Workspace connector) goes in the lowest
848
+ tier whose entire audience is authorized to use the connector, and so
849
+ reaches every tier above. For example, billing restricted to leads goes
850
+ in the leads tier. Per-user connectors (`notes_alice`) go only in personal
851
+ pools, and so do the Workspace connectors with a mapping `subject` that
852
+ the agent uses; rule 10 covers listing them in a tier for its people.
853
+ 8. **Changes stay in config.** Adding an account for a user adds its
854
+ connector and lists it in that user's pool `tools`. Adding a user adds one
855
+ `<agent>-<user>` pool. Adding a tier adds one pool and one constant, and
856
+ the tier's place in the harness's tier list.
857
+ 9. **No other person in a personal pool.** The only human member an
858
+ `<agent>-<user>` pool may have is that same user, and usually it has none.
859
+ Never add another person: its per-user connectors (`notes_alice`) use that
860
+ user's account for everyone the pool admits, so another member would read
861
+ and write the user's account as their own. A person's own use normally
862
+ goes through their own pools or plain `/mcp`.
863
+ 10. **People in a tier are cleared for it.** A tier can also be a human team
864
+ pool: list the people cleared for that tier in its `members` beside the
865
+ agent. They use it at `/mcp/<pool>`, the agent at `/agent/<pool>`.
866
+ List no one else: a member reaches
867
+ every shared connector the tier holds, on the deployment's account. Such a
868
+ pool may also list Workspace mapping connectors or `authScope: "personal"`
869
+ connectors for its people, who act as themselves on them; those connectors
870
+ are absent for the agent, since a tier has no owner and an agent has no
871
+ principal.
872
+
873
+ **Choosing the pool each turn**
874
+
875
+ The harness decides which pool a turn uses, from its own verified view of
876
+ the chat: the platform's sender and participant ids, never names or claims in
877
+ message text, and never the model. Connecta checks only that the agent is a
878
+ member of the pool it opens. The agent's one token opens every pool that
879
+ lists it, so clearance is enforced nowhere but the harness.
880
+
881
+ - In a one-on-one with a user who has a personal pool, use that pool.
882
+ - Otherwise, use the most capable tier that everyone present is cleared for.
883
+ Everyone present means every participant who can read the reply, people and
884
+ other bots, not only the sender.
885
+ - If anyone present is unknown to the harness, or it cannot verify who is
886
+ present, use the lowest tier.
887
+ - A scheduled run uses the tier of the audience that will read its output, by
888
+ the same rule; with no audience, the lowest tier.
889
+ - Choose again on every turn. Someone joining a chat lowers its tier from the
890
+ next turn.
891
+
892
+ **The lowest tier.** The first pool in the harness's tier list is the
893
+ fallback (recommended name: `<agent>`), so make it safe in front of anyone
894
+ who can reach the agent. Where unknown people can, make it a guest tier:
895
+ `readOnly: true`, with exact `connector.tool` addresses for the reads guests
896
+ may use. A new downstream tool then never appears there, and no write runs.
897
+ The tier above spreads the guest list and adds whole connectors.
898
+
899
+ ```ts
900
+ import type { GoogleWorkspaceOptions } from "@zackbart/connecta/providers/gmail";
901
+
902
+ const agent = "agent:assistant";
903
+ // Tiers, lowest first. Each tier's tools spread the tier below.
904
+ const guestTools = ["crm.search_contacts", "tickets.list_tickets"];
905
+ const teamTools = [...guestTools, "crm", "tickets"];
906
+ const leadsTools = [...teamTools, "billing"];
907
+
908
+ const emailOf = (id?: string) => (id === "user_alice" ? "alice@example.com" : undefined);
909
+ const workspace: GoogleWorkspaceOptions = {
910
+ purpose: "Mail and files of the person in a one-on-one",
911
+ serviceAccount,
912
+ subject: (who) => emailOf(who.principal?.id),
913
+ };
914
+
915
+ createConnecta({
916
+ auth: clerkAuth({ publishableKey, secretKey }),
917
+ storage,
918
+ accessTokens: accessTokens(storage),
919
+ readOnly: false,
920
+ maintainers: ["user_alice"],
921
+ agents: ["assistant"],
922
+ pools: {
923
+ // Lowest tier and fallback: reads guests may use.
924
+ assistant: { tools: guestTools, readOnly: true, members: [agent] },
925
+ // Also the team's own pool at /mcp/assistant-team.
926
+ "assistant-team": { tools: teamTools, members: [agent, "user_alice", "user_bob"] },
927
+ "assistant-leads": { tools: leadsTools, members: [agent] },
928
+ // Each personal pool spreads its user's highest tier.
929
+ "assistant-alice": {
930
+ tools: [...leadsTools, "notes_alice", "gmail", "drive"],
931
+ owner: "alice@example.com",
932
+ members: [agent],
933
+ },
934
+ "assistant-bob": {
935
+ tools: [...teamTools, "notes_bob", "gmail"],
936
+ owner: "bob@example.com",
937
+ members: [agent],
938
+ },
939
+ },
940
+ connectors: [
941
+ crm,
942
+ tickets,
943
+ billing,
944
+ // Shared auth: a maintainer connects each user's own account once.
945
+ remoteMcp("notes_alice", { url: "https://notes.example.com/mcp", auth: { type: "oauth" } }),
946
+ remoteMcp("notes_bob", { url: "https://notes.example.com/mcp", auth: { type: "oauth" } }),
947
+ gmail("gmail", workspace),
948
+ drive("drive", workspace),
949
+ ],
950
+ executor,
951
+ });
952
+ ```
953
+
954
+ Alice is cleared for leads and Bob for team. The agent's client picks the
955
+ pool from who is present, for example:
956
+
957
+ ```ts
958
+ // Lowest first. A person's tier is an index into this list.
959
+ const tiers = ["assistant", "assistant-team", "assistant-leads"];
960
+ const people: Record<string, { tier: number; handle?: string }> = {
961
+ "<alice's sender id>": { tier: 2, handle: "alice" },
962
+ "<bob's sender id>": { tier: 1, handle: "bob" },
963
+ };
964
+
965
+ // `verifiedParticipants`: everyone who can read the reply, other than the
966
+ // agent, from the platform's ids; undefined when the harness cannot verify it.
967
+ function poolFor(turn: { isDirect: boolean; verifiedParticipants?: string[] }) {
968
+ const present = turn.verifiedParticipants;
969
+ if (!present?.length) return tiers[0];
970
+ const known = present.map((id) => people[id]);
971
+ if (turn.isDirect && present.length === 1 && known[0]?.handle) {
972
+ return `assistant-${known[0].handle}`;
973
+ }
974
+ return tiers[Math.min(...known.map((person) => person?.tier ?? 0))];
975
+ }
976
+ const url = `https://connecta.example.com/agent/${poolFor(turn)}`;
977
+ ```
978
+
979
+ A chat with Alice and Bob gets `assistant-team`; add an unknown guest and it
980
+ gets `assistant`, even if the platform marks the chat as direct. Rule 9 is
981
+ why each personal pool lists only the agent: people act as themselves only
982
+ through a Workspace mapping, and a shared-auth connector such as
983
+ `notes_alice` acts as Alice for whoever the pool admits.
984
+ Rule 10 is why `assistant-team` may list Alice and Bob: it holds only
985
+ connectors that every member may use on the deployment's account.
371
986
 
372
987
  ## Shared and personal auth
373
988
 
@@ -403,7 +1018,7 @@ namespace: the live **epoch**, the latest consent's state digest, and one
403
1018
  authorization server's client registration, tokens, and discovery. Each
404
1019
  consent has a **flow record**, `oauth:flow:<sha256(state)>`, holding its
405
1020
  epoch, consent URL, and PKCE verifier for fifteen minutes; the state itself is
406
- stored nowhere. Keys come from `src/storage/keys.ts`.
1021
+ stored nowhere.
407
1022
 
408
1023
  With a vault, the grant's body and each verifier are sealed with its AES-GCM
409
1024
  key, the additional authenticated data naming the connector, owner partition,
@@ -442,12 +1057,12 @@ namespace is left to clean up. They then delete each consent whose epoch is no
442
1057
  longer live; one left behind cannot complete and expires with its link.
443
1058
  `POST /ui/oauth/<id>` takes a `mode`:
444
1059
 
445
- | Request | What it does |
446
- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
447
- | `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. |
448
- | `?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. |
449
- | `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. |
450
- | `DELETE /ui/oauth/<id>` | Disconnects and invalidates the cached catalog, even if the browser leaves. |
1060
+ | Request | What it does |
1061
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1062
+ | `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. |
1063
+ | `?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. |
1064
+ | `GET /connect/<id>?h=...` | Verifies the signed handoff and, when signed in, browser identity, connector visibility and management permission. With the UI mounted and a maintainer signed in, 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, or for a pool member, the original browser visit starts consent. Explicit `POST /ui/oauth/<id>` actions return a signed link with `start=1` when the UI is mounted. |
1065
+ | `DELETE /ui/oauth/<id>` | Disconnects and invalidates the cached catalog, even if the browser leaves. |
451
1066
 
452
1067
  `authorize_connector` issues the same browser link, with `force` carried in the
453
1068
  signed handoff. On hosts without URL elicitation, direct and program auth
@@ -819,30 +1434,57 @@ anything, so SDK hook order cannot mix servers or cross a reset.
819
1434
 
820
1435
  ## Management permissions
821
1436
 
822
- Visibility grants no authentication-management permission. Two resolvers take
823
- `Readonly<AuthenticatedIdentity>` and return `"all"`, `"none"` (the default),
824
- or connector ids: `credentialAdministration` for shared credentials and OAuth
825
- grants, `personalConnection` for a human's own grants on personal connectors.
826
- Each action needs visibility _and_ the permission; both run only for an
827
- interactive identity, and personal actions also need a stable namespaced
828
- principal, whose partition they always use. Exceptions and unknown ids fail
829
- closed; permissions come from authenticated identity, never caller input.
830
-
831
- `identity.activityAccess` takes `Readonly<IdentityReference>` — `id` and
832
- `namespace` — and controls reading activity. When configured, an authenticated
833
- principal may pass it without an interactive session. This includes a `cta_`
834
- token's stored principal and a non-interactive API principal; subject-only
835
- callers and anonymous requests cannot qualify. The optional activity `readGate`
836
- also applies. Reads remain GET-only and filter connector/tool grants, personal
837
- ownership and recorded pool access before any actor-label lookup. Personal rows
838
- use the admitted principal in the existing typed actor id and namespace fields;
839
- rows without a provable owner are withheld. Non-interactive
840
- reads return typed actors without directory labels. Old rows without a pool
841
- remain subject to connector, tool and owner checks. Undeclared, it admits every
842
- interactive human, the one default here that is open, because a single-operator
843
- deployment would otherwise be locked out of its own event stream. Team
844
- deployments should set it. There is no general administrator role and no
845
- 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.
1437
+ Management follows `maintainers` and pool `members`; no resolver configures it. The former `identity.credentialAdministration`,
1438
+ `identity.personalConnection`, `identity.accessTokenManagement`, and
1439
+ `identity.activityAccess` each fail construction naming their replacement,
1440
+ and so does the `identity` section itself, even empty.
1441
+
1442
+ | Action | Who may |
1443
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
1444
+ | Shared credentials and OAuth grants | An interactive maintainer |
1445
+ | A personal connector's own account | An interactive person with a stable principal who is a maintainer, or a member of a pool containing that connector; always their own partition |
1446
+ | Agent tokens: list, issue, rotate, revoke | An interactive maintainer |
1447
+ | Activity reads | A maintainer principal, interactive or not, within the optional `readGate` |
1448
+
1449
+ Agents and other machine credentials manage nothing. Shared auth is
1450
+ deployment-wide, so every step on a shared connector takes an interactive
1451
+ maintainer: issuing a link through `authorize_connector`, a program's or a
1452
+ failed call's auth handoff, or an elicitation; starting it at `/connect/<id>`;
1453
+ completing the OAuth callback, which re-checks at completion; and
1454
+ `/ui/oauth/<id>` and `/ui/credentials/<id>`. A pool member may connect and
1455
+ disconnect only a personal connector in one of their pools: through
1456
+ `authorize_connector` at `/mcp/<pool>`, the `/connect/<id>` link and its
1457
+ callback, which are bound to the principal that requested them, and
1458
+ `/ui/oauth/<id>` and `/ui/credentials/<id>`, which resolve only their pools'
1459
+ connectors and refuse a shared one with 403. A person who is neither gets the
1460
+ private 404 every operator route gives. A maintainer also manages their own
1461
+ personal connectors, which plain `/mcp` includes, in their own partition. A
1462
+ maintainer's link continues in the operator UI; a member's link starts the
1463
+ authorization directly, as it does on a deployment without the UI. A member's
1464
+ operator contract names, per personal connector, the actions their permission,
1465
+ the deployment and the connector's hooks support: credential `save` with a
1466
+ vault, `clear` once their own partition holds a value, and `test` only when that
1467
+ value matches the declared shape and a matching test hook exists; OAuth
1468
+ `connect` and `restart` with `startAuth`, `finishAuth` and `verifyState`, and
1469
+ `disconnect` with `disconnectAuth`. `/ui/oauth/<id>` gates each verb on those
1470
+ same hooks, so a connector provisioned out of band can still be disconnected,
1471
+ and `/ui/credentials/<id>` refuses to clear a partition that holds nothing
1472
+ with 409 `credential_required`, for members and maintainers alike.
1473
+
1474
+ Activity is read by maintainers alone. An authenticated maintainer principal
1475
+ reads it without an interactive session, including a non-interactive API
1476
+ principal; pool members, agents, subject-only callers, and anonymous requests
1477
+ get the private 404. The optional activity `readGate` still narrows it. Reads
1478
+ remain GET-only and filter connector/tool grants, personal ownership and
1479
+ recorded pool access before any actor-label lookup. Personal rows use the
1480
+ admitted principal in the existing typed actor id and namespace fields; rows
1481
+ without a provable owner are withheld. Non-interactive reads return typed
1482
+ actors without directory labels. Old rows without a pool remain subject to
1483
+ connector, tool and owner checks. A maintainer sees rows from plain `/mcp` and
1484
+ from every declared pool within that pool's tools; rows from a pool no longer
1485
+ declared are withheld. Agent token lifecycle routes require an interactive
1486
+ maintainer with a stable principal; they issue, rotate, and revoke declared
1487
+ agents' tokens, and never create, rename, or bind one.
846
1488
 
847
1489
  Personal activity rows must carry the checked `principal` actor basis as well as
848
1490
  a matching principal ID and namespace. Legacy subject actors cannot prove
@@ -854,18 +1496,10 @@ boundary reads returns 503 without a cursor; cancellation starts no further read
854
1496
  ```ts
855
1497
  createConnecta({
856
1498
  auth: cloudflareAccessAuth(),
857
- identity: {
858
- connectorAccess: ({ principal, actor }) =>
859
- principal?.id === "owner-id"
860
- ? "all"
861
- : actor.id === "calendar-bot"
862
- ? ["calendar.create_event"]
863
- : ["shared_docs", "personal_linear", "notion.search_pages"],
864
- credentialAdministration: ({ principal }) =>
865
- principal?.id === "owner-id" ? "all" : "none",
866
- personalConnection: () => ["personal_linear"],
867
- activityAccess: ({ id }) => id === "owner-id",
868
- },
1499
+ // Administers shared credentials, agent tokens, and activity.
1500
+ maintainers: ["owner-id"],
1501
+ // Each member connects their own account on personal_linear.
1502
+ pools: { team: { tools: ["shared_docs", "personal_linear", "notion.search_pages"], members: team } },
869
1503
  connectors, executor,
870
1504
  readOnly: false, // required: true refuses every write call
871
1505
  });
@@ -889,14 +1523,18 @@ the token against the Worker-level Access application's AUD. A nonempty trusted
889
1523
  `ctx.access.getIdentity()` supplies `user_uuid`, with `email` as fallback, in the
890
1524
  stable `cloudflare-access` principal namespace. Missing identity, service
891
1525
  identity, or unusable user ids refuse. Access service credentials can admit the
892
- request at the edge, but connecta requires a `cta_` token for machine identity.
1526
+ request at the edge, but connecta requires a `cta_` token at `/agent/<pool>`
1527
+ for machine identity.
893
1528
 
894
1529
  It never reads `Cf-Access-Jwt-Assertion`, fetches signing keys, or accepts a
895
- caller's JWT. Missing context or a throwing lookup fails closed on both
896
- runtimes. It does not support Node, a `cloudflared` origin, or a Service Binding
897
- hop. [Decision 0003](https://github.com/zackbart/connecta/blob/main/decisions/0003-inbound-auth.md) records that boundary
898
- and replaces #506's provisional verdict with supported Worker Access.
899
- Connecta configuration still decides connector and management permissions.
1530
+ caller's JWT. Missing or malformed trusted context returns 401. A rejected
1531
+ human identity, including a service identity or an unusable user id, returns 403. A thrown identity lookup or its five-second deadline returns retryable
1532
+ 503 with `problem: "identity_unavailable"` and `Retry-After: 1`. Every refusal
1533
+ carries `Cache-Control: no-store`. None of these failures admits an identity,
1534
+ and an unavailable lookup never falls back to another provider. The operator
1535
+ UI can re-request admission after a failure without a browser reload. It does not support Node, a `cloudflared` origin, or a Service Binding
1536
+ hop.
1537
+ Connecta configuration still decides connector access and management: `maintainers` and pool `members`.
900
1538
 
901
1539
  Protect the Worker with a Worker-level Access application whose destination is
902
1540
  `{ "type": "worker", "worker_id": "<the Worker script tag>" }`. A traditional
@@ -921,13 +1559,16 @@ covers. For another client add its exact URI or the narrowest wildcard covering
921
1559
  it, never its whole origin. A missing entry lets discovery succeed and
922
1560
  registration fail later, which looks like a broken MCP server, not a setting.
923
1561
 
924
- Do not add a bypass for the discovery routes; a fully automated client uses a
1562
+ Do not add a bypass for the discovery routes or any human path. The one
1563
+ bypass connecta expects is the [agent application for `/agent/*`](./deploying.md#agent-endpoints-behind-cloudflare-access),
1564
+ behind which an agent's `cta_` token is its only credential. Worker-level
1565
+ Access runs before every other connecta route, so `/health`, operator pages,
1566
+ downstream OAuth callbacks, `/mcp`, and `/mcp/<pool>` all require Access, and a
1567
+ `cta_` token is not a standalone edge credential there because Cloudflare
1568
+ rejects the request before connecta sees it. Unattended tools that need those
1569
+ paths, such as `connecta doctor` reading `/health`, use a
925
1570
  [service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
926
- through `CF-Access-Client-Id` and `CF-Access-Client-Secret` instead. Worker-level
927
- Access runs before every connecta route, so `/health`, operator pages, downstream
928
- OAuth callbacks, and `/mcp` all require Access unless a more-specific policy says
929
- otherwise, and a `cta_` token is not a standalone edge credential
930
- because Cloudflare rejects the request before connecta sees it. Custom public
1571
+ through `CF-Access-Client-Id` and `CF-Access-Client-Secret`. Custom public
931
1572
  webhooks live outside connecta and need their own Access routing policy. The
932
1573
  [Worker example](../examples/worker/) carries the whole deployment shape.
933
1574
 
@@ -995,7 +1636,7 @@ with no audience/resource claim from exact client IDs dedicated to this
995
1636
  deployment. Never share these clients with another resource server. A present
996
1637
  malformed or mismatched binding is rejected even for an allowlisted client.
997
1638
  The list does not restrict correctly bound tokens. It applies to every pool;
998
- identity and pool grants still narrow access. Profile scopes do not identify a
1639
+ maintainers and pool members still decide access. Profile scopes do not identify a
999
1640
  resource, and `scopes` remains metadata, not an admission rule.
1000
1641
 
1001
1642
  Each new DCR registration gets a new client ID and needs an operator update
@@ -1146,8 +1787,8 @@ Clerk sign-in returns to the same link. Expired session cookies use Clerk's hand
1146
1787
  identity checks or code exchange; responses preserve session cookies. Access supplies trusted Worker identity
1147
1788
  at the edge. Protect `/connect/*` and `/oauth/callback/*` with the MCP endpoint's Access application.
1148
1789
 
1149
- For both connector scopes, the browser must match the initiating principal and retain `personalConnection` or
1150
- shared `credentialAdministration` permission. Connecta saves that principal against downstream state. The
1790
+ For both connector scopes, the browser must match the initiating principal and still be permitted to manage the
1791
+ connector: a maintainer for shared auth, or for personal auth a maintainer or a member of a pool containing it. Connecta saves that principal against downstream state. The
1151
1792
  callback checks state, identity, permission, and RFC 9207 `iss` before consuming
1152
1793
  the handoff, interpreting an error, or exchanging a code. A supplied issuer must
1153
1794
  exactly match the consent. A server advertising issuer-response support also
@@ -1290,7 +1931,7 @@ owner's access token as `Authorization: Bearer` with these rules:
1290
1931
  preventing repeated refreshes by later program calls.
1291
1932
  - Handlers can name no storage, sealing, or owner partition; the registry owns them.
1292
1933
  - The answer's `.text()` and `.json()`, clones' too, decode its bytes as UTF-8, so
1293
- workerd never quotes a downstream's Content-Type in its own log (INV-6).
1934
+ workerd never quotes a downstream's Content-Type in its own log.
1294
1935
 
1295
1936
  The grant record and consents, epoch fencing, vault sealing, shared/personal ownership,
1296
1937
  callback state/principal checks, `authorize_connector`, Connect, Restart, and
@@ -1302,7 +1943,7 @@ For providers offering both, deployment config chooses; handlers check `ctx.oaut
1302
1943
 
1303
1944
  ## Request-local downstream Bearer tokens
1304
1945
 
1305
- `remoteMcp({ auth: { type: "request", token: async (ctx) => … } })` resolves
1946
+ `remoteMcp("<id>", { auth: { type: "request", token: async (ctx) => … } })` resolves
1306
1947
  its token before connecting and before reusing a client. The callback receives
1307
1948
  the operation's cancellation context, never an agent-selected credential owner.
1308
1949
  Only shared ownership is accepted. Tokens remain inside the request's hardened
@@ -1320,7 +1961,14 @@ is retained across requests, and rejected writes are never replayed. An omitted
1320
1961
  `describe()` includes neither the key nor the minted tokens. The maintained
1321
1962
  provider skill explains App permissions and installation prerequisites.
1322
1963
 
1323
- ## Source and tests
1964
+ ## Contributor reference
1965
+
1966
+ ### Source and tests
1967
+
1968
+ [Decision 0007](https://github.com/zackbart/connecta/blob/main/decisions/0007-agent-pool-layout.md)
1969
+ explains the pool layout; [decision 0006](https://github.com/zackbart/connecta/blob/main/decisions/0006-pools-are-the-access-model.md)
1970
+ explains pools as the access model. `test/identity-scope.test.ts` checks identity
1971
+ boundaries, and `src/storage/keys.ts` owns credential key families.
1324
1972
 
1325
1973
  Inbound adapters live in [src/auth](https://github.com/zackbart/connecta/tree/main/src/auth);
1326
1974
  [auth routes](https://github.com/zackbart/connecta/blob/main/src/routes/oauth.ts)